diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-10 13:24:57 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-10 20:24:57 +0000 |
| commit | 09279a428659cf41824737d3e0c97bcc19a8885a (patch) | |
| tree | 64731502c065df2483e8dd680d46c5559f3094f2 /crates/atuin-ai/src/tui/view | |
| parent | feat: add strip_trailing_whitespace, on by default (#3390) (diff) | |
| download | atuin-09279a428659cf41824737d3e0c97bcc19a8885a.zip | |
feat: Client-tool execution + permission system (#3370)
Adds client-side tool execution to Atuin AI, starting with
`atuin_history`. The server can request tool calls, which are executed
locally with a permission system, and results are sent back to continue
the conversation.
Diffstat (limited to 'crates/atuin-ai/src/tui/view')
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 225 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 50 |
2 files changed, 222 insertions, 53 deletions
diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs index 0cd51dfa..ee5483d8 100644 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -1,14 +1,20 @@ //! View function that builds the eye-declare element tree from app state. use eye_declare::{ - Cells, Column, Elements, HStack, Span, Spinner, Text, View, WidthConstraint, element, + BorderType, 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::tui::components::select::SelectOption; +use crate::tui::events::{AiTuiEvent, PermissionResult}; + use super::components::atuin_ai::AtuinAi; use super::components::input_box::InputBox; use super::components::markdown::Markdown; -use super::state::{AppMode, AppState}; +use super::components::select::Select; +use super::state::{AppMode, Session}; mod turn; @@ -20,23 +26,25 @@ mod turn; /// - Error display (if in error state) /// - Spacer /// - Input box (bordered, with contextual keybindings) -pub fn ai_view(state: &AppState) -> Elements { - let mut turn_builder = turn::TurnBuilder::new(); +pub(crate) fn ai_view(state: &Session) -> Elements { + let mut turn_builder = turn::TurnBuilder::new(&state.tool_tracker); - for event in &state.events { + for event in &state.conversation.events { turn_builder.add_event(event); } let turns = turn_builder.build(); - let busy = state.mode == AppMode::Streaming || state.mode == AppMode::Generating; + let busy = state.interaction.mode == AppMode::Streaming + || state.interaction.mode == AppMode::Generating; let last_index = turns.len().saturating_sub(1); element! { AtuinAi( - mode: state.mode, - has_command: state.has_any_command(), - is_input_blank: state.is_input_blank, - pending_confirmation: state.confirmation_pending, + mode: state.interaction.mode, + has_command: state.conversation.has_any_command(), + is_input_blank: state.interaction.is_input_blank, + pending_confirmation: state.interaction.confirmation_pending, + has_executing_preview: state.tool_tracker.has_executing_preview(), ) { #(for (index, turn) in turns.iter().enumerate() { #(match turn { @@ -53,25 +61,94 @@ pub fn ai_view(state: &AppState) -> Elements { }) #(if !state.is_exiting() { - View(key: "input-box", padding_top: Cells::from(1)) { - InputBox( - key: "input", - title: "Generate a command or ask a question", - title_right: "Atuin AI", - footer: state.footer_text(), - active: state.mode == AppMode::Input && !state.confirmation_pending, - ) + #(input_view(state)) + }) + } + } +} - #(if state.is_input_blank && state.has_any_command() && state.mode == AppMode::Input { - #(if state.confirmation_pending { - Text { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) } - } else { - Text { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) } - }) +fn input_view(state: &Session) -> Elements { + let asking_tool = state.tool_tracker.asking_for_permission(); + let in_git_project = state.in_git_project; + + element! { + #(if let Some(tc) = asking_tool { + #(tool_call_view(tc, in_git_project)) + }) + + #(if asking_tool.is_none() { + View(key: "input-box", padding_top: Cells::from(1)) { + InputBox( + key: "input", + title: "Generate a command or ask a question", + title_right: "Atuin AI", + footer: state.footer_text(), + active: state.interaction.mode == AppMode::Input && !state.interaction.confirmation_pending, + ) + + #(if state.interaction.is_input_blank && state.conversation.has_any_command() && state.interaction.mode == AppMode::Input { + #(if state.interaction.confirmation_pending { + Text { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) } + } else { + Text { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) } }) + }) + } + }) + } +} - } - }) +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::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" + }; + + element! { + View(key: format!("tool-call-{}", tool_call.id), padding_left: Cells::from(2), padding_top: Cells::from(1)) { + Text { + Span(text: format!("Atuin AI would like to {}: ", verb), style: Style::default()) + Span(text: &tool_desc, style: Style::default().fg(Color::Yellow)) + } + View(padding_left: Cells::from(2)) { + Select(options: [ + 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)) + }) as Box<dyn Fn(&SelectOption) -> Option<AiTuiEvent> + Send + Sync>) + } } } } @@ -86,7 +163,7 @@ fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements { element! { View(padding_top: Cells::from(padding)) { Text { - Span(text: "You", style: label_style) + Span(text: " You ", style: label_style.reversed()) } #(for event in events { #(match event { @@ -114,9 +191,9 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { element! { View { Spinner( - label: "Atuin AI", - label_style: label_style, - done_label_style: label_style, + label: " Atuin AI ", + label_style: label_style.reversed(), + done_label_style: label_style.reversed(), hide_checkmark: true, label_first: true, done: !busy, @@ -136,6 +213,52 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { turn::UiEvent::SuggestedCommand(details) => { 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)) + }) + } + } + } _ => element!{} }) }) @@ -180,6 +303,48 @@ fn tool_summary_view(summary: &turn::ToolSummary) -> Elements { } } +/// Render a status indicator for a non-preview tool call (e.g. atuin_history, read_file). +fn tool_status_view(name: &str, status: &turn::ToolResultStatus) -> Elements { + match status { + turn::ToolResultStatus::Pending => { + element! { + Spinner( + label: format!("Running: {name}"), + label_style: Style::default().fg(Color::Yellow), + done: false, + ) + } + } + turn::ToolResultStatus::Success => { + element! { + Spinner( + label: format!("Ran: {name}"), + done: true, + ) + } + } + turn::ToolResultStatus::Error => { + element! { + Text { + Span(text: "✗ ", style: Style::default().fg(Color::Red)) + Span(text: format!("{name}: denied"), style: Style::default().fg(Color::Red)) + } + } + } + } +} + +/// Render a spinner/status line for a command preview (shell tools). +fn preview_spinner_view(name: &str, done: bool) -> Elements { + element! { + Spinner( + label: if done { format!("Ran: {name}") } else { format!("Running: {name}") }, + label_style: Style::default().fg(Color::Yellow), + done: done, + ) + } +} + fn suggested_command_view(details: &turn::SuggestedCommandDetails) -> Elements { let is_dangerous = matches!( details.danger_level, diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs index 861da64c..6949236c 100644 --- a/crates/atuin-ai/src/tui/view/turn.rs +++ b/crates/atuin-ai/src/tui/view/turn.rs @@ -1,5 +1,8 @@ +use crate::tools::descriptor; +use crate::tools::{ToolPreview, ToolTracker}; use crate::tui::ConversationEvent; +/// Server-sent danger level for a suggested command #[derive(Debug)] pub(crate) enum DangerLevel { Low(Option<String>), @@ -37,6 +40,7 @@ impl From<(&String, &String)> for DangerLevel { } } +/// Server-sent confidence level for a suggested command #[derive(Debug)] pub(crate) enum ConfidenceLevel { Low(Option<String>), @@ -85,9 +89,11 @@ pub(crate) enum UiEvent { #[derive(Debug)] pub(crate) struct ToolCallDetails { - tool_use_id: String, - name: String, - status: ToolResultStatus, + pub(crate) tool_use_id: String, + pub(crate) name: String, + pub(crate) status: ToolResultStatus, + pub(crate) is_client: bool, + pub(crate) preview: Option<ToolPreview>, } #[derive(Debug)] @@ -118,16 +124,19 @@ pub(crate) enum UiTurn { OutOfBand { events: Vec<UiEvent> }, } -pub(crate) struct TurnBuilder { +pub(crate) struct TurnBuilder<'a> { turns: Vec<UiTurn>, current_turn: Option<UiTurn>, + tracker: &'a ToolTracker, } -impl TurnBuilder { - pub(crate) fn new() -> Self { +/// A struct to iteratively build [UiTurn] events from [ConversationEvent]s. +impl<'a> TurnBuilder<'a> { + pub(crate) fn new(tracker: &'a ToolTracker) -> Self { Self { turns: Vec::new(), current_turn: None, + tracker, } } @@ -174,7 +183,7 @@ impl TurnBuilder { for event in events.drain(..) { match event { - UiEvent::ToolCall(details) => { + UiEvent::ToolCall(details) if !details.is_client => { pending_tools.push(details); } other => { @@ -306,12 +315,17 @@ impl TurnBuilder { } 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); + self.start_agent_turn(); if let UiTurn::Agent { events } = self.turn_mut_unsafe() { events.push(UiEvent::ToolCall(ToolCallDetails { tool_use_id: id.to_string(), name: name.to_string(), status: ToolResultStatus::Pending, + is_client, + preview, })); } } @@ -385,25 +399,15 @@ impl ToolSummary { /// Present-tense progressive verb for a tool name (e.g. "Searching...") fn progressive_verb(name: &str) -> String { - match name { - "search" => "Searching...".into(), - "read" | "read_file" => "Reading file...".into(), - "write" | "write_file" => "Writing file...".into(), - "execute" | "run" | "bash" => "Running command...".into(), - "list" | "list_files" => "Listing files...".into(), - _ => format!("Running {}...", name.replace('_', " ")), - } + descriptor::by_name(name) + .map(|d| d.progressive_verb.to_string()) + .unwrap_or_else(|| format!("Running {}...", name.replace('_', " "))) } /// Past-tense verb for a tool name (e.g. "Searched") fn past_verb(name: &str) -> String { - match name { - "search" => "Searched".into(), - "read" | "read_file" => "Read file".into(), - "write" | "write_file" => "Wrote file".into(), - "execute" | "run" | "bash" => "Ran command".into(), - "list" | "list_files" => "Listed files".into(), - _ => format!("Ran {}", name.replace('_', " ")), - } + descriptor::by_name(name) + .map(|d| d.past_verb.to_string()) + .unwrap_or_else(|| format!("Ran {}", name.replace('_', " "))) } } |
