aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/view
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai/src/tui/view')
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs570
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs198
2 files changed, 659 insertions, 109 deletions
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>,