diff options
Diffstat (limited to 'crates/atuin-ai/src/tui')
| -rw-r--r-- | crates/atuin-ai/src/tui/dispatch.rs | 199 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/events.rs | 27 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 9 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 570 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 198 |
5 files changed, 889 insertions, 114 deletions
diff --git a/crates/atuin-ai/src/tui/dispatch.rs b/crates/atuin-ai/src/tui/dispatch.rs index ea895c01..fea26953 100644 --- a/crates/atuin-ai/src/tui/dispatch.rs +++ b/crates/atuin-ai/src/tui/dispatch.rs @@ -61,15 +61,17 @@ pub(crate) fn dispatch(ctx: &mut DispatchContext, event: AiTuiEvent) -> bool { !ctx.exiting.load(Ordering::Acquire) } -/// Persist new events and the server session ID if it has changed. +/// Persist new events, server session ID, file tracker, and edit permissions. /// Called from the dispatch thread (sync), bridges to async via the tokio handle. fn persist_session(ctx: &mut DispatchContext) { - let Ok((events, server_sid)) = ctx + let Ok((events, server_sid, file_tracker_json, edit_perms_json)) = ctx .handle .fetch(|state| { ( state.conversation.events.clone(), state.conversation.session_id.clone(), + state.file_tracker.to_json().ok(), + state.edit_permissions.to_json().ok(), ) }) .blocking_recv() @@ -86,6 +88,22 @@ fn persist_session(ctx: &mut DispatchContext) { { tracing::warn!("failed to persist server session ID: {e}"); } + if let Some(ref json) = file_tracker_json + && let Err(e) = rt.block_on( + ctx.session_mgr + .set_metadata(crate::file_tracker::METADATA_KEY, json), + ) + { + tracing::warn!("failed to persist file tracker: {e}"); + } + if let Some(ref json) = edit_perms_json + && let Err(e) = rt.block_on( + ctx.session_mgr + .set_metadata(crate::edit_permissions::METADATA_KEY, json), + ) + { + tracing::warn!("failed to persist edit permissions: {e}"); + } } fn launch_stream(ctx: &DispatchContext, setup: impl FnOnce(&mut Session) + Send + 'static) { @@ -210,6 +228,10 @@ fn execute_tool( let shell_call = shell_call.clone(); execute_shell_tool(handle, tx, &tool_id, &shell_call); } + ClientToolCall::Edit(edit_call) => { + let edit_call = edit_call.clone(); + execute_edit_tool(handle, tx, tool_id, edit_call); + } _ => { execute_simple_tool(handle, tx, tool_id, tool, db); } @@ -231,7 +253,21 @@ fn execute_simple_tool( tokio::spawn(async move { let outcome = tool.execute(&db).await; + + // After a successful file read, capture tracking data for freshness + // checking. This re-stats the file to get content hash and mtime. + let read_tracking = if let ClientToolCall::Read(ref read_tool) = tool + && !outcome.is_error() + { + capture_read_tracking(&read_tool.path) + } else { + None + }; + h.update(move |state| { + if let Some((path, content, mtime)) = read_tracking { + state.file_tracker.record_read(path, &content, mtime); + } state.finish_tool_call(&tool_id, outcome); if !state.tool_tracker.has_pending() { let _ = tx.send(AiTuiEvent::ContinueAfterTools); @@ -240,6 +276,117 @@ fn execute_simple_tool( }); } +/// Capture file content and mtime for the read tracker. +/// Returns None for directories or if the file can't be read. +fn capture_read_tracking( + path: &std::path::Path, +) -> Option<(std::path::PathBuf, Vec<u8>, std::time::SystemTime)> { + let resolved = if path.is_relative() { + std::env::current_dir().ok()?.join(path) + } else { + path.to_path_buf() + }; + if !resolved.is_file() { + return None; + } + let content = std::fs::read(&resolved).ok()?; + let mtime = std::fs::metadata(&resolved).ok()?.modified().ok()?; + Some((resolved, content, mtime)) +} + +/// Execute an edit_file tool call. +/// +/// Orchestrates snapshot → execute → tracker update. The snapshot and +/// tracker mutations happen via `h.update()` (on the TUI thread) since +/// they need mutable Session state. The actual file I/O (freshness check, +/// read, match, atomic write) runs in the tokio task. +fn execute_edit_tool( + handle: &Handle<Session>, + tx: &mpsc::Sender<AiTuiEvent>, + tool_id: String, + edit_call: crate::tools::EditToolCall, +) { + let h = handle.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + let resolved = edit_call.resolved_path(); + + // 1. Read the original file content (used for snapshot + diff). + let old_content = std::fs::read(&resolved).ok(); + + // 2. Snapshot the original file before editing. + if let Some(ref content) = old_content { + let snap_path = resolved.clone(); + let snap_content = content.clone(); + h.update(move |state| { + if let Some(ref mut store) = state.snapshot_store + && let Err(e) = store.ensure_snapshot(&snap_path, &snap_content) + { + tracing::warn!("failed to create file snapshot: {e}"); + } + }); + } + + // 3. Fetch a clone of the file tracker for freshness checking. + let Ok(tracker) = h.fetch(|state| state.file_tracker.clone()).await else { + let tc_id = tool_id.clone(); + h.update(move |state| { + state.finish_tool_call( + &tc_id, + crate::tools::ToolOutcome::Error("Internal error: TUI unavailable".into()), + ); + if !state.tool_tracker.has_pending() { + let _ = tx.send(AiTuiEvent::ContinueAfterTools); + } + }); + return; + }; + + // 4. Execute: freshness check → read → match → atomic write + let (outcome, new_bytes) = edit_call.execute(&resolved, &tracker); + + // 5. Compute diff preview on success + let edit_preview = if let Some(ref new_bytes) = new_bytes { + if let Some(ref old_bytes) = old_content { + let old_str = String::from_utf8_lossy(old_bytes); + let new_str = String::from_utf8_lossy(new_bytes); + let preview = crate::diff::EditPreview::compute(&old_str, &new_str); + if preview.hunks.is_empty() { + None + } else { + Some(preview) + } + } else { + None + } + } else { + None + }; + + // 6. Update tracker, store diff preview, and finish the tool call + let tc_id = tool_id; + h.update(move |state| { + if let Some(ref new_bytes) = new_bytes + && let Ok(mtime) = std::fs::metadata(&resolved).and_then(|m| m.modified()) + { + state + .file_tracker + .update_after_edit(&resolved, new_bytes, mtime); + } + if let Some(preview) = edit_preview + && let Some(tracked) = state.tool_tracker.get_mut(&tc_id) + { + tracked.edit_preview = Some(preview); + } + state.finish_tool_call(&tc_id, outcome); + if !state.tool_tracker.has_pending() { + let _ = tx.send(AiTuiEvent::ContinueAfterTools); + } + }); + }); +} + /// Execute a shell tool with streaming VT100 preview. fn execute_shell_tool( handle: &Handle<Session>, @@ -352,12 +499,28 @@ async fn check_tool_permission_inner( .map_err(|e| format!("Internal error fetching tool state: {e}"))? .ok_or_else(|| "Internal error: tool not found in tracker".to_string())?; - // 2. Resolve working directory + // 2. For edit tools, check session-scoped permission grants before + // hitting the filesystem-based resolver. A valid grant means the user + // already approved this file recently. + if let ClientToolCall::Edit(ref edit) = tool { + let resolved = edit.resolved_path(); + let has_grant = h2 + .fetch(move |state| state.edit_permissions.has_valid_grant(&resolved)) + .await + .unwrap_or(false); + + if has_grant { + execute_tool(h2, tx, id, tool, db); + return Ok(()); + } + } + + // 3. Resolve working directory let working_dir = target_dir .or_else(|| std::env::current_dir().ok()) .ok_or_else(|| "Could not determine working directory".to_string())?; - // 3. Create permission resolver and check + // 4. Create permission resolver and check let resolver = PermissionResolver::new(working_dir) .await .map_err(|e| format!("Permission check failed: {e}"))?; @@ -367,7 +530,7 @@ async fn check_tool_permission_inner( .await .map_err(|e| format!("Permission check failed: {e}"))?; - // 4. Handle response — all paths here handle the tool, so return Ok + // 5. Handle response — all paths here handle the tool, so return Ok let id_clone = id.clone(); match response { PermissionResponse::Allowed => { @@ -423,6 +586,32 @@ fn on_select_permission(ctx: &mut DispatchContext, permission: PermissionResult) execute_tool(&h2, &tx, tool_id, tool, &db); }); } + PermissionResult::AllowFileForSession => { + // Cache a session-scoped, time-limited grant for this file + let db = ctx.app_ctx.history_db.clone(); + tokio::spawn(async move { + let Ok(Some((tool_id, tool))) = h2 + .fetch(move |state| { + state + .tool_tracker + .asking_for_permission() + .map(|t| (t.id.clone(), t.tool.clone())) + }) + .await + else { + return; + }; + + if let ClientToolCall::Edit(ref edit) = tool { + let resolved = edit.resolved_path(); + h2.update(move |state| { + state.edit_permissions.grant(resolved); + }); + } + + execute_tool(&h2, &tx, tool_id, tool, &db); + }); + } PermissionResult::AlwaysAllowInDir => { let db = ctx.app_ctx.history_db.clone(); let git_root = ctx.app_ctx.git_root.clone(); diff --git a/crates/atuin-ai/src/tui/events.rs b/crates/atuin-ai/src/tui/events.rs index 1a422fef..969f6ae5 100644 --- a/crates/atuin-ai/src/tui/events.rs +++ b/crates/atuin-ai/src/tui/events.rs @@ -38,7 +38,34 @@ pub(crate) enum AiTuiEvent { #[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/state.rs b/crates/atuin-ai/src/tui/state.rs index e122918e..af1ebffe 100644 --- a/crates/atuin-ai/src/tui/state.rs +++ b/crates/atuin-ai/src/tui/state.rs @@ -474,6 +474,12 @@ pub(crate) struct Session { pub slash_registry: SlashCommandRegistry, /// The unique ID for this invocation pub invocation_id: String, + /// Tracks which files have been read, for freshness checking before edits. + pub file_tracker: crate::file_tracker::FileReadTracker, + /// Session-scoped edit permission grants (per-file, time-limited). + pub edit_permissions: crate::edit_permissions::EditPermissionCache, + /// Backs up files before the first edit in a session. + pub snapshot_store: Option<crate::snapshots::SnapshotStore>, } impl Session { @@ -491,6 +497,9 @@ impl Session { archived_view_events: Vec::new(), slash_registry: Default::default(), invocation_id: invocation_id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string()), + file_tracker: Default::default(), + edit_permissions: Default::default(), + snapshot_store: None, } } diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs index 565a0597..bdbece9c 100644 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -1,12 +1,11 @@ //! View function that builds the eye-declare element tree from app state. use eye_declare::{ - BorderType, Cells, Column, Elements, HStack, Span, Spinner, Text, View, Viewport, - WidthConstraint, element, + Cells, Column, Elements, HStack, Span, Spinner, Text, View, Viewport, WidthConstraint, element, }; use ratatui_core::style::{Color, Modifier, Style}; -use crate::tools::{ClientToolCall, TrackedTool}; +use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview, TrackedTool}; use crate::tui::components::select::SelectOption; use crate::tui::components::session_continue::SessionContinue; use crate::tui::events::{AiTuiEvent, PermissionResult}; @@ -68,6 +67,16 @@ pub(crate) fn ai_view(state: &Session) -> Elements { }) }) + #({ + let needs_pending_banner = busy && !matches!(turns.last(), Some(turn::UiTurn::Agent { .. })); + if needs_pending_banner { + let empty: &[turn::UiEvent] = &[]; + agent_turn_view(empty, true) + } else { + element! {} + } + }) + #(if !state.is_exiting() { #(input_view(state)) }) @@ -135,16 +144,13 @@ fn tool_call_view(tool_call: &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(), }; - let dir_label = if in_git_project { - "Always allow in this workspace" - } else { - "Always allow in this directory" - }; + 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)) { @@ -153,39 +159,68 @@ fn tool_call_view(tool_call: &TrackedTool, in_git_project: bool) -> Elements { Span(text: &tool_desc, style: Style::default().fg(Color::Yellow)) } View(padding_left: Cells::from(2)) { - Select(options: [ - SelectOption::builder() - .label("Allow") - .value("allow") - .build(), - SelectOption::builder() - .label(dir_label) - .value("always-allow-in-dir") - .build(), - SelectOption::builder() - .label("Always allow") - .value("always-allow") - .build(), - SelectOption::builder() - .label("Deny") - .value("deny") - .build(), - ], on_select: Box::new(move |option: &SelectOption| { - let value = match option.value.as_str() { - "allow" => PermissionResult::Allow, - "always-allow-in-dir" => PermissionResult::AlwaysAllowInDir, - "always-allow" => PermissionResult::AlwaysAllow, - "deny" => PermissionResult::Deny, - _ => unreachable!(), - }; - - Some(AiTuiEvent::SelectPermission(value)) + 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(_) => 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) -> Elements { let label_style = Style::default() .fg(Color::Cyan) @@ -231,7 +266,10 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { label_first: true, done: !busy, ) - #(for event in events { + #(for (i, event) in events.iter().enumerate() { + #(if i > 0 { + Text { Span(text: "") } + }) #(match event { turn::UiEvent::Text { content } => { element! { @@ -247,47 +285,42 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { suggested_command_view(details) }, turn::UiEvent::ToolCall(details) => { - let preview_done = details.preview.as_ref().is_some_and(|p| p.exit_code.is_some() || p.interrupted); let tool_key = details.tool_use_id.clone(); element! { View(key: format!("tool-output-{tool_key}"), padding_left: Cells::from(2)) { - #(if let Some(ref preview) = details.preview { - View(key: format!("preview-{tool_key}")) { - #(preview_spinner_view(&details.name, preview_done)) - Viewport( - key: format!("viewport-{tool_key}"), - lines: preview.lines.clone(), - height: 10, - border: BorderType::Plain, - border_style: Style::default().fg(Color::DarkGray), - style: Style::default().fg(Color::White), - wrap: false, - ) - #(if let Some(code) = preview.exit_code { - #(if code == 0 { - Text { - Span(text: format!("Exit code: {code}"), style: Style::default().fg(Color::Green)) - } - } else { - Text { - Span(text: format!("Exit code: {code}"), style: Style::default().fg(Color::Red)) - } - }) - }) - #(if preview.interrupted { - Text { - Span(text: "Interrupted", style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) - } - }) - #(if !preview_done { - Text { - Span(text: "[Ctrl+C] Interrupt", style: Style::default().fg(Color::DarkGray)) - } - }) - } - } else { - #(tool_status_view(&details.name, &details.status)) + #(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 } => { + file_write_tool_view(&details.status, path) + }, + turn::ToolRenderData::Remote => { + tool_status_view(&details.name, &details.status) + }, + turn::ToolRenderData::FileRead { .. } + | turn::ToolRenderData::HistorySearch { .. } => { + 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), }) } } @@ -367,17 +400,391 @@ fn tool_status_view(name: &str, status: &turn::ToolResultStatus) -> Elements { } } -/// Render a spinner/status line for a command preview (shell tools). -fn preview_spinner_view(name: &str, done: bool) -> Elements { +// ─────────────────────────────────────────────────────────────────── +// 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); + + 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 { + if preview.interrupted { + return element! { + Text { + Span(text: "Interrupted", 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 status with the target path. +fn file_write_tool_view(status: &turn::ToolResultStatus, path: &std::path::Path) -> Elements { + let display_path = path.display(); + match status { + turn::ToolResultStatus::Pending => { + element! { + Spinner( + label: format!("Writing: {display_path}"), + label_style: Style::default().fg(Color::Yellow), + done: false, + ) + } + } + turn::ToolResultStatus::Success => { + element! { + Spinner(label: format!("Wrote: {display_path}"), done: true) + } + } + turn::ToolResultStatus::Error => { + element! { + Text { + Span(text: "✗ ", style: Style::default().fg(Color::Red)) + Span(text: format!("Write {display_path}: denied"), style: Style::default().fg(Color::Red)) + } + } + } + } +} + +// ─────────────────────────────────────────────────────────────────── +// 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: if done { format!("Ran: {name}") } else { format!("Running: {name}") }, - label_style: Style::default().fg(Color::Yellow), - done: done, - ) + 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, @@ -413,9 +820,6 @@ fn suggested_command_view(details: &turn::SuggestedCommandDetails) -> Elements { element! { View { - #(if !details.first_event_in_turn { - Text { Span(text: "") } - }) Text { Span(text: " Suggested command:", style: Style::default().fg(Color::Cyan)) } diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs index a2555dc6..1c19a6b2 100644 --- a/crates/atuin-ai/src/tui/view/turn.rs +++ b/crates/atuin-ai/src/tui/view/turn.rs @@ -1,5 +1,7 @@ +use std::path::PathBuf; + use crate::tools::descriptor; -use crate::tools::{ToolPreview, ToolTracker}; +use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview, ToolTracker}; use crate::tui::ConversationEvent; /// Server-sent danger level for a suggested command @@ -80,20 +82,99 @@ impl From<(&String, &String)> for ConfidenceLevel { #[derive(Debug)] pub(crate) enum UiEvent { - Text { content: String }, + 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 }, + /// Atuin history search. + HistorySearch { + query: String, + filter_modes: Vec<HistorySearchFilterMode>, + }, + /// 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) is_client: bool, - pub(crate) preview: Option<ToolPreview>, + pub(crate) render_data: ToolRenderData, } #[derive(Debug)] @@ -101,7 +182,6 @@ pub(crate) struct SuggestedCommandDetails { pub(crate) command: String, pub(crate) danger_level: DangerLevel, pub(crate) confidence_level: ConfidenceLevel, - pub(crate) first_event_in_turn: bool, } #[derive(Debug)] @@ -179,33 +259,49 @@ impl<'a> TurnBuilder<'a> { pub(crate) fn build(&mut self) -> Vec<UiTurn> { self.commit_turn(); - // Collapse consecutive tool calls within each agent turn into ToolSummary + // 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 UiTurn::Agent { events } = turn { let mut new_events: Vec<UiEvent> = Vec::new(); - let mut pending_tools: Vec<ToolCallDetails> = 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.is_client => { - pending_tools.push(details); + UiEvent::ToolCall(details) if details.render_data.is_remote() => { + flush_group(&mut pending_group, &mut new_events); + pending_remote.push(details); } - other => { - if !pending_tools.is_empty() { - new_events.push(UiEvent::ToolSummary(ToolSummary { - tool_calls: std::mem::take(&mut pending_tools), - })); + 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); } } } - if !pending_tools.is_empty() { - new_events.push(UiEvent::ToolSummary(ToolSummary { - tool_calls: pending_tools, - })); - } + flush_remote(&mut pending_remote, &mut new_events); + flush_group(&mut pending_group, &mut new_events); *events = new_events; } @@ -255,6 +351,9 @@ impl<'a> TurnBuilder<'a> { } fn add_agent_text(&mut self, content: &str) { + if content.trim().is_empty() { + return; + } self.start_agent_turn(); if let UiTurn::Agent { events } = self.turn_mut_unsafe() { events.push(UiEvent::Text { @@ -303,8 +402,6 @@ impl<'a> TurnBuilder<'a> { let danger = DangerLevel::from((&danger_level, &danger_notes)); let confidence = ConfidenceLevel::from((&confidence_level, &confidence_notes)); - let first_event_in_turn = events.is_empty(); - events.push(UiEvent::SuggestedCommand(SuggestedCommandDetails { command: input .get("command") @@ -313,14 +410,12 @@ impl<'a> TurnBuilder<'a> { .to_string(), danger_level: danger, confidence_level: confidence, - first_event_in_turn, })); } } fn add_tool_call(&mut self, id: &str, name: &str, _input: &serde_json::Value) { - let is_client = descriptor::by_name(name).is_some_and(|d| d.is_client); - let preview = self.tracker.preview_for(id); + let render_data = self.build_render_data(id, name); self.start_agent_turn(); if let UiTurn::Agent { events } = self.turn_mut_unsafe() { @@ -328,12 +423,44 @@ impl<'a> TurnBuilder<'a> { tool_use_id: id.to_string(), name: name.to_string(), status: ToolResultStatus::Pending, - is_client, - preview, + 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.preview(), + }, + ClientToolCall::Read(read) => ToolRenderData::FileRead { + path: read.path.clone(), + }, + ClientToolCall::Edit(edit) => ToolRenderData::FileEdit { + path: edit.path.clone(), + preview: tracked.edit_preview.clone(), + }, + ClientToolCall::Write(write) => ToolRenderData::FileWrite { + path: write.path.clone(), + }, + ClientToolCall::AtuinHistory(history) => ToolRenderData::HistorySearch { + query: history.query.clone(), + filter_modes: history.filter_modes.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(); if let UiTurn::Agent { events } = self.turn_mut_unsafe() { @@ -364,6 +491,25 @@ impl<'a> TurnBuilder<'a> { } } +/// 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>, |
