aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai/src/tui')
-rw-r--r--crates/atuin-ai/src/tui/components/atuin_ai.rs143
-rw-r--r--crates/atuin-ai/src/tui/components/input_box.rs220
-rw-r--r--crates/atuin-ai/src/tui/components/markdown.rs210
-rw-r--r--crates/atuin-ai/src/tui/components/mod.rs5
-rw-r--r--crates/atuin-ai/src/tui/components/select.rs95
-rw-r--r--crates/atuin-ai/src/tui/components/session_continue.rs49
-rw-r--r--crates/atuin-ai/src/tui/content/help.md6
-rw-r--r--crates/atuin-ai/src/tui/events.rs67
-rw-r--r--crates/atuin-ai/src/tui/mod.rs7
-rw-r--r--crates/atuin-ai/src/tui/slash.rs79
-rw-r--r--crates/atuin-ai/src/tui/state.rs237
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs978
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs606
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('_', " ")))
- }
-}