aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/view
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-04-10 13:24:57 -0700
committerGitHub <noreply@github.com>2026-04-10 20:24:57 +0000
commit09279a428659cf41824737d3e0c97bcc19a8885a (patch)
tree64731502c065df2483e8dd680d46c5559f3094f2 /crates/atuin-ai/src/tui/view
parentfeat: add strip_trailing_whitespace, on by default (#3390) (diff)
downloadatuin-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.rs225
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs50
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('_', " ")))
}
}