aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-04-21 10:32:54 -0700
committerGitHub <noreply@github.com>2026-04-21 10:32:54 -0700
commit0f20ee4eb871907defe7848f0d3e2203cfff057e (patch)
treecda9034c4c6e7b5ecf0fe957978284e9138b80ff /crates/atuin-ai/src/tui
parentchore: Clarified note about regular expressions matching in path. (#3427) (diff)
downloadatuin-0f20ee4eb871907defe7848f0d3e2203cfff057e.zip
feat: AI tool rendering overhaul + edit_file tool (#3423)
Overhaul of how AI tool calls are modeled, rendered, and displayed in the Atuin AI TUI. Fixes bugs in shell command output capture, implements the `edit_file` tool with full safety infrastructure, and adds a diff preview for edits.
Diffstat (limited to 'crates/atuin-ai/src/tui')
-rw-r--r--crates/atuin-ai/src/tui/dispatch.rs199
-rw-r--r--crates/atuin-ai/src/tui/events.rs27
-rw-r--r--crates/atuin-ai/src/tui/state.rs9
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs570
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs198
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>,