diff options
Diffstat (limited to 'crates/atuin-ai/src/tui')
| -rw-r--r-- | crates/atuin-ai/src/tui/components/atuin_ai.rs | 143 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/input_box.rs | 220 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/markdown.rs | 210 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/mod.rs | 5 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/select.rs | 95 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/session_continue.rs | 49 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/content/help.md | 6 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/events.rs | 67 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/mod.rs | 7 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/slash.rs | 79 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 237 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 978 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 606 |
13 files changed, 0 insertions, 2702 deletions
diff --git a/crates/atuin-ai/src/tui/components/atuin_ai.rs b/crates/atuin-ai/src/tui/components/atuin_ai.rs deleted file mode 100644 index 31dff1c3..00000000 --- a/crates/atuin-ai/src/tui/components/atuin_ai.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Top-level AtuinAi component that translates key events into AiTuiEvents. -//! -//! Global shortcuts (Ctrl+C, Esc) are handled in the capture phase so they -//! fire regardless of which child is focused. Contextual shortcuts (Enter, -//! Tab) are handled in the bubble phase so child components like the -//! permission Select can consume them first. - -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use eye_declare::{Elements, EventResult, Hooks, component, props}; - -use crate::commands::inline::DriverEventSender; -use crate::tui::events::AiTuiEvent; -use crate::tui::state::AppMode; - -/// Top-level wrapper component for the AI TUI. -/// -/// Props carry the current mode so `handle_event` can translate keys -/// into the right `AiTuiEvent`. Children are rendered via slot children. -#[props] -pub(crate) struct AtuinAi { - pub mode: AppMode, - pub has_command: bool, - pub is_input_blank: bool, - pub pending_confirmation: bool, - pub has_executing_preview: bool, -} - -#[derive(Default)] -pub(crate) struct AtuinAiState { - tx: Option<DriverEventSender>, -} - -#[component(props = AtuinAi, state = AtuinAiState, children = Elements)] -fn atuin_ai( - _props: &AtuinAi, - _state: &AtuinAiState, - hooks: &mut Hooks<AtuinAi, AtuinAiState>, - children: Elements, -) -> Elements { - hooks.use_context::<DriverEventSender>(|tx, _, state| { - state.tx = tx.cloned(); - }); - - // Capture phase: global shortcuts that must fire regardless of child focus. - hooks.use_event_capture(move |event, props, state| { - let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - modifiers, - .. - }) = event - else { - return EventResult::Ignored; - }; - - let Some(ref tx) = state.read().tx else { - return EventResult::Ignored; - }; - - // Ctrl+C — interrupt executing command or exit - if modifiers.contains(KeyModifiers::CONTROL) && *code == KeyCode::Char('c') { - if props.has_executing_preview { - let _ = tx.send(AiTuiEvent::InterruptToolExecution); - } else { - let _ = tx.send(AiTuiEvent::Exit); - } - return EventResult::Consumed; - } - - // Esc — always handled at the top level - if *code == KeyCode::Esc { - match props.mode { - AppMode::Input => { - if props.has_executing_preview { - let _ = tx.send(AiTuiEvent::InterruptToolExecution); - } else if props.pending_confirmation { - let _ = tx.send(AiTuiEvent::CancelConfirmation); - } else { - let _ = tx.send(AiTuiEvent::Exit); - } - } - AppMode::Generating | AppMode::Streaming => { - let _ = tx.send(AiTuiEvent::CancelGeneration); - } - AppMode::Error => { - let _ = tx.send(AiTuiEvent::Exit); - } - } - return EventResult::Consumed; - } - - if *code == KeyCode::Tab - && matches!(props.mode, AppMode::Input) - && modifiers.contains(KeyModifiers::NONE) - && props.has_command - && props.is_input_blank - { - let _ = tx.send(AiTuiEvent::InsertCommand); - return EventResult::Consumed; - } - - EventResult::Ignored - }); - - // Bubble phase: contextual shortcuts that children (e.g. Select) may handle first. - hooks.use_event(move |event, props, state| { - let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = event - else { - return EventResult::Ignored; - }; - - let Some(ref tx) = state.read().tx else { - return EventResult::Ignored; - }; - - match props.mode { - AppMode::Input => match code { - KeyCode::Enter => { - if props.has_command && props.is_input_blank { - let _ = tx.send(AiTuiEvent::ExecuteCommand); - return EventResult::Consumed; - } - EventResult::Ignored - } - _ => EventResult::Ignored, - }, - AppMode::Error => match code { - KeyCode::Enter | KeyCode::Char('r') => { - let _ = tx.send(AiTuiEvent::Retry); - EventResult::Consumed - } - _ => EventResult::Ignored, - }, - _ => EventResult::Ignored, - } - }); - - children -} diff --git a/crates/atuin-ai/src/tui/components/input_box.rs b/crates/atuin-ai/src/tui/components/input_box.rs deleted file mode 100644 index 6b81322c..00000000 --- a/crates/atuin-ai/src/tui/components/input_box.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Bordered input box component for the AI TUI. -//! -//! Wraps tui-textarea's TextArea, which handles rendering, wrapping, cursor -//! positioning, and height measurement natively. The component configures the -//! TextArea's block (border + titles) and forwards events to it. -//! -//! On Enter, sends `AiTuiEvent::SubmitInput` via the context-provided channel. - -use std::sync::{Arc, Mutex}; - -use crossterm::event::KeyModifiers; -use eye_declare::{Canvas, Elements, EventResult, Hooks, component, element, props}; -use ratatui::widgets::{Block, Borders, Padding}; -use ratatui_core::{ - layout::Rect, - style::{Color, Modifier, Style}, - text::Line, - widgets::Widget, -}; -use tui_textarea::TextArea; - -use crate::commands::inline::DriverEventSender; -use crate::tui::{events::AiTuiEvent, slash::SlashCommandSearchResult}; - -/// A bordered text input box backed by tui-textarea. -/// -/// Props configure the chrome (title, footer). The TextArea itself lives -/// in the component's State so it owns cursor, wrapping, and rendering. -#[props] -pub(crate) struct InputBox { - /// Title shown in top-left border - pub title: String, - /// Right-side label in top border - pub title_right: String, - /// Footer text shown in bottom border (keybinding hints) - pub footer: String, - /// Whether the input is currently active (shows cursor, accepts input) - pub active: bool, - /// If the user has typed a slash command, this holds the best match for it. - pub slash_suggestion: Option<SlashCommandSearchResult>, -} - -pub(crate) struct InputBoxState { - textarea: Arc<Mutex<TextArea<'static>>>, - tx: Option<DriverEventSender>, -} - -impl Default for InputBoxState { - fn default() -> Self { - let mut textarea = TextArea::default(); - textarea.set_cursor_line_style(ratatui::style::Style::default()); - textarea.set_wrap_mode(tui_textarea::WrapMode::Word); - textarea.set_placeholder_text("Type a message..."); - textarea.set_placeholder_style( - ratatui::style::Style::default() - .fg(ratatui::style::Color::DarkGray) - .add_modifier(ratatui::style::Modifier::ITALIC), - ); - Self { - textarea: Arc::new(Mutex::new(textarea)), - tx: None, - } - } -} - -fn make_block(props: &InputBox) -> Block<'static> { - let border_style = Style::default().fg(Color::DarkGray); - let title_style = Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::BOLD); - - let mut block = Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .padding(Padding::horizontal(1)); - - if !props.title.is_empty() { - block = - block.title_top(Line::styled(format!(" {} ", props.title), title_style).left_aligned()); - } - if !props.title_right.is_empty() { - block = block.title_top( - Line::styled(format!(" {} ", props.title_right), border_style).right_aligned(), - ); - } - if !props.footer.is_empty() { - block = block.title_bottom( - Line::styled(format!(" {} ", props.footer), border_style).right_aligned(), - ); - } - - block -} - -#[component(props = InputBox, state = InputBoxState)] -fn input_box( - props: &InputBox, - state: &InputBoxState, - hooks: &mut Hooks<InputBox, InputBoxState>, -) -> Elements { - // Always focusable so focus isn't lost when the permission Select is - // removed from the tree. The `active` prop controls visual state and - // whether keystrokes are processed, not focusability. - hooks.use_focusable(true); - hooks.use_autofocus(); - - hooks.use_context::<DriverEventSender>(|tx, _, state| { - state.tx = tx.cloned(); - }); - - hooks.use_event(move |event, props, state| { - let state = state.read(); - - if !props.active { - return EventResult::Ignored; - } - - if let crossterm::event::Event::Paste(text) = event { - let mut textarea = state.textarea.lock().unwrap(); - textarea.insert_str(text); - return EventResult::Consumed; - } - - if let crossterm::event::Event::Key(key) = event { - if key.kind != crossterm::event::KeyEventKind::Press { - return EventResult::Ignored; - } - - let mut textarea = state.textarea.lock().unwrap(); - - match key.code { - crossterm::event::KeyCode::Char('j') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { - textarea.insert_newline(); - return EventResult::Consumed; - } - crossterm::event::KeyCode::Tab if props.slash_suggestion.is_some() => { - // If there's a slash command suggestion, Tab accepts it. - if let Some(suggestion) = &props.slash_suggestion { - textarea.clear(); - textarea.insert_str(format!("/{}", suggestion.command.name)); - // Manually trigger an input update event so the slash suggestion box can update immediately - if let Some(ref tx) = state.tx { - let _ = tx.send(AiTuiEvent::InputUpdated(textarea.lines().join("\n"))); - } - return EventResult::Consumed; - } - } - crossterm::event::KeyCode::Enter => { - if key.modifiers.contains(KeyModifiers::SHIFT) { - textarea.insert_newline(); - return EventResult::Consumed; - } else { - let text = textarea.lines().join("\n"); - if text.trim().is_empty() { - return EventResult::Ignored; - } - - textarea.clear(); - - if let Some(ref tx) = state.tx { - let _ = tx.send(AiTuiEvent::SubmitInput(text)); - } - return EventResult::Consumed; - } - } - _ => {} - } - - // All other keys: forward to textarea. - // tui-textarea can convert crossterm events itself. - textarea.input(*key); - - if let Some(ref tx) = state.tx { - let _ = tx.send(AiTuiEvent::InputUpdated(textarea.lines().join("\n"))); - } - return EventResult::Consumed; - } - - EventResult::Ignored - }); - - let textarea = state.textarea.clone(); - let block = make_block(props); - let active = props.active; - element!( - Canvas(render_fn: move |area, buf| { - let mut area = area; - - if area.height < 3 || area.width < 4 { - return; - } - - let height = { - // TextArea handles scrolling internally if content overflows. - let inner = block.inner(Rect::new(0, 0, area.width, u16::MAX)); - let chrome = (u16::MAX).saturating_sub(inner.height); - let content = textarea.lock().unwrap().measure(area.width - 4); - chrome + content.preferred_rows - }; - - area.height = height.min(7); - let inner = block.clone().inner(area); - block.clone().render(area, buf); - - let mut textarea = textarea.lock().unwrap(); - if active { - textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); - textarea.set_placeholder_text("Type a message..."); - } else { - textarea.set_cursor_style(Style::default()); - textarea.set_placeholder_text(""); - } - - // Render textarea into the inner area - textarea.render(inner, buf); - }) - ) -} diff --git a/crates/atuin-ai/src/tui/components/markdown.rs b/crates/atuin-ai/src/tui/components/markdown.rs deleted file mode 100644 index 607520b7..00000000 --- a/crates/atuin-ai/src/tui/components/markdown.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Markdown rendering component using pulldown-cmark. -//! -//! More robust than eye-declare's built-in Markdown component: -//! uses a proper CommonMark parser rather than line-by-line regex. - -use eye_declare::{Component, props}; -use pulldown_cmark::{Event, Parser, Tag, TagEnd}; -use ratatui_core::{ - buffer::Buffer, - layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::Widget, -}; -use ratatui_widgets::paragraph::{Paragraph, Wrap}; - -/// A markdown rendering component backed by pulldown-cmark. -#[props] -pub(crate) struct Markdown { - pub source: String, -} - -/// Style configuration for markdown rendering. -pub(crate) struct MarkdownStyles { - pub base: Style, - pub code_inline: Style, - pub code_block: Style, - pub bold: Style, - pub italic: Style, - pub heading: Style, -} - -impl MarkdownStyles { - pub fn new() -> Self { - let base = Style::default(); - Self { - base, - code_inline: Style::default().fg(Color::Yellow), - code_block: Style::default().fg(Color::Green), - bold: base.add_modifier(Modifier::BOLD), - italic: base.add_modifier(Modifier::ITALIC), - heading: Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - } - } -} - -impl Default for MarkdownStyles { - fn default() -> Self { - Self::new() - } -} - -impl Component for Markdown { - type State = MarkdownStyles; - - fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) { - if self.source.is_empty() || area.width == 0 || area.height == 0 { - return; - } - let text = parse_markdown(&self.source, state); - Paragraph::new(text) - .wrap(Wrap { trim: false }) - .render(area, buf); - } - - fn desired_height(&self, width: u16, state: &Self::State) -> Option<u16> { - if self.source.is_empty() || width == 0 { - return Some(0); - } - let text = parse_markdown(&self.source, state); - Some( - Paragraph::new(text) - .wrap(Wrap { trim: false }) - .line_count(width) as u16, - ) - } - - fn initial_state(&self) -> Option<MarkdownStyles> { - Some(MarkdownStyles::new()) - } -} - -/// Parse markdown source into styled ratatui Text using pulldown-cmark. -fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'static> { - let parser = Parser::new(source); - let mut lines: Vec<Vec<Span<'static>>> = vec![Vec::new()]; - let mut current_line = 0; - - let mut style_stack: Vec<Style> = vec![styles.base]; - let mut in_code_block = false; - let mut in_list_item = false; - // True until the first paragraph inside a list item has been opened. - // The first paragraph should flow inline with the "- " prefix. - let mut list_item_first_para = false; - - for event in parser { - match event { - Event::Start(Tag::Strong) => { - let bold = style_stack.last().copied().unwrap_or(styles.bold); - style_stack.push(bold); - } - Event::End(TagEnd::Strong) => { - style_stack.pop(); - } - Event::Start(Tag::Emphasis) => { - let italic = style_stack.last().copied().unwrap_or(styles.italic); - style_stack.push(italic); - } - Event::End(TagEnd::Emphasis) => { - style_stack.pop(); - } - Event::Start(Tag::CodeBlock(_)) => { - in_code_block = true; - if !lines[current_line].is_empty() { - current_line += 1; - lines.push(Vec::new()); - current_line += 1; - lines.push(Vec::new()); - } - } - Event::End(TagEnd::CodeBlock) => { - in_code_block = false; - if !lines[current_line].is_empty() { - current_line += 1; - lines.push(Vec::new()); - } - } - Event::Code(code) => { - lines[current_line].push(Span::styled(format!("{}", code), styles.code_inline)); - } - Event::Text(text) => { - let current_style = if in_code_block { - styles.code_block - } else { - style_stack.last().copied().unwrap_or(styles.base) - }; - let prefix = if in_code_block { " " } else { "" }; - let parts: Vec<&str> = text.split('\n').collect(); - for (i, part) in parts.iter().enumerate() { - if i > 0 { - current_line += 1; - lines.push(Vec::new()); - } - if !part.is_empty() { - lines[current_line] - .push(Span::styled(format!("{}{}", prefix, part), current_style)); - } - } - } - Event::SoftBreak => { - let current_style = style_stack.last().copied().unwrap_or(styles.base); - lines[current_line].push(Span::styled(" ", current_style)); - } - Event::HardBreak => { - current_line += 1; - lines.push(Vec::new()); - } - Event::Start(Tag::Paragraph) => { - if in_list_item && list_item_first_para { - // First paragraph flows inline with the "- " prefix - list_item_first_para = false; - } else if current_line > 0 || !lines[0].is_empty() { - current_line += 1; - lines.push(Vec::new()); - if !in_list_item { - // Blank separator between paragraphs (but not inside list items) - current_line += 1; - lines.push(Vec::new()); - } - } - } - Event::End(TagEnd::Paragraph) => {} - Event::Start(Tag::Heading { .. }) => { - if current_line > 0 || !lines[0].is_empty() { - current_line += 1; - lines.push(Vec::new()); - current_line += 1; - lines.push(Vec::new()); - } - style_stack.push(styles.heading); - } - Event::End(TagEnd::Heading(_)) => { - style_stack.pop(); - } - Event::Start(Tag::Item) => { - if current_line > 0 || !lines[0].is_empty() { - current_line += 1; - lines.push(Vec::new()); - } - lines[current_line].push(Span::styled("- ", Style::default().fg(Color::DarkGray))); - in_list_item = true; - list_item_first_para = true; - } - Event::End(TagEnd::Item) => { - in_list_item = false; - } - Event::Start(Tag::List(_)) if current_line > 0 || !lines[0].is_empty() => { - current_line += 1; - lines.push(Vec::new()); - } - Event::End(TagEnd::List(_)) => {} - _ => {} - } - } - - let text_lines: Vec<Line<'static>> = lines.into_iter().map(Line::from).collect(); - Text::from(text_lines) -} diff --git a/crates/atuin-ai/src/tui/components/mod.rs b/crates/atuin-ai/src/tui/components/mod.rs deleted file mode 100644 index 9959dbad..00000000 --- a/crates/atuin-ai/src/tui/components/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub(crate) mod atuin_ai; -pub(crate) mod input_box; -pub(crate) mod markdown; -pub(crate) mod select; -pub(crate) mod session_continue; diff --git a/crates/atuin-ai/src/tui/components/select.rs b/crates/atuin-ai/src/tui/components/select.rs deleted file mode 100644 index 771d7830..00000000 --- a/crates/atuin-ai/src/tui/components/select.rs +++ /dev/null @@ -1,95 +0,0 @@ -use crossterm::event::KeyCode; -use eye_declare::{Elements, EventResult, Hooks, Span, Text, View, component, element, props}; -use ratatui::style::Style; -use typed_builder::TypedBuilder; - -use crate::commands::inline::DriverEventSender; -use crate::tui::events::AiTuiEvent; - -type OnSelectFn = Box<dyn Fn(&SelectOption) -> Option<AiTuiEvent> + Send + Sync + 'static>; - -#[derive(TypedBuilder)] -pub(crate) struct SelectOption { - #[builder(setter(into))] - pub label: String, - #[builder(setter(into))] - pub value: String, - #[builder(default = Style::default())] - pub label_style: Style, - #[builder(default = Style::default().reversed())] - pub selected_style: Style, -} - -#[derive(Default)] -pub(crate) struct PermissionSelectorState { - selected_option: usize, - tx: Option<DriverEventSender>, -} - -#[props] -pub(crate) struct Select { - pub options: Vec<SelectOption>, - pub on_select: OnSelectFn, -} - -#[component(props = Select, state = PermissionSelectorState)] -pub(crate) fn permission_selector( - props: &Select, - state: &PermissionSelectorState, - hooks: &mut Hooks<Select, PermissionSelectorState>, -) -> Elements { - hooks.use_focusable(true); - hooks.use_autofocus(); - - hooks.use_context::<DriverEventSender>(|tx, _, state| { - state.tx = tx.cloned(); - }); - - hooks.use_event(move |event, props, state| { - if !event.is_key_press() { - return EventResult::Ignored; - } - - if let crossterm::event::Event::Key(key) = event { - if key.kind != crossterm::event::KeyEventKind::Press { - return EventResult::Ignored; - } - - match key.code { - KeyCode::Up => { - state.selected_option = - (state.selected_option + props.options.len() - 1) % props.options.len(); - return EventResult::Consumed; - } - KeyCode::Down => { - state.selected_option = (state.selected_option + 1) % props.options.len(); - return EventResult::Consumed; - } - KeyCode::Enter => { - let option = &props.options[state.selected_option]; - if let Some(event) = (props.on_select)(option) - && let Some(ref tx) = state.tx - { - let _ = tx.send(event); - } - return EventResult::Consumed; - } - _ => {} - } - } - - EventResult::Ignored - }); - - element!( - View { - #(for (index, option) in props.options.iter().enumerate() { - Text { Span(text: &option.label, style: if index == state.selected_option { - option.selected_style - } else { - option.label_style - }) } - }) - } - ) -} diff --git a/crates/atuin-ai/src/tui/components/session_continue.rs b/crates/atuin-ai/src/tui/components/session_continue.rs deleted file mode 100644 index bfbfb191..00000000 --- a/crates/atuin-ai/src/tui/components/session_continue.rs +++ /dev/null @@ -1,49 +0,0 @@ -use chrono_humanize::HumanTime; -use eye_declare::{Elements, Hooks, Span, Text, component, element, props}; -use ratatui::style::{Color, Modifier, Style}; - -#[props] -pub(crate) struct SessionContinue { - pub continued_at: Option<chrono::DateTime<chrono::Utc>>, -} - -#[derive(Default)] -pub(crate) struct SessionContinueState { - /// Frozen on mount so the label doesn't change on every render. - label: Option<String>, -} - -#[component(props = SessionContinue, state = SessionContinueState)] -fn session_continue( - _props: &SessionContinue, - state: &SessionContinueState, - hooks: &mut Hooks<SessionContinue, SessionContinueState>, -) -> Elements { - hooks.use_mount(|props, state| { - state.label = Some(match props.continued_at { - Some(t) => { - let human = HumanTime::from(t - chrono::Utc::now()); - format!( - " Continuing previous session (last active {human}) - type /new to start a new session" - ) - } - None => { - " Continuing previous session - type /new to start a new session".to_string() - } - }); - }); - - let resume_label = state - .label - .as_deref() - .unwrap_or(" Continuing previous session - type /new to start a new session"); - - element! { - Text { - Span( - text: resume_label, - style: Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), - ) - } - } -} diff --git a/crates/atuin-ai/src/tui/content/help.md b/crates/atuin-ai/src/tui/content/help.md deleted file mode 100644 index d6623ac9..00000000 --- a/crates/atuin-ai/src/tui/content/help.md +++ /dev/null @@ -1,6 +0,0 @@ -Welcome to Atuin AI, an AI assistant in your terminal. You can ask it to generate a shell command for you, or ask general terminal or software questions. - -Commands: -{commands} - -For more information, see [https://docs.atuin.sh/cli/ai/introduction/](https://docs.atuin.sh/cli/ai/introduction/) diff --git a/crates/atuin-ai/src/tui/events.rs b/crates/atuin-ai/src/tui/events.rs deleted file mode 100644 index abcb1bd9..00000000 --- a/crates/atuin-ai/src/tui/events.rs +++ /dev/null @@ -1,67 +0,0 @@ -/// Application-domain events emitted by UI components. -/// -/// Components translate raw key events into these semantic events, -/// which are sent via an `mpsc::Sender<AiTuiEvent>` provided through -/// eye-declare's context system. The main event loop in `inline.rs` -/// receives them and mutates `AppState` accordingly. -#[derive(Debug)] -pub(crate) enum AiTuiEvent { - /// User updated the input text - InputUpdated(String), - /// User submitted text input (Enter in Input mode) - SubmitInput(String), - /// User entered a slash command (e.g. "/help") - #[allow(unused)] - SlashCommand(String), - /// User selected a permission - SelectPermission(PermissionResult), - /// Cancel active generation or streaming (Esc during Generating/Streaming) - CancelGeneration, - /// Execute the suggested command - ExecuteCommand, - /// Insert command without executing - InsertCommand, - /// Cancel confirmation of dangerous command - CancelConfirmation, - /// Interrupt a running tool execution (Ctrl+C during ExecutingPreview) - InterruptToolExecution, - /// Retry after error - Retry, - /// Exit the application - Exit, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum PermissionResult { - Allow, - /// Per-file, time-limited grant scoped to the current session. - AllowFileForSession, - AlwaysAllowInDir, - AlwaysAllow, - Deny, -} - -impl PermissionResult { - /// String identifier used as the SelectOption value. - pub fn as_value_str(&self) -> &'static str { - match self { - Self::Allow => "allow", - Self::AllowFileForSession => "allow-file-session", - Self::AlwaysAllowInDir => "always-allow-in-dir", - Self::AlwaysAllow => "always-allow", - Self::Deny => "deny", - } - } - - /// Parse from a SelectOption value string. - pub fn from_value_str(s: &str) -> Option<Self> { - match s { - "allow" => Some(Self::Allow), - "allow-file-session" => Some(Self::AllowFileForSession), - "always-allow-in-dir" => Some(Self::AlwaysAllowInDir), - "always-allow" => Some(Self::AlwaysAllow), - "deny" => Some(Self::Deny), - _ => None, - } - } -} diff --git a/crates/atuin-ai/src/tui/mod.rs b/crates/atuin-ai/src/tui/mod.rs deleted file mode 100644 index 9727f362..00000000 --- a/crates/atuin-ai/src/tui/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub(crate) mod components; -pub(crate) mod events; -pub(crate) mod slash; -pub(crate) mod state; -pub(crate) mod view; - -pub(crate) use state::{ConversationEvent, events_to_messages}; diff --git a/crates/atuin-ai/src/tui/slash.rs b/crates/atuin-ai/src/tui/slash.rs deleted file mode 100644 index 7d5e6fa8..00000000 --- a/crates/atuin-ai/src/tui/slash.rs +++ /dev/null @@ -1,79 +0,0 @@ -#[derive(Debug, Clone)] -pub(crate) struct SlashCommand { - pub name: String, - pub description: String, -} - -impl SlashCommand { - pub fn new(name: &str, description: &str) -> Self { - Self { - name: name.to_string(), - description: description.to_string(), - } - } -} - -#[derive(Debug)] -pub(crate) struct SlashCommandRegistry { - commands: Vec<SlashCommand>, -} - -#[derive(Debug, Clone)] -pub(crate) struct SlashCommandSearchResult { - pub command: SlashCommand, - pub relevance: f32, - pub span: (usize, usize), -} - -impl SlashCommandRegistry { - pub fn new() -> Self { - Self { - commands: Vec::new(), - } - } - - pub fn register(&mut self, command: SlashCommand) { - self.commands.push(command); - } - - pub fn get_commands(&self) -> &[SlashCommand] { - &self.commands - } - - pub fn search_fuzzy(&self, query: &str) -> Vec<SlashCommandSearchResult> { - let query_lower = query.to_lowercase(); - - self.commands - .iter() - .filter_map(|command| { - let name_lower = command.name.to_lowercase(); - if let Some(start) = name_lower.find(&query_lower as &str) { - let end = start + query_lower.len(); - Some((command, start, end)) - } else { - None - } - }) - .map(|(command, start, end)| { - SlashCommandSearchResult { - command: command.clone(), - relevance: 1.0, // Simple relevance score for now - span: (start, end), - } - }) - .collect() - } -} - -impl Default for SlashCommandRegistry { - fn default() -> Self { - let mut registry = Self::new(); - registry.register(SlashCommand::new("help", "Show help information")); - registry.register(SlashCommand::new( - "new", - "Start a new conversation, archiving the current one", - )); - - registry - } -} diff --git a/crates/atuin-ai/src/tui/state.rs b/crates/atuin-ai/src/tui/state.rs deleted file mode 100644 index 71da6ff5..00000000 --- a/crates/atuin-ai/src/tui/state.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Core state types for the conversation protocol. -//! -//! ConversationEvent and events_to_messages are the canonical representations -//! used by both the FSM and the context window builder. AppMode is used by -//! the view layer for component prop derivation. - -/// Conversation event types matching the API protocol. -#[derive(Debug, Clone)] -pub(crate) enum ConversationEvent { - /// User message (what the user typed) - UserMessage { content: String }, - /// Text content from assistant (streamed or complete) - Text { content: String }, - /// Tool call from assistant - ToolCall { - id: String, - name: String, - input: serde_json::Value, - }, - /// Tool result (from server-side or client-side execution) - ToolResult { - tool_use_id: String, - content: String, - is_error: bool, - /// Server-side results are stored in the DB; the client sends an opaque - /// reference (`remote: true`) instead of the full content. - remote: bool, - /// Approximate content length for token estimation of remote results. - content_length: Option<usize>, - }, - /// Out-of-band output from the system — not sent to the server - OutOfBandOutput { - name: String, - command: Option<String>, - content: String, - }, - /// Context injected for the LLM that is not rendered in the TUI. - /// Converted to a user message in the API protocol. - SystemContext { content: String }, - /// A skill was loaded and its content injected into the conversation. - /// Serialized as a full user message for the API but rendered compactly - /// in the TUI (just the `/name args` invocation line). - SkillInvocation { - name: String, - arguments: Option<String>, - content: String, - }, -} - -impl ConversationEvent { - /// Whether this event represents actual conversation content sent to the API. - pub(crate) fn is_api_content(&self) -> bool { - match self { - ConversationEvent::UserMessage { .. } => true, - ConversationEvent::Text { .. } => true, - ConversationEvent::ToolCall { .. } => true, - ConversationEvent::ToolResult { .. } => true, - ConversationEvent::OutOfBandOutput { .. } => false, - ConversationEvent::SystemContext { .. } => false, - ConversationEvent::SkillInvocation { .. } => true, - } - } - - /// Extract command from a suggest_command tool call. - pub(crate) fn as_command(&self) -> Option<&str> { - if let ConversationEvent::ToolCall { name, input, .. } = self - && name == "suggest_command" - { - return input.get("command").and_then(|v| v.as_str()); - } - None - } -} - -/// Application mode for key handling and component props. -/// -/// Derived from AgentState in the view layer via `From<&AgentState>`. -#[derive(Debug, Clone, PartialEq, Eq, Copy)] -pub(crate) enum AppMode { - /// User is typing input - Input, - /// Waiting for generation (showing spinner) - Generating, - /// Streaming SSE response - Streaming, - /// Error state, can retry - Error, -} - -/// Convert a slice of conversation events to Claude API message format. -/// -/// This is the canonical event-to-message conversion, used by the context window -/// builder to convert turn slices independently. The logic handles combining -/// adjacent Text + ToolCall events into single assistant messages with mixed -/// content blocks. -pub(crate) fn events_to_messages(events: &[ConversationEvent]) -> Vec<serde_json::Value> { - let mut messages = Vec::new(); - let mut i = 0; - - while i < events.len() { - match &events[i] { - ConversationEvent::UserMessage { content } => { - messages.push(serde_json::json!({ - "role": "user", - "content": content - })); - i += 1; - } - ConversationEvent::Text { content } if content.is_empty() => { - i += 1; - } - ConversationEvent::Text { content } => { - let next_is_tool_call = events - .get(i + 1) - .is_some_and(|e| matches!(e, ConversationEvent::ToolCall { .. })); - - if next_is_tool_call { - let mut content_blocks = Vec::new(); - - if !content.is_empty() { - content_blocks.push(serde_json::json!({ - "type": "text", - "text": content - })); - } - - while let Some(ConversationEvent::ToolCall { - id, name, input, .. - }) = events.get(i + 1) - { - content_blocks.push(serde_json::json!({ - "type": "tool_use", - "id": id, - "name": name, - "input": input - })); - i += 1; - } - - messages.push(serde_json::json!({ - "role": "assistant", - "content": content_blocks - })); - i += 1; - } else { - messages.push(serde_json::json!({ - "role": "assistant", - "content": content - })); - i += 1; - } - } - ConversationEvent::ToolCall { .. } => { - let mut tool_uses = Vec::new(); - while i < events.len() { - if let ConversationEvent::ToolCall { - id, name, input, .. - } = &events[i] - { - tool_uses.push(serde_json::json!({ - "type": "tool_use", - "id": id, - "name": name, - "input": input - })); - i += 1; - } else { - break; - } - } - messages.push(serde_json::json!({ - "role": "assistant", - "content": tool_uses - })); - } - ConversationEvent::ToolResult { - tool_use_id, - content, - is_error, - remote, - content_length, - } => { - let tool_result = if *remote { - let mut obj = serde_json::json!({ - "type": "tool_result", - "tool_use_id": tool_use_id, - "remote": true, - "is_error": is_error - }); - if let Some(len) = content_length { - obj["content_length"] = serde_json::json!(len); - } - obj - } else { - serde_json::json!({ - "type": "tool_result", - "tool_use_id": tool_use_id, - "content": content, - "is_error": is_error - }) - }; - messages.push(serde_json::json!({ - "role": "user", - "content": [tool_result] - })); - i += 1; - } - ConversationEvent::OutOfBandOutput { .. } => { - i += 1; - } - ConversationEvent::SystemContext { content } => { - messages.push(serde_json::json!({ - "role": "user", - "content": content - })); - i += 1; - } - ConversationEvent::SkillInvocation { - name, - arguments, - content, - } => { - let header = match arguments { - Some(args) => format!("[Loaded skill: {name}]\n[Arguments: {args}]"), - None => format!("[Loaded skill: {name}]"), - }; - messages.push(serde_json::json!({ - "role": "user", - "content": format!("{header}\n\n{content}") - })); - i += 1; - } - } - } - - messages -} diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs deleted file mode 100644 index b594cedf..00000000 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ /dev/null @@ -1,978 +0,0 @@ -//! View function that builds the eye-declare element tree from app state. - -use eye_declare::{ - Cells, Column, Elements, HStack, Span, Spinner, Text, View, Viewport, WidthConstraint, element, -}; -use ratatui_core::style::{Color, Modifier, Style}; - -use crate::driver::ViewState; -use crate::fsm::{AgentState, StreamPhase}; -use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview}; -use crate::tui::components::select::SelectOption; -use crate::tui::components::session_continue::SessionContinue; -use crate::tui::events::{AiTuiEvent, PermissionResult}; - -use super::components::atuin_ai::AtuinAi; -use super::components::input_box::InputBox; -use super::components::markdown::Markdown; -use super::components::select::Select; -use super::state::AppMode; - -pub(crate) mod turn; - -impl From<&AgentState> for AppMode { - fn from(state: &AgentState) -> Self { - match state { - AgentState::Idle { .. } => AppMode::Input, - AgentState::Turn { - stream: StreamPhase::Connecting, - } => AppMode::Generating, - AgentState::Turn { .. } => AppMode::Streaming, - AgentState::Error(_) => AppMode::Error, - } - } -} - -/// Build the element tree from current state. -/// -/// Layout (top to bottom): -/// - Conversation messages (user messages, agent responses, tool status) -/// - Streaming content (if actively streaming) -/// - Error display (if in error state) -/// - Spacer -/// - Input box (bordered, with contextual keybindings) -pub(crate) fn ai_view(state: &ViewState) -> Elements { - let committed = state.committed_turn_count; - let turns: Vec<&turn::UiTurn> = state.turns.iter().filter(|t| t.id >= committed).collect(); - let busy = state.is_busy(); - let last_index = turns.len().saturating_sub(1); - - // Turns are direct children of the root VStack so that eye_declare's - // on_commit can detect them scrolling into terminal scrollback and - // prune them from the tree. AtuinAi wraps only the interactive footer - // (input box, error display, pending banner) so its event capture/bubble - // handlers still fire for keyboard events. - element! { - #(if state.is_resumed && (!state.is_exiting() || !turns.is_empty()) { - SessionContinue(key: "continuation-notice", continued_at: state.last_event_time) - }) - - #(for (index, turn) in turns.iter().enumerate() { - #(match &turn.kind { - turn::UiTurnKind::User { events } => { - user_turn_view(events, index == 0, turn.id) - } - turn::UiTurnKind::Agent { events } => { - agent_turn_view(events, busy && index == last_index, state.tools.awaiting_permission().is_some(), turn.id) - } - turn::UiTurnKind::OutOfBand { events } => { - out_of_band_turn_view(events, turn.id) - } - }) - }) - - AtuinAi( - key: "footer", - mode: AppMode::from(&state.agent_state), - has_command: state.has_command, - is_input_blank: state.is_input_blank, - pending_confirmation: state.has_confirmation(), - has_executing_preview: state.tools.has_executing_preview(), - ) { - #({ - let needs_pending_banner = busy && !matches!(turns.last(), Some(turn::UiTurn { kind: turn::UiTurnKind::Agent { .. }, .. })); - if needs_pending_banner { - let empty: &[turn::UiEvent] = &[]; - agent_turn_view(empty, true, false, usize::MAX) - } else { - element! {} - } - }) - - #(if let AgentState::Error(ref msg) = state.agent_state { - View(key: "error-display", padding_left: Cells::from(2), padding_top: Cells::from(1)) { - Text { - Span(text: "Error: ", style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) - Span(text: msg, style: Style::default().fg(Color::Red)) - } - } - }) - - #(if !state.is_exiting() { - #(input_view(state)) - }) - } - } -} - -fn input_view(state: &ViewState) -> Elements { - let asking_tool = state.tools.awaiting_permission(); - let in_git_project = state.in_git_project; - let slash_results = state - .slash_command_search_results - .iter() - .take(4) - .collect::<Vec<_>>(); - let first_slash_result = slash_results.first().cloned(); - - element! { - #(if let Some(tc) = asking_tool { - #(tool_call_view(tc, in_git_project)) - }) - - #(if asking_tool.is_none() { - View(key: "input-box", padding_top: Cells::from(1)) { - InputBox( - key: "input", - title: "Generate a command or ask a question", - title_right: "Atuin AI", - footer: state.footer_text(), - active: state.is_input_active(), - slash_suggestion: first_slash_result.cloned() - ) - - #(if state.is_input_blank && state.has_command && state.is_input_active() { - #(if state.has_confirmation() { - Text { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) } - } else { - Text { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) } - }) - }) - - #(if !slash_results.is_empty() { - #(for (i, result) in slash_results.iter().enumerate() { - Text { - Span(text: format!("/{}", &result.command.name[..result.span.0]), style: Style::default().fg(Color::Blue)) - Span(text: &result.command.name[result.span.0..result.span.1], style: Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED)) - Span(text: format!("{}", &result.command.name[result.span.1..]), style: Style::default().fg(Color::Blue)) - Span(text: " - ") - Span(text: &result.command.description) - - #(if i == 0 { - Span(text: " [Tab] Insert", style: Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC).dim()) - }) - } - - }) - }) - } - }) - } -} - -fn tool_call_view(tool_call: &crate::fsm::tools::TrackedTool, in_git_project: bool) -> Elements { - let verb = tool_call.tool.descriptor().display_verb; - let tool_desc = match &tool_call.tool { - ClientToolCall::Read(tool) => tool.path.display().to_string(), - ClientToolCall::Edit(tool) => tool.path.display().to_string(), - ClientToolCall::Write(tool) => tool.path.display().to_string(), - ClientToolCall::Shell(tool) => tool.command.clone(), - ClientToolCall::AtuinHistory(tool) => tool.query.clone(), - ClientToolCall::AtuinOutput(tool) => tool.history_id.to_string(), - ClientToolCall::LoadSkill(tool) => format!("skill: {}", tool.name), - }; - - let select_options = permission_options_for_tool(&tool_call.tool, in_git_project); - - element! { - View(key: format!("tool-call-{}", tool_call.id), padding_left: Cells::from(2), padding_top: Cells::from(1)) { - Text { - Span(text: format!("Atuin AI would like to {}: ", verb), style: Style::default()) - Span(text: &tool_desc, style: Style::default().fg(Color::Yellow)) - } - View(padding_left: Cells::from(2)) { - Select(options: select_options, on_select: Box::new(move |option: &SelectOption| { - PermissionResult::from_value_str(option.value.as_str()) - .map(AiTuiEvent::SelectPermission) - }) as Box<dyn Fn(&SelectOption) -> Option<AiTuiEvent> + Send + Sync>) - } - } - } -} - -/// Build the permission SelectOptions appropriate for a tool call. -/// -/// Edit tools get a per-file session-scoped option instead of the -/// workspace-level "Always allow in this directory". Other tools -/// keep the standard set. -fn permission_options_for_tool(tool: &ClientToolCall, in_git_project: bool) -> Vec<SelectOption> { - match tool { - ClientToolCall::Edit(_) | ClientToolCall::Write(_) => vec![ - SelectOption::builder() - .label("Allow") - .value(PermissionResult::Allow.as_value_str()) - .build(), - SelectOption::builder() - .label("Allow this file for this session") - .value(PermissionResult::AllowFileForSession.as_value_str()) - .build(), - SelectOption::builder() - .label("Always allow") - .value(PermissionResult::AlwaysAllow.as_value_str()) - .build(), - SelectOption::builder() - .label("Deny") - .value(PermissionResult::Deny.as_value_str()) - .build(), - ], - _ => { - let dir_label = if in_git_project { - "Always allow in this workspace" - } else { - "Always allow in this directory" - }; - vec![ - SelectOption::builder() - .label("Allow") - .value(PermissionResult::Allow.as_value_str()) - .build(), - SelectOption::builder() - .label(dir_label) - .value(PermissionResult::AlwaysAllowInDir.as_value_str()) - .build(), - SelectOption::builder() - .label("Always allow") - .value(PermissionResult::AlwaysAllow.as_value_str()) - .build(), - SelectOption::builder() - .label("Deny") - .value(PermissionResult::Deny.as_value_str()) - .build(), - ] - } - } -} - -fn user_turn_view(events: &[turn::UiEvent], first_turn: bool, turn_id: usize) -> Elements { - let label_style = Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD); - - let padding = if first_turn { 0 } else { 1 }; - - element! { - View(key: format!("turn-{turn_id}"), padding_top: Cells::from(padding)) { - Text { - Span(text: " You ", style: label_style.reversed()) - } - #(for event in events { - #(match event { - turn::UiEvent::Text { content } => { - element! { - View(padding_left: Cells::from(2)) { - Text { - Span(text: content, style: Style::default()) - } - } - } - }, - _ => element!{} - }) - }) - } - } -} - -fn agent_turn_view( - events: &[turn::UiEvent], - busy: bool, - showing_ui: bool, - turn_id: usize, -) -> Elements { - let label_style = Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD); - - element! { - View(key: format!("turn-{turn_id}")) { - Text { - Span(text: " Atuin AI ", style: label_style.reversed()) - } - #(for (i, event) in events.iter().enumerate() { - #(if i > 0 { - Text { Span(text: "") } - }) - #(match event { - turn::UiEvent::Text { content } => { - element! { - View(padding_left: Cells::from(2)) { - Markdown(source: content) - } - } - }, - turn::UiEvent::ToolSummary(summary) => { - tool_summary_view(summary) - }, - turn::UiEvent::SuggestedCommand(details) => { - suggested_command_view(details) - }, - turn::UiEvent::ToolCall(details) => { - let tool_key = details.tool_use_id.clone(); - - element! { - View(key: format!("tool-output-{tool_key}"), padding_left: Cells::from(2)) { - #(match &details.render_data { - turn::ToolRenderData::Shell { command, preview } => { - shell_tool_view(&tool_key, command, preview.as_ref()) - }, - turn::ToolRenderData::FileEdit { path, preview } => { - file_edit_tool_view(&tool_key, &details.status, path, preview.as_ref()) - }, - turn::ToolRenderData::FileWrite { path, preview } => { - file_write_tool_view(&tool_key, &details.status, path, preview.as_ref()) - }, - turn::ToolRenderData::Remote => { - tool_status_view(&details.name, &details.status) - }, - turn::ToolRenderData::FileRead { .. } - | turn::ToolRenderData::HistorySearch { .. } - | turn::ToolRenderData::SkillLoad { .. } => { - element!{} - }, - }) - } - } - } - turn::UiEvent::ToolGroup(group) => { - let group_key = group.calls - .first() - .map(|c| c.tool_use_id.as_str()) - .unwrap_or("empty"); - - element! { - View(key: format!("group-{group_key}"), padding_left: Cells::from(2)) { - #(match group.kind { - turn::ToolGroupKind::FileRead => file_read_group_view(group), - turn::ToolGroupKind::HistorySearch => history_search_group_view(group), - }) - } - } - } - _ => element!{} - }) - }) - - #(if busy && !showing_ui { - View(key: "agent-working-spinner", padding_left: Cells::from(2), padding_top: Cells::from(1)) { - Spinner( - label: "", - spinner_style: Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), - ) - } - }) - } - } -} - -fn out_of_band_turn_view(events: &[turn::UiEvent], turn_id: usize) -> Elements { - element! { - View(key: format!("turn-{turn_id}")) { - Text { - Span(text: " System ", style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD).add_modifier(Modifier::REVERSED)) - } - #(for event in events { - #(match event { - turn::UiEvent::OutOfBandOutput(details) => { - out_of_band_output_view(details) - } - _ => element!{} - }) - }) - } - } -} - -fn out_of_band_output_view(details: &turn::OutOfBandOutputDetails) -> Elements { - element! { - View(padding_left: Cells::from(2)) { - #(if details.command.is_some() { - Text { - Span(text: details.command.as_ref().unwrap(), style: Style::default().fg(Color::Blue)) - } - }) - Markdown(source: details.content.clone()) - } - } -} - -fn tool_summary_view(summary: &turn::ToolSummary) -> Elements { - element! { - Spinner(label: summary.summary(), done: !summary.any_pending()) - } -} - -/// Render a status indicator for a non-preview tool call (e.g. atuin_history, read_file). -fn tool_status_view(name: &str, status: &turn::ToolResultStatus) -> Elements { - match status { - turn::ToolResultStatus::Pending => { - element! { - Spinner( - label: format!("Running: {name}"), - label_style: Style::default().fg(Color::Yellow), - done: false, - ) - } - } - turn::ToolResultStatus::Success => { - element! { - Spinner( - label: format!("Ran: {name}"), - done: true, - ) - } - } - turn::ToolResultStatus::Error => { - element! { - Text { - Span(text: "✗ ", style: Style::default().fg(Color::Red)) - Span(text: format!("{name}: denied"), style: Style::default().fg(Color::Red)) - } - } - } - } -} - -// ─────────────────────────────────────────────────────────────────── -// Per-tool view functions -// ─────────────────────────────────────────────────────────────────── - -/// Max output lines shown for a shell command preview. -const MAX_SHELL_PREVIEW_LINES: u16 = 5; - -/// Render a shell command execution with live VT100 output viewport. -fn shell_tool_view(tool_key: &str, command: &str, preview: Option<&ToolPreview>) -> Elements { - let preview_done = preview.is_some_and(|p| p.exit_code.is_some() || p.interrupted.is_some()); - - element! { - #(if let Some(preview) = preview { - View(key: format!("preview-{tool_key}")) { - Spinner( - label: if preview_done { format!("Ran: {command}") } else { format!("Running: {command}") }, - done: preview_done, - hide_checkmark: true, - ) - HStack { - View(width: WidthConstraint::Fixed(2)) { - Text { Span(text: "└ ") } - } - Column { - Viewport( - key: format!("viewport-{tool_key}"), - lines: preview.lines.clone(), - height: (preview.lines.len() as u16).clamp(1, MAX_SHELL_PREVIEW_LINES), - style: Style::default().fg(Color::Gray), - wrap: false, - ) - } - } - #(shell_tool_footer(preview, preview_done)) - } - } else { - Spinner( - label: format!("Running: {command}"), - label_style: Style::default().fg(Color::Yellow), - done: false, - ) - }) - } -} - -fn shell_tool_footer(preview: &ToolPreview, preview_done: bool) -> Elements { - use crate::fsm::tools::InterruptReason; - - if let Some(reason) = &preview.interrupted { - let text = match reason { - InterruptReason::User => "Interrupted".to_string(), - InterruptReason::Timeout(secs) => format!("Timed out ({secs}s)"), - }; - return element! { - Text { - Span(text: text, style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) - } - }; - } - if !preview_done { - return element! { - Text { - Span(text: "[Ctrl+C] Interrupt", style: Style::default().fg(Color::DarkGray)) - } - }; - } - if let Some(code) = preview.exit_code { - let style = if code == 0 { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::Red) - }; - return element! { - Text { Span(text: format!("Exit code: {code}"), style: style) } - }; - } - element! {} -} - -/// Render a file edit tool call with diff preview. -fn file_edit_tool_view( - key: &str, - status: &turn::ToolResultStatus, - path: &std::path::Path, - preview: Option<&crate::diff::EditPreview>, -) -> Elements { - use crate::diff::DiffLine; - - let display_path = format_path_for_display(path); - - let status_line = match status { - turn::ToolResultStatus::Pending => { - element! { - Spinner( - label: format!("Editing: {display_path}"), - label_style: Style::default().fg(Color::Yellow), - done: false, - ) - } - } - turn::ToolResultStatus::Success => { - element! { - Spinner(label: format!("Edited: {display_path}"), done: true) - } - } - turn::ToolResultStatus::Error => { - element! { - Text { - Span(text: "✗ ", style: Style::default().fg(Color::Red)) - Span(text: format!("Edit {display_path}: failed"), style: Style::default().fg(Color::Red)) - } - } - } - }; - - // If no preview, just show the status line - let Some(preview) = preview else { - return status_line; - }; - if preview.hunks.is_empty() { - return status_line; - } - - // Calculate the line number gutter width from the highest line number - let max_line_num = preview.max_line_number(); - let gutter_width = max_line_num.to_string().len().max(2) as u16 + 1; // +1 for spacing - - element! { - View(key: key.to_string()) { - #(status_line) - - View(key: format!("{key}-diff"), padding_left: Cells::from(2)) { - #(for (hunk_idx, hunk) in preview.hunks.iter().enumerate() { - #({ - let gutter_w = gutter_width; - let mut before_pos = hunk.before_start; - let mut after_pos = hunk.after_start; - let lines_rendered: Vec<_> = hunk.lines.iter().enumerate().map(|(line_idx, line)| { - let (prefix, text, style, gutter_text, gutter_style) = match line { - DiffLine::Context(t) => { - let num = format!("{:>width$}", after_pos, width = (gutter_w - 1) as usize); - before_pos += 1; - after_pos += 1; - (" ", t.as_str(), Style::default().fg(Color::DarkGray), num, Style::default().fg(Color::DarkGray)) - } - DiffLine::Removed(t) => { - let num = format!("{:>width$}", before_pos, width = (gutter_w - 1) as usize); - before_pos += 1; - ("-", t.as_str(), Style::default().fg(Color::Red), num, Style::default().fg(Color::Red)) - } - DiffLine::Added(t) => { - let num = format!("{:>width$}", after_pos, width = (gutter_w - 1) as usize); - after_pos += 1; - ("+", t.as_str(), Style::default().fg(Color::Green), num, Style::default().fg(Color::Green)) - } - }; - (line_idx, prefix, text.to_string(), style, gutter_text, gutter_style) - }).collect(); - - element! { - View(key: format!("{key}-hunk-{hunk_idx}")) { - #(for (line_idx, prefix, text, style, gutter_text, gutter_style) in &lines_rendered { - HStack(key: format!("{key}-hunk-{hunk_idx}-line-{line_idx}")) { - View(width: WidthConstraint::Fixed(gutter_w)) { - Text { Span(text: gutter_text, style: *gutter_style) } - } - View { - Text { - Span(text: *prefix, style: *style) - Span(text: text, style: *style) - } - } - } - }) - } - } - }) - }) - } - } - } -} - -/// Render a file write tool call with content preview. -fn file_write_tool_view( - key: &str, - status: &turn::ToolResultStatus, - path: &std::path::Path, - preview: Option<&crate::diff::WritePreview>, -) -> Elements { - let display_path = format_path_for_display(path); - - let status_line = match status { - turn::ToolResultStatus::Pending => { - element! { - Spinner( - label: format!("Writing: {display_path}"), - label_style: Style::default().fg(Color::Yellow), - done: false, - ) - } - } - turn::ToolResultStatus::Success => { - let line_info = preview - .map(|p| format!(" ({} lines)", p.total_lines)) - .unwrap_or_default(); - element! { - Spinner(label: format!("Wrote: {display_path}{line_info}"), done: true) - } - } - turn::ToolResultStatus::Error => { - element! { - Text { - Span(text: "✗ ", style: Style::default().fg(Color::Red)) - Span(text: format!("Write {display_path}: failed"), style: Style::default().fg(Color::Red)) - } - } - } - }; - - let Some(preview) = preview else { - return status_line; - }; - if preview.lines.is_empty() { - return status_line; - } - - let gutter_width = preview.total_lines.to_string().len().max(2) as u16 + 1; - let remaining = preview.remaining_lines(); - - element! { - View(key: key.to_string()) { - #(status_line) - - View(key: format!("{key}-content"), padding_left: Cells::from(2)) { - #(for (idx, line) in preview.lines.iter().enumerate() { - HStack(key: format!("{key}-line-{idx}")) { - View(width: WidthConstraint::Fixed(gutter_width)) { - Text { Span( - text: format!("{:>width$}", idx + 1, width = (gutter_width - 1) as usize), - style: Style::default().fg(Color::DarkGray) - ) } - } - View { - Text { Span(text: line, style: Style::default().fg(Color::DarkGray)) } - } - } - }) - - #(if remaining > 0 { - Text { - Span( - text: format!(" ... +{remaining} more lines"), - style: Style::default().fg(Color::DarkGray) - ) - } - }) - } - } - } -} - -// ─────────────────────────────────────────────────────────────────── -// Tool group view functions -// ─────────────────────────────────────────────────────────────────── - -/// Max entries shown under a tool group header. When the group holds more -/// than this, only the most recent `MAX_GROUP_ENTRIES` are displayed; the -/// count in the header line tells the full story. -const MAX_GROUP_ENTRIES: usize = 5; - -/// Format a filesystem path for display in tool rows. -/// -/// - Relative to the current working directory if the path is under it -/// - `~/...` prefix if the path is under the user's home directory -/// - Absolute otherwise (and relative paths pass through unchanged) -fn format_path_for_display(path: &std::path::Path) -> String { - if let Ok(cwd) = std::env::current_dir() - && let Ok(relative) = path.strip_prefix(&cwd) - { - return relative.display().to_string(); - } - - if let Ok(home) = std::env::var("HOME") - && let Ok(relative) = path.strip_prefix(&home) - { - return format!("~/{}", relative.display()); - } - - path.display().to_string() -} - -fn filter_mode_label(mode: &HistorySearchFilterMode) -> &'static str { - match mode { - HistorySearchFilterMode::Global => "global", - HistorySearchFilterMode::Host => "host", - HistorySearchFilterMode::Session => "session", - HistorySearchFilterMode::Directory => "directory", - HistorySearchFilterMode::Workspace => "workspace", - } -} - -/// Format a list of filter modes as `"(global, workspace)"`, or an empty -/// string if the list is empty. -fn format_filter_modes(modes: &[HistorySearchFilterMode]) -> String { - if modes.is_empty() { - return String::new(); - } - let parts: Vec<&'static str> = modes.iter().map(filter_mode_label).collect(); - format!("({})", parts.join(", ")) -} - -/// Tree-connector marker for a row in a grouped list: `└ ` for the first -/// visible row, two spaces for subsequent rows. -fn tree_marker(is_first: bool) -> &'static str { - if is_first { "└ " } else { " " } -} - -/// 2-char status marker column: ✓ / ✗ / blank. -fn status_marker_view(status: &turn::ToolResultStatus) -> Elements { - match status { - turn::ToolResultStatus::Pending => element! { - Text { Span(text: " ") } - }, - turn::ToolResultStatus::Success => element! { - Text { Span(text: "✓ ", style: Style::default().fg(Color::Green)) } - }, - turn::ToolResultStatus::Error => element! { - Text { Span(text: "✗ ", style: Style::default().fg(Color::Red)) } - }, - } -} - -/// Compute the slice of calls to show — the most recent `MAX_GROUP_ENTRIES`. -fn visible_group_calls(group: &turn::ToolGroup) -> &[turn::ToolCallDetails] { - let start = group.calls.len().saturating_sub(MAX_GROUP_ENTRIES); - &group.calls[start..] -} - -/// Render a single row in a grouped list: [tree marker][status][content]. -fn group_row_view(is_first: bool, status: &turn::ToolResultStatus, content: Elements) -> Elements { - element! { - HStack { - View(width: WidthConstraint::Fixed(2)) { - Text { Span(text: tree_marker(is_first)) } - } - View(width: WidthConstraint::Fixed(2)) { - #(status_marker_view(status)) - } - Column { - #(content) - } - } - } -} - -/// Render a group of consecutive `read_file` tool calls. -fn file_read_group_view(group: &turn::ToolGroup) -> Elements { - let count = group.calls.len(); - let label = if count == 1 { - "Read 1 file".to_string() - } else { - format!("Read {count} files") - }; - let done = !group.any_pending(); - let visible = visible_group_calls(group); - - element! { - Spinner(label: label, done: done, hide_checkmark: true) - #(for (i, details) in visible.iter().enumerate() { - #(file_read_row(i == 0, details)) - }) - } -} - -fn file_read_row(is_first: bool, details: &turn::ToolCallDetails) -> Elements { - let path_str = match &details.render_data { - turn::ToolRenderData::FileRead { path } => format_path_for_display(path), - _ => String::new(), - }; - - let content = element! { - Text { Span(text: path_str) } - }; - - group_row_view(is_first, &details.status, content) -} - -/// Render a group of consecutive `atuin_history` tool calls. -fn history_search_group_view(group: &turn::ToolGroup) -> Elements { - let done = !group.any_pending(); - let visible = visible_group_calls(group); - - element! { - Spinner(label: "Searched Atuin history:", done: done, hide_checkmark: true) - #(for (i, details) in visible.iter().enumerate() { - #(history_search_row(i == 0, details)) - }) - } -} - -fn history_search_row(is_first: bool, details: &turn::ToolCallDetails) -> Elements { - let (query, filter_modes) = match &details.render_data { - turn::ToolRenderData::HistorySearch { - query, - filter_modes, - } => (query.as_str(), filter_modes.as_slice()), - _ => ("", [].as_slice()), - }; - - let is_empty_query = query.trim().is_empty(); - let filter_label = format_filter_modes(filter_modes); - - let content = if is_empty_query { - element! { - Text { - Span( - text: "recent commands", - style: Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC), - ) - #(if !filter_label.is_empty() { - Span(text: " ") - Span(text: filter_label, style: Style::default().fg(Color::DarkGray)) - }) - } - } - } else { - element! { - Text { - Span(text: query.to_string()) - #(if !filter_label.is_empty() { - Span(text: " ") - Span(text: filter_label, style: Style::default().fg(Color::DarkGray)) - }) - } - } - }; - - group_row_view(is_first, &details.status, content) -} - -fn suggested_command_view(details: &turn::SuggestedCommandDetails) -> Elements { - let is_dangerous = matches!( - details.danger_level, - turn::DangerLevel::High(_) | turn::DangerLevel::Medium(_) - ); - let danger_notes = details.danger_level.notes(); - let danger_style = match details.danger_level { - turn::DangerLevel::High(_) => Style::default().fg(Color::Red), - turn::DangerLevel::Medium(_) => Style::default().fg(Color::Yellow), - turn::DangerLevel::Low(_) => Style::default().fg(Color::Green), - turn::DangerLevel::Unknown(_) => Style::default().fg(Color::Green), - }; - let danger_text = match details.danger_level { - turn::DangerLevel::High(_) => "High", - turn::DangerLevel::Medium(_) => "Medium", - turn::DangerLevel::Low(_) => "Low", - turn::DangerLevel::Unknown(_) => "Unknown", - }; - - let low_confidence = matches!( - details.confidence_level, - turn::ConfidenceLevel::Low(_) | turn::ConfidenceLevel::Medium(_) - ); - - let confidence_level = match details.confidence_level { - turn::ConfidenceLevel::Low(_) => "Low", - turn::ConfidenceLevel::Medium(_) => "Medium", - turn::ConfidenceLevel::High(_) => "High", - turn::ConfidenceLevel::Unknown(_) => "Unknown", - }; - - let confidence_notes = details.confidence_level.notes(); - - element! { - View { - Text { - Span(text: " Suggested command:", style: Style::default().fg(Color::Cyan)) - } - HStack { - View(width: WidthConstraint::Fixed(2)) { - Text { - #(if is_dangerous || low_confidence { - Span(text: "! ", style: Style::default().fg(Color::Yellow)) - } else { - Span(text: "$ ", style: Style::default().fg(Color::Blue)) - }) - } - } - Column { - Text { - Span(text: &details.command, style: Style::default().fg(Color::Green)) - } - } - } - #(if is_dangerous { - View(padding_left: Cells::from(2)) { - Text { - Span(text: "Danger: ", style: danger_style) - Span(text: danger_text, style: danger_style.add_modifier(Modifier::BOLD)) - } - } - }) - #(if is_dangerous && danger_notes.is_some() { - View(padding_left: Cells::from(2)) { - HStack { - View(width: WidthConstraint::Fixed(2)) { - Text { - Span(text: "└") - } - } - View(width: WidthConstraint::Fill) { - Markdown(source: danger_notes.unwrap()) - } - } - } - }) - #(if low_confidence { - View(padding_left: Cells::from(2)) { - Text { - Span(text: "Confidence: ", style: Style::default().fg(Color::Blue)) - Span(text: confidence_level, style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) - } - } - }) - #(if low_confidence && confidence_notes.is_some() { - View(padding_left: Cells::from(2)) { - HStack { - View(width: WidthConstraint::Fixed(2)) { - Text { - Span(text: "└") - } - } - View(width: WidthConstraint::Fill) { - Markdown(source: confidence_notes.unwrap()) - } - } - } - }) - } - } -} - -// ai_view_old removed — superseded by ai_view above diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs deleted file mode 100644 index aa1f55fa..00000000 --- a/crates/atuin-ai/src/tui/view/turn.rs +++ /dev/null @@ -1,606 +0,0 @@ -use std::path::PathBuf; - -use crate::fsm::tools::ToolManager; -use crate::tools::descriptor; -use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview}; -use crate::tui::ConversationEvent; - -/// Server-sent danger level for a suggested command -#[derive(Debug)] -pub(crate) enum DangerLevel { - Low(Option<String>), - Medium(Option<String>), - High(Option<String>), - Unknown(Option<String>), -} - -impl DangerLevel { - pub(crate) fn notes(&self) -> Option<&String> { - match self { - DangerLevel::Low(notes) => notes.as_ref(), - DangerLevel::Medium(notes) => notes.as_ref(), - DangerLevel::High(notes) => notes.as_ref(), - DangerLevel::Unknown(notes) => notes.as_ref(), - } - } -} - -impl From<(&String, &String)> for DangerLevel { - fn from((danger_level, danger_notes): (&String, &String)) -> Self { - let notes = if danger_notes.is_empty() { - None - } else { - Some(danger_notes.to_string()) - }; - - match danger_level.as_str() { - "low" => DangerLevel::Low(notes), - "medium" => DangerLevel::Medium(notes), - "med" => DangerLevel::Medium(notes), - "high" => DangerLevel::High(notes), - _ => DangerLevel::Unknown(notes), - } - } -} - -/// Server-sent confidence level for a suggested command -#[derive(Debug)] -pub(crate) enum ConfidenceLevel { - Low(Option<String>), - Medium(Option<String>), - High(Option<String>), - Unknown(Option<String>), -} - -impl ConfidenceLevel { - pub(crate) fn notes(&self) -> Option<&String> { - match self { - ConfidenceLevel::Low(notes) => notes.as_ref(), - ConfidenceLevel::Medium(notes) => notes.as_ref(), - ConfidenceLevel::High(notes) => notes.as_ref(), - ConfidenceLevel::Unknown(notes) => notes.as_ref(), - } - } -} - -impl From<(&String, &String)> for ConfidenceLevel { - fn from((confidence_level, confidence_notes): (&String, &String)) -> Self { - let notes = if confidence_notes.is_empty() { - None - } else { - Some(confidence_notes.to_string()) - }; - - match confidence_level.as_str() { - "low" => ConfidenceLevel::Low(notes), - "medium" => ConfidenceLevel::Medium(notes), - "med" => ConfidenceLevel::Medium(notes), - "high" => ConfidenceLevel::High(notes), - _ => ConfidenceLevel::Unknown(notes), - } - } -} - -#[derive(Debug)] -pub(crate) enum UiEvent { - Text { - content: String, - }, - ToolCall(ToolCallDetails), - /// Consecutive client-side tool calls of the same groupable kind, collapsed - /// into one unit so the view can render a shared status line + a list of - /// individual entries. - ToolGroup(ToolGroup), - ToolSummary(ToolSummary), - SuggestedCommand(SuggestedCommandDetails), - OutOfBandOutput(OutOfBandOutputDetails), -} - -/// A run of consecutive client-side tool calls of the same groupable kind. -#[derive(Debug)] -pub(crate) struct ToolGroup { - pub(crate) kind: ToolGroupKind, - pub(crate) calls: Vec<ToolCallDetails>, -} - -impl ToolGroup { - /// True if any call in the group is still pending. - pub(crate) fn any_pending(&self) -> bool { - self.calls - .iter() - .any(|c| c.status == ToolResultStatus::Pending) - } -} - -/// Which kind of client-side tools this group holds. -/// -/// Only tool types that benefit from grouped presentation appear here. -/// Shell (needs its own viewport) and FileWrite (wants diffs/contents) are -/// intentionally absent — those render as individual `UiEvent::ToolCall`s. -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub(crate) enum ToolGroupKind { - FileRead, - HistorySearch, -} - -/// Tool-type-specific data for rendering in the view layer. -/// -/// Each variant carries the data a per-tool renderer component needs. -/// Built by TurnBuilder from ToolTracker + ConversationEvent data. -#[derive(Debug)] -pub(crate) enum ToolRenderData { - /// Shell command with live/cached VT100 output preview. - Shell { - command: String, - preview: Option<ToolPreview>, - }, - /// File read operation. - FileRead { path: PathBuf }, - /// File edit (str_replace) operation. - FileEdit { - path: PathBuf, - preview: Option<crate::diff::EditPreview>, - }, - /// File write/create operation. - FileWrite { - path: PathBuf, - preview: Option<crate::diff::WritePreview>, - }, - /// Atuin history search. - HistorySearch { - query: String, - filter_modes: Vec<HistorySearchFilterMode>, - }, - /// Skill loading — read-only, auto-approved. - SkillLoad { _name: String }, - /// Server-side tool — no client rendering data available. - Remote, -} - -impl ToolRenderData { - pub(crate) fn is_remote(&self) -> bool { - matches!(self, ToolRenderData::Remote) - } - - /// The group kind this tool should collapse into, if any. - /// - /// Returns `None` for tools that render as individual `UiEvent::ToolCall`s - /// (shell, file writes, remote). - pub(crate) fn group_kind(&self) -> Option<ToolGroupKind> { - match self { - ToolRenderData::FileRead { .. } => Some(ToolGroupKind::FileRead), - ToolRenderData::HistorySearch { .. } => Some(ToolGroupKind::HistorySearch), - _ => None, - } - } -} - -#[derive(Debug)] -pub(crate) struct ToolCallDetails { - pub(crate) tool_use_id: String, - pub(crate) name: String, - pub(crate) status: ToolResultStatus, - pub(crate) render_data: ToolRenderData, -} - -#[derive(Debug)] -pub(crate) struct SuggestedCommandDetails { - pub(crate) command: String, - pub(crate) danger_level: DangerLevel, - pub(crate) confidence_level: ConfidenceLevel, -} - -#[derive(Debug)] -pub(crate) struct OutOfBandOutputDetails { - pub(crate) command: Option<String>, - pub(crate) content: String, -} - -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum ToolResultStatus { - Pending, - Success, - Error, -} - -#[derive(Debug)] -pub(crate) struct UiTurn { - pub(crate) id: usize, - pub(crate) kind: UiTurnKind, -} - -#[derive(Debug)] -pub(crate) enum UiTurnKind { - User { events: Vec<UiEvent> }, - Agent { events: Vec<UiEvent> }, - OutOfBand { events: Vec<UiEvent> }, -} - -pub(crate) struct TurnBuilder<'a> { - turns: Vec<UiTurnKind>, - current_turn: Option<UiTurnKind>, - tracker: &'a ToolManager, - next_id: usize, -} - -/// A struct to iteratively build [UiTurn] events from [ConversationEvent]s. -impl<'a> TurnBuilder<'a> { - pub(crate) fn new(tracker: &'a ToolManager) -> Self { - Self { - turns: Vec::new(), - current_turn: None, - tracker, - next_id: 0, - } - } - - pub(crate) fn new_starting_at(tracker: &'a ToolManager, start_id: usize) -> Self { - Self { - turns: Vec::new(), - current_turn: None, - tracker, - next_id: start_id, - } - } - - pub(crate) fn add_event(&mut self, event: &ConversationEvent) { - match event { - ConversationEvent::UserMessage { content } => { - self.add_user_message(content); - } - ConversationEvent::Text { content } => { - self.add_agent_text(content); - } - ConversationEvent::ToolCall { id, name, input } => { - if name == "suggest_command" { - self.add_suggested_command(input); - } else { - self.add_tool_call(id, name, input); - } - } - ConversationEvent::ToolResult { - tool_use_id, - content, - is_error, - .. - } => { - self.add_tool_result(tool_use_id, content, *is_error); - } - ConversationEvent::OutOfBandOutput { - name, - command, - content, - } => { - self.add_out_of_band_output(name, command.as_deref(), content); - } - ConversationEvent::SystemContext { .. } => { - // Not rendered in the TUI — only sent to the API - } - ConversationEvent::SkillInvocation { - name, arguments, .. - } => { - let display = match arguments { - Some(args) => format!("/{name} {args}"), - None => format!("/{name}"), - }; - self.add_user_message(&display); - } - } - } - - pub(crate) fn build(&mut self) -> Vec<UiTurn> { - self.commit_turn(); - - // Within each agent turn: - // - Consecutive remote tool calls collapse into a ToolSummary - // - Consecutive client-side tool calls of the same group kind collapse - // into a ToolGroup (e.g. N file reads → one group) - // - All other events pass through unchanged - for turn in &mut self.turns { - if let UiTurnKind::Agent { events } = turn { - let mut new_events: Vec<UiEvent> = Vec::new(); - let mut pending_remote: Vec<ToolCallDetails> = Vec::new(); - let mut pending_group: Option<(ToolGroupKind, Vec<ToolCallDetails>)> = None; - - for event in events.drain(..) { - match event { - UiEvent::ToolCall(details) if details.render_data.is_remote() => { - flush_group(&mut pending_group, &mut new_events); - pending_remote.push(details); - } - UiEvent::ToolCall(details) - if details.render_data.group_kind().is_some() => - { - flush_remote(&mut pending_remote, &mut new_events); - - let kind = details.render_data.group_kind().unwrap(); - match pending_group.as_mut() { - Some((current_kind, calls)) if *current_kind == kind => { - calls.push(details); - } - _ => { - flush_group(&mut pending_group, &mut new_events); - pending_group = Some((kind, vec![details])); - } - } - } - other => { - flush_remote(&mut pending_remote, &mut new_events); - flush_group(&mut pending_group, &mut new_events); - new_events.push(other); - } - } - } - - flush_remote(&mut pending_remote, &mut new_events); - flush_group(&mut pending_group, &mut new_events); - - *events = new_events; - } - } - - let kinds = std::mem::take(&mut self.turns); - kinds - .into_iter() - .enumerate() - .map(|(i, kind)| UiTurn { - id: self.next_id + i, - kind, - }) - .collect() - } - - fn commit_turn(&mut self) { - if let Some(turn) = self.current_turn.take() { - self.turns.push(turn); - } - } - - fn start_user_turn(&mut self) { - if !matches!(self.current_turn, Some(UiTurnKind::User { .. })) { - self.commit_turn(); - self.current_turn = Some(UiTurnKind::User { events: vec![] }); - } - } - - fn start_agent_turn(&mut self) { - if !matches!(self.current_turn, Some(UiTurnKind::Agent { .. })) { - self.commit_turn(); - self.current_turn = Some(UiTurnKind::Agent { events: vec![] }); - } - } - - fn start_out_of_band_turn(&mut self) { - if !matches!(self.current_turn, Some(UiTurnKind::OutOfBand { .. })) { - self.commit_turn(); - self.current_turn = Some(UiTurnKind::OutOfBand { events: vec![] }); - } - } - - fn current_events_mut(&mut self) -> &mut Vec<UiEvent> { - match self.current_turn.as_mut().unwrap() { - UiTurnKind::User { events } - | UiTurnKind::Agent { events } - | UiTurnKind::OutOfBand { events } => events, - } - } - - fn add_user_message(&mut self, content: &str) { - self.start_user_turn(); - self.current_events_mut().push(UiEvent::Text { - content: content.to_string(), - }); - } - - fn add_agent_text(&mut self, content: &str) { - if content.trim().is_empty() { - return; - } - self.start_agent_turn(); - self.current_events_mut().push(UiEvent::Text { - content: content.to_string(), - }); - } - - fn add_suggested_command(&mut self, input: &serde_json::Value) { - let command = input - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - if command.is_empty() { - return; - } - - self.start_agent_turn(); - { - let events = self.current_events_mut(); - let danger_level = input - .get("danger") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let confidence_level = input - .get("confidence") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let danger_notes = input - .get("danger_notes") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let confidence_notes = input - .get("confidence_notes") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let danger = DangerLevel::from((&danger_level, &danger_notes)); - let confidence = ConfidenceLevel::from((&confidence_level, &confidence_notes)); - - events.push(UiEvent::SuggestedCommand(SuggestedCommandDetails { - command: input - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - danger_level: danger, - confidence_level: confidence, - })); - } - } - - fn add_tool_call(&mut self, id: &str, name: &str, _input: &serde_json::Value) { - let render_data = self.build_render_data(id, name); - - self.start_agent_turn(); - self.current_events_mut() - .push(UiEvent::ToolCall(ToolCallDetails { - tool_use_id: id.to_string(), - name: name.to_string(), - status: ToolResultStatus::Pending, - render_data, - })); - } - - /// Build tool-type-specific render data from the ToolTracker. - /// - /// For client-side tools, the tracker holds the typed `ClientToolCall` and - /// any live/cached preview data. For server-side (or unknown) tools, we - /// fall back to `ToolRenderData::Remote`. - fn build_render_data(&self, id: &str, _name: &str) -> ToolRenderData { - if let Some(tracked) = self.tracker.get(id) { - match &tracked.tool { - ClientToolCall::Shell(shell) => ToolRenderData::Shell { - command: shell.command.clone(), - preview: tracked.shell_preview(), - }, - ClientToolCall::Read(read) => ToolRenderData::FileRead { - path: read.path.clone(), - }, - ClientToolCall::Edit(edit) => ToolRenderData::FileEdit { - path: edit.path.clone(), - preview: tracked.edit_preview().cloned(), - }, - ClientToolCall::Write(write) => ToolRenderData::FileWrite { - path: write.path.clone(), - preview: tracked.write_preview().cloned(), - }, - ClientToolCall::AtuinHistory(history) => ToolRenderData::HistorySearch { - query: history.query.clone(), - filter_modes: history.filter_modes.clone(), - }, - ClientToolCall::AtuinOutput(_) => ToolRenderData::Remote, - ClientToolCall::LoadSkill(skill) => ToolRenderData::SkillLoad { - _name: skill.name.clone(), - }, - } - } else { - // Not in tracker → server-side tool - ToolRenderData::Remote - } - } - - fn add_tool_result(&mut self, tool_use_id: &str, _content: &str, is_error: bool) { - self.start_agent_turn(); - let events = self.current_events_mut(); - let event = events.iter_mut().find(|e| match e { - UiEvent::ToolCall(ToolCallDetails { - tool_use_id: id, .. - }) => id == tool_use_id, - _ => false, - }); - if let Some(UiEvent::ToolCall(ToolCallDetails { status, .. })) = event { - *status = if is_error { - ToolResultStatus::Error - } else { - ToolResultStatus::Success - }; - } - } - - fn add_out_of_band_output(&mut self, _name: &str, command: Option<&str>, content: &str) { - self.start_out_of_band_turn(); - self.current_events_mut() - .push(UiEvent::OutOfBandOutput(OutOfBandOutputDetails { - command: command.map(|c| c.to_string()), - content: content.to_string(), - })); - } -} - -/// Drain pending remote tool calls into a `ToolSummary`. -fn flush_remote(pending: &mut Vec<ToolCallDetails>, out: &mut Vec<UiEvent>) { - if !pending.is_empty() { - out.push(UiEvent::ToolSummary(ToolSummary { - tool_calls: std::mem::take(pending), - })); - } -} - -/// Drain a pending client-side tool group into a `ToolGroup`. -fn flush_group( - pending: &mut Option<(ToolGroupKind, Vec<ToolCallDetails>)>, - out: &mut Vec<UiEvent>, -) { - if let Some((kind, calls)) = pending.take() { - out.push(UiEvent::ToolGroup(ToolGroup { kind, calls })); - } -} - -#[derive(Debug)] -pub(crate) struct ToolSummary { - tool_calls: Vec<ToolCallDetails>, -} - -impl ToolSummary { - /// Determines the summary line: - /// - If any call is pending, use present tense verb with `-ing` - /// - If multiple calls are complete, say "Used n tools" - /// - If a single call is complete, use past tense verb - pub(crate) fn summary(&self) -> String { - if self.any_pending() { - // Find the last pending tool for the active verb - if let Some(pending) = self - .tool_calls - .iter() - .rev() - .find(|t| t.status == ToolResultStatus::Pending) - { - return Self::progressive_verb(&pending.name); - } - } - - if self.tool_calls.len() == 1 { - return Self::past_verb(&self.tool_calls[0].name); - } - - format!("Used {} tools", self.tool_calls.len()) - } - - /// Determines if the spinner should be spinning - pub(crate) fn any_pending(&self) -> bool { - self.tool_calls - .iter() - .any(|tool_call| tool_call.status == ToolResultStatus::Pending) - } - - /// Present-tense progressive verb for a tool name (e.g. "Searching...") - fn progressive_verb(name: &str) -> String { - descriptor::by_name(name) - .map(|d| d.progressive_verb.to_string()) - .unwrap_or_else(|| format!("Running {}...", name.replace('_', " "))) - } - - /// Past-tense verb for a tool name (e.g. "Searched") - fn past_verb(name: &str) -> String { - descriptor::by_name(name) - .map(|d| d.past_verb.to_string()) - .unwrap_or_else(|| format!("Ran {}", name.replace('_', " "))) - } -} |
