aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui
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
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')
-rw-r--r--crates/atuin-ai/src/tui/components/atuin_ai.rs16
-rw-r--r--crates/atuin-ai/src/tui/components/markdown.rs47
-rw-r--r--crates/atuin-ai/src/tui/components/mod.rs7
-rw-r--r--crates/atuin-ai/src/tui/components/select.rs96
-rw-r--r--crates/atuin-ai/src/tui/dispatch.rs571
-rw-r--r--crates/atuin-ai/src/tui/events.rs19
-rw-r--r--crates/atuin-ai/src/tui/mod.rs11
-rw-r--r--crates/atuin-ai/src/tui/state.rs600
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs225
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs50
10 files changed, 1273 insertions, 369 deletions
diff --git a/crates/atuin-ai/src/tui/components/atuin_ai.rs b/crates/atuin-ai/src/tui/components/atuin_ai.rs
index fab29502..c04ac722 100644
--- a/crates/atuin-ai/src/tui/components/atuin_ai.rs
+++ b/crates/atuin-ai/src/tui/components/atuin_ai.rs
@@ -22,10 +22,11 @@ pub(crate) struct AtuinAi {
pub has_command: bool,
pub is_input_blank: bool,
pub pending_confirmation: bool,
+ pub has_executing_preview: bool,
}
#[derive(Default)]
-pub struct AtuinAiState {
+pub(crate) struct AtuinAiState {
tx: Option<mpsc::Sender<AiTuiEvent>>,
}
@@ -55,15 +56,24 @@ fn atuin_ai(
return EventResult::Ignored;
};
- // Ctrl+C always exits
+ // Ctrl+C — interrupt executing command or exit
if modifiers.contains(KeyModifiers::CONTROL) && *code == KeyCode::Char('c') {
- let _ = tx.send(AiTuiEvent::Exit);
+ if props.has_executing_preview {
+ let _ = tx.send(AiTuiEvent::InterruptToolExecution);
+ } else {
+ let _ = tx.send(AiTuiEvent::Exit);
+ }
return EventResult::Consumed;
}
match props.mode {
AppMode::Input => match code {
KeyCode::Esc => {
+ if props.has_executing_preview {
+ let _ = tx.send(AiTuiEvent::InterruptToolExecution);
+ return EventResult::Consumed;
+ }
+
if props.pending_confirmation {
let _ = tx.send(AiTuiEvent::CancelConfirmation);
return EventResult::Consumed;
diff --git a/crates/atuin-ai/src/tui/components/markdown.rs b/crates/atuin-ai/src/tui/components/markdown.rs
index 1cd7dbcf..f164fdc5 100644
--- a/crates/atuin-ai/src/tui/components/markdown.rs
+++ b/crates/atuin-ai/src/tui/components/markdown.rs
@@ -16,20 +16,12 @@ use ratatui_widgets::paragraph::{Paragraph, Wrap};
/// A markdown rendering component backed by pulldown-cmark.
#[props]
-pub struct Markdown {
+pub(crate) struct Markdown {
pub source: String,
}
-impl Markdown {
- pub fn new(source: impl Into<String>) -> Self {
- Self {
- source: source.into(),
- }
- }
-}
-
/// Style configuration for markdown rendering.
-pub struct MarkdownStyles {
+pub(crate) struct MarkdownStyles {
pub base: Style,
pub code_inline: Style,
pub code_block: Style,
@@ -98,26 +90,22 @@ fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'stat
let mut style_stack: Vec<Style> = vec![styles.base];
let mut in_code_block = false;
+ let mut in_list_item = false;
+ // True until the first paragraph inside a list item has been opened.
+ // The first paragraph should flow inline with the "- " prefix.
+ let mut list_item_first_para = false;
for event in parser {
match event {
Event::Start(Tag::Strong) => {
- let bold = style_stack
- .last()
- .copied()
- .unwrap_or(styles.base)
- .add_modifier(Modifier::BOLD);
+ let bold = style_stack.last().copied().unwrap_or(styles.bold);
style_stack.push(bold);
}
Event::End(TagEnd::Strong) => {
style_stack.pop();
}
Event::Start(Tag::Emphasis) => {
- let italic = style_stack
- .last()
- .copied()
- .unwrap_or(styles.base)
- .add_modifier(Modifier::ITALIC);
+ let italic = style_stack.last().copied().unwrap_or(styles.italic);
style_stack.push(italic);
}
Event::End(TagEnd::Emphasis) => {
@@ -170,12 +158,17 @@ fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'stat
lines.push(Vec::new());
}
Event::Start(Tag::Paragraph) => {
- if current_line > 0 || !lines[0].is_empty() {
- // Two line advances: one to end the current line, one for a blank separator.
- current_line += 1;
- lines.push(Vec::new());
+ if in_list_item && list_item_first_para {
+ // First paragraph flows inline with the "- " prefix
+ list_item_first_para = false;
+ } else if current_line > 0 || !lines[0].is_empty() {
current_line += 1;
lines.push(Vec::new());
+ if !in_list_item {
+ // Blank separator between paragraphs (but not inside list items)
+ current_line += 1;
+ lines.push(Vec::new());
+ }
}
}
Event::End(TagEnd::Paragraph) => {}
@@ -197,8 +190,12 @@ fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'stat
lines.push(Vec::new());
}
lines[current_line].push(Span::styled("- ", Style::default().fg(Color::DarkGray)));
+ in_list_item = true;
+ list_item_first_para = true;
+ }
+ Event::End(TagEnd::Item) => {
+ in_list_item = false;
}
- Event::End(TagEnd::Item) => {}
Event::Start(Tag::List(_)) => {
if current_line > 0 || !lines[0].is_empty() {
current_line += 1;
diff --git a/crates/atuin-ai/src/tui/components/mod.rs b/crates/atuin-ai/src/tui/components/mod.rs
index 2f684f5f..3458327d 100644
--- a/crates/atuin-ai/src/tui/components/mod.rs
+++ b/crates/atuin-ai/src/tui/components/mod.rs
@@ -1,3 +1,4 @@
-pub mod atuin_ai;
-pub mod input_box;
-pub mod markdown;
+pub(crate) mod atuin_ai;
+pub(crate) mod input_box;
+pub(crate) mod markdown;
+pub(crate) mod select;
diff --git a/crates/atuin-ai/src/tui/components/select.rs b/crates/atuin-ai/src/tui/components/select.rs
new file mode 100644
index 00000000..5abbe655
--- /dev/null
+++ b/crates/atuin-ai/src/tui/components/select.rs
@@ -0,0 +1,96 @@
+use std::sync::mpsc;
+
+use crossterm::event::KeyCode;
+use eye_declare::{Elements, EventResult, Hooks, Span, Text, View, component, element, props};
+use ratatui::style::Style;
+use typed_builder::TypedBuilder;
+
+use crate::tui::events::AiTuiEvent;
+
+type OnSelectFn = Box<dyn Fn(&SelectOption) -> Option<AiTuiEvent> + Send + Sync + 'static>;
+
+#[derive(TypedBuilder)]
+pub(crate) struct SelectOption {
+ #[builder(setter(into))]
+ pub label: String,
+ #[builder(setter(into))]
+ pub value: String,
+ #[builder(default = Style::default())]
+ pub label_style: Style,
+ #[builder(default = Style::default().reversed())]
+ pub selected_style: Style,
+}
+
+#[derive(Default)]
+pub(crate) struct PermissionSelectorState {
+ selected_option: usize,
+ tx: Option<mpsc::Sender<AiTuiEvent>>,
+}
+
+#[props]
+pub(crate) struct Select {
+ pub options: Vec<SelectOption>,
+ pub on_select: OnSelectFn,
+}
+
+#[component(props = Select, state = PermissionSelectorState)]
+pub(crate) fn permission_selector(
+ props: &Select,
+ state: &PermissionSelectorState,
+ hooks: &mut Hooks<Select, PermissionSelectorState>,
+) -> Elements {
+ hooks.use_focusable(true);
+ hooks.use_autofocus();
+
+ hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, _, state| {
+ state.tx = tx.cloned();
+ });
+
+ hooks.use_event(move |event, props, state| {
+ if !event.is_key_press() {
+ return EventResult::Ignored;
+ }
+
+ if let crossterm::event::Event::Key(key) = event {
+ if key.kind != crossterm::event::KeyEventKind::Press {
+ return EventResult::Ignored;
+ }
+
+ match key.code {
+ KeyCode::Up => {
+ state.selected_option =
+ (state.selected_option + props.options.len() - 1) % props.options.len();
+ return EventResult::Consumed;
+ }
+ KeyCode::Down => {
+ state.selected_option = (state.selected_option + 1) % props.options.len();
+ return EventResult::Consumed;
+ }
+ KeyCode::Enter => {
+ let option = &props.options[state.selected_option];
+ if let Some(event) = (props.on_select)(option)
+ && let Some(ref tx) = state.tx
+ {
+ let _ = tx.send(event);
+ }
+ return EventResult::Consumed;
+ }
+ _ => {}
+ }
+ }
+
+ EventResult::Ignored
+ });
+
+ element!(
+ View {
+ #(for (index, option) in props.options.iter().enumerate() {
+ Text { Span(text: &option.label, style: if index == state.selected_option {
+ option.selected_style
+ } else {
+ option.label_style
+ }) }
+ })
+ }
+ )
+}
diff --git a/crates/atuin-ai/src/tui/dispatch.rs b/crates/atuin-ai/src/tui/dispatch.rs
new file mode 100644
index 00000000..b3e84757
--- /dev/null
+++ b/crates/atuin-ai/src/tui/dispatch.rs
@@ -0,0 +1,571 @@
+use std::path::PathBuf;
+use std::sync::mpsc;
+
+use crate::context::{AppContext, ClientContext};
+use crate::permissions::check::PermissionResponse;
+use crate::permissions::resolver::PermissionResolver;
+use crate::permissions::rule::Rule;
+use crate::permissions::writer::{self, RuleDisposition};
+use crate::stream::{ChatRequest, run_chat_stream};
+use crate::tools::{ClientToolCall, ToolPhase};
+use crate::tui::events::{AiTuiEvent, PermissionResult};
+use crate::tui::state::{ExitAction, Session};
+use eye_declare::Handle;
+use tokio::task::JoinHandle;
+
+pub(crate) fn dispatch(
+ handle: &Handle<Session>,
+ event: AiTuiEvent,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ app_ctx: &AppContext,
+ client_ctx: &ClientContext,
+) {
+ match event {
+ AiTuiEvent::ContinueAfterTools => {
+ on_continue_after_tools(handle, tx, app_ctx, client_ctx);
+ }
+ AiTuiEvent::InputUpdated(input) => {
+ on_input_updated(handle, input);
+ }
+ AiTuiEvent::SubmitInput(input) => {
+ on_submit_input(handle, tx, app_ctx, client_ctx, input);
+ }
+ AiTuiEvent::SlashCommand(cmd) => {
+ on_slash_command(handle, cmd);
+ }
+ AiTuiEvent::CheckToolCallPermission(id) => {
+ on_check_tool_permission(handle, tx, app_ctx, id);
+ }
+ AiTuiEvent::SelectPermission(result) => {
+ on_select_permission(handle, tx, app_ctx, result);
+ }
+ AiTuiEvent::CancelGeneration => {
+ on_cancel_generation(handle);
+ }
+ AiTuiEvent::ExecuteCommand => {
+ on_execute_command(handle);
+ }
+ AiTuiEvent::CancelConfirmation => {
+ on_cancel_confirmation(handle);
+ }
+ AiTuiEvent::InterruptToolExecution => {
+ on_interrupt_tool_execution(handle);
+ }
+ AiTuiEvent::InsertCommand => {
+ on_insert_command(handle);
+ }
+ AiTuiEvent::Retry => {
+ on_retry(handle, tx, app_ctx, client_ctx);
+ }
+ AiTuiEvent::Exit => {
+ on_exit(handle);
+ }
+ }
+}
+
+fn launch_stream(
+ handle: &Handle<Session>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ app_ctx: &AppContext,
+ client_ctx: &ClientContext,
+ setup: impl FnOnce(&mut Session) + Send + 'static,
+) {
+ let h2 = handle.clone();
+ let tx2 = tx.clone();
+ let app = app_ctx.clone();
+ let cc = client_ctx.clone();
+ let caps = app_ctx.capabilities.clone();
+ handle.update(move |state| {
+ (setup)(state);
+ state.start_streaming();
+ let messages = state.conversation.events_to_messages();
+ let sid = state.conversation.session_id.clone();
+ let request = ChatRequest::new(messages, sid, &caps);
+ let task: JoinHandle<()> = tokio::spawn(async move {
+ run_chat_stream(h2, tx2, app, cc, request).await;
+ });
+ state.stream_abort = Some(task.abort_handle());
+ });
+}
+
+fn on_continue_after_tools(
+ handle: &Handle<Session>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ app_ctx: &AppContext,
+ client_ctx: &ClientContext,
+) {
+ launch_stream(handle, tx, app_ctx, client_ctx, |_state| {});
+}
+
+fn on_input_updated(handle: &Handle<Session>, input: String) {
+ let input_blank = input.trim().is_empty();
+
+ handle.update(move |state| {
+ state.interaction.is_input_blank = input_blank;
+ });
+}
+
+fn on_submit_input(
+ handle: &Handle<Session>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ app_ctx: &AppContext,
+ client_ctx: &ClientContext,
+ input: String,
+) {
+ let input = input.trim().to_string();
+ if input.is_empty() {
+ let h2 = handle.clone();
+ handle.update(move |state| {
+ if state.conversation.has_any_command() {
+ state.exit_action = Some(ExitAction::Execute(
+ state.conversation.current_command().unwrap().to_string(),
+ ));
+ } else {
+ state.exit_action = Some(ExitAction::Cancel);
+ }
+ h2.exit();
+ });
+ return;
+ }
+
+ if input.starts_with('/') {
+ handle.update(move |state| {
+ state.conversation.handle_slash_command(&input);
+ });
+ return;
+ }
+
+ // Start generation and spawn streaming task
+ launch_stream(handle, tx, app_ctx, client_ctx, |state| {
+ state.start_generating(input);
+ state.interaction.is_input_blank = true;
+ });
+}
+
+fn on_slash_command(handle: &Handle<Session>, command: String) {
+ handle.update(move |state| {
+ state.conversation.handle_slash_command(&command);
+ });
+}
+
+// ───────────────────────────────────────────────────────────────────
+// Tool execution dispatch
+// ───────────────────────────────────────────────────────────────────
+
+/// Execute a tool call. Handles Shell tools (streaming with preview) and
+/// non-shell tools (synchronous) uniformly.
+fn execute_tool(
+ handle: &Handle<Session>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ tool_id: String,
+ tool: ClientToolCall,
+ db: &std::sync::Arc<atuin_client::database::Sqlite>,
+) {
+ match &tool {
+ ClientToolCall::Shell(shell_call) => {
+ let shell_call = shell_call.clone();
+ execute_shell_tool(handle, tx, &tool_id, &shell_call);
+ }
+ _ => {
+ execute_simple_tool(handle, tx, tool_id, tool, db);
+ }
+ }
+}
+
+/// Execute a non-shell tool and finish the tool call.
+/// The ToolCall event is already in the conversation (added by handle_client_tool_call).
+fn execute_simple_tool(
+ handle: &Handle<Session>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ tool_id: String,
+ tool: ClientToolCall,
+ db: &std::sync::Arc<atuin_client::database::Sqlite>,
+) {
+ let h = handle.clone();
+ let tx = tx.clone();
+ let db = db.clone();
+
+ tokio::spawn(async move {
+ let outcome = tool.execute(&db).await;
+ h.update(move |state| {
+ state.finish_tool_call(&tool_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>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ tool_id: &str,
+ shell_call: &crate::tools::ShellToolCall,
+) {
+ let h = handle.clone();
+ let tx = tx.clone();
+ let shell_call = shell_call.clone();
+ let command = shell_call.command.clone();
+ let tc_id = tool_id.to_string();
+
+ // 1. Set up channels for streaming output and interruption
+ let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::<Vec<String>>(32);
+ let (abort_tx, abort_rx) = tokio::sync::oneshot::channel::<()>();
+
+ // 2. Mark as executing with preview and store the abort sender on the tracker entry
+ let tc_id_setup = tc_id.clone();
+ h.update(move |state| {
+ if let Some(tracked) = state.tool_tracker.get_mut(&tc_id_setup) {
+ tracked.mark_executing_preview(command);
+ tracked.abort_tx = Some(abort_tx);
+ }
+ });
+
+ // 3. Spawn a task to consume output updates and feed them to state
+ let h_output = h.clone();
+ let preview_id = tc_id.clone();
+ let output_task = tokio::spawn(async move {
+ while let Some(lines) = output_rx.recv().await {
+ let id = preview_id.clone();
+ h_output.update(move |state| {
+ if let Some(tracked) = state.tool_tracker.get_mut(&id)
+ && let ToolPhase::ExecutingWithPreview {
+ ref mut output_lines,
+ ..
+ } = tracked.phase
+ {
+ *output_lines = lines;
+ }
+ });
+ }
+ });
+
+ // 4. Spawn the streaming execution task
+ let tc_id_finish = tc_id;
+ tokio::spawn(async move {
+ let outcome =
+ crate::tools::execute_shell_command_streaming(&shell_call, output_tx, abort_rx).await;
+
+ // Wait for the output task to finish so the final preview lines are captured
+ let _ = output_task.await;
+
+ h.update(move |state| {
+ state.finish_tool_call(&tc_id_finish, outcome);
+ if !state.tool_tracker.has_pending() {
+ let _ = tx.send(AiTuiEvent::ContinueAfterTools);
+ }
+ });
+ });
+}
+
+// ───────────────────────────────────────────────────────────────────
+// Permission handlers
+// ───────────────────────────────────────────────────────────────────
+
+fn on_check_tool_permission(
+ handle: &Handle<Session>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ app_ctx: &AppContext,
+ id: String,
+) {
+ let h2 = handle.clone();
+ let tx_for_task = tx.clone();
+ let db = app_ctx.history_db.clone();
+
+ tokio::spawn(async move {
+ let id_for_error = id.clone();
+ let result = check_tool_permission_inner(&h2, &tx_for_task, &db, id).await;
+
+ // If the inner function didn't handle the tool (returned an error message),
+ // finish the tool call with that error so the conversation doesn't stall.
+ if let Err(error_msg) = result {
+ let tx = tx_for_task.clone();
+ h2.update(move |state| {
+ state.finish_tool_call(&id_for_error, crate::tools::ToolOutcome::Error(error_msg));
+ if !state.tool_tracker.has_pending() {
+ let _ = tx.send(AiTuiEvent::ContinueAfterTools);
+ }
+ });
+ }
+ });
+}
+
+/// Inner permission check that returns Err(message) if the tool call should be
+/// finished with an error. Returns Ok(()) if the tool was handled (executed,
+/// denied, or sent to the permission UI).
+async fn check_tool_permission_inner(
+ h2: &Handle<Session>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ db: &std::sync::Arc<atuin_client::database::Sqlite>,
+ id: String,
+) -> Result<(), String> {
+ // 1. Fetch the tracked tool's data
+ let id_for_fetch = id.clone();
+ let (tool, target_dir) = h2
+ .fetch(move |state| {
+ state
+ .tool_tracker
+ .get(&id_for_fetch)
+ .map(|t| (t.tool.clone(), t.target_dir().map(PathBuf::from)))
+ })
+ .await
+ .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
+ 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
+ let resolver = PermissionResolver::new(working_dir)
+ .await
+ .map_err(|e| format!("Permission check failed: {e}"))?;
+
+ let response = resolver
+ .check(&tool)
+ .await
+ .map_err(|e| format!("Permission check failed: {e}"))?;
+
+ // 4. Handle response — all paths here handle the tool, so return Ok
+ let id_clone = id.clone();
+ match response {
+ PermissionResponse::Allowed => {
+ execute_tool(h2, tx, id, tool, db);
+ }
+ PermissionResponse::Denied => {
+ let tx = tx.clone();
+ h2.update(move |state| {
+ state.finish_tool_call(
+ &id_clone,
+ crate::tools::ToolOutcome::Error(
+ "Permission denied on the user's system".to_string(),
+ ),
+ );
+ if !state.tool_tracker.has_pending() {
+ let _ = tx.send(AiTuiEvent::ContinueAfterTools);
+ }
+ });
+ }
+ PermissionResponse::Ask => {
+ h2.update(move |state| {
+ if let Some(tracked) = state.tool_tracker.get_mut(&id_clone) {
+ tracked.mark_asking();
+ }
+ });
+ }
+ }
+
+ Ok(())
+}
+
+fn on_select_permission(
+ handle: &Handle<Session>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ app_ctx: &AppContext,
+ permission: PermissionResult,
+) {
+ let tx = tx.clone();
+ let h2 = handle.clone();
+
+ match permission {
+ PermissionResult::Allow => {
+ // Fetch the tool that's asking for permission, then execute it
+ let db = 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;
+ };
+
+ execute_tool(&h2, &tx, tool_id, tool, &db);
+ });
+ }
+ PermissionResult::AlwaysAllowInDir => {
+ let db = app_ctx.history_db.clone();
+ let git_root = app_ctx.git_root.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;
+ };
+
+ // Write the rule to the project (git root) or cwd permissions file
+ let project_root = git_root
+ .or_else(|| std::env::current_dir().ok())
+ .unwrap_or_else(|| PathBuf::from("."));
+ let file_path = writer::project_permissions_path(&project_root);
+ let rule = Rule {
+ tool: tool.rule_name().to_string(),
+ scope: None,
+ };
+ if let Err(e) = writer::write_rule(&file_path, &rule, RuleDisposition::Allow).await
+ {
+ tracing::error!("Failed to write project permission rule: {e}");
+ }
+
+ execute_tool(&h2, &tx, tool_id, tool, &db);
+ });
+ }
+ PermissionResult::AlwaysAllow => {
+ let db = 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;
+ };
+
+ // Write the rule to the global permissions file
+ let file_path = writer::global_permissions_path();
+ let rule = Rule {
+ tool: tool.rule_name().to_string(),
+ scope: None,
+ };
+ if let Err(e) = writer::write_rule(&file_path, &rule, RuleDisposition::Allow).await
+ {
+ tracing::error!("Failed to write global permission rule: {e}");
+ }
+
+ execute_tool(&h2, &tx, tool_id, tool, &db);
+ });
+ }
+ PermissionResult::Deny => {
+ h2.update(move |state| {
+ let Some(tracked) = state.tool_tracker.asking_for_permission() else {
+ return;
+ };
+ let tool_id = tracked.id.clone();
+
+ state.finish_tool_call(
+ &tool_id,
+ crate::tools::ToolOutcome::Error("Permission denied by the user".to_string()),
+ );
+ if !state.tool_tracker.has_pending() {
+ let _ = tx.send(AiTuiEvent::ContinueAfterTools);
+ }
+ });
+ }
+ }
+}
+
+// ───────────────────────────────────────────────────────────────────
+// Other handlers
+// ───────────────────────────────────────────────────────────────────
+
+fn on_cancel_generation(handle: &Handle<Session>) {
+ handle.update(|state| match state.interaction.mode {
+ crate::tui::state::AppMode::Generating => {
+ state.cancel_generation();
+ }
+ crate::tui::state::AppMode::Streaming => {
+ state.cancel_streaming();
+ }
+ _ => {}
+ });
+}
+
+fn on_execute_command(handle: &Handle<Session>) {
+ let h2 = handle.clone();
+ handle.update(move |state| {
+ let cmd = state.conversation.current_command().map(|c| c.to_string());
+ if let Some(cmd) = cmd {
+ if state.conversation.is_current_command_dangerous()
+ && !state.interaction.confirmation_pending
+ {
+ state.interaction.confirmation_pending = true;
+ } else {
+ state.interaction.confirmation_pending = false;
+ state.exit_action = Some(ExitAction::Execute(cmd));
+ h2.exit();
+ }
+ }
+ });
+}
+
+fn on_cancel_confirmation(handle: &Handle<Session>) {
+ handle.update(move |state| {
+ state.interaction.confirmation_pending = false;
+ });
+}
+
+fn on_insert_command(handle: &Handle<Session>) {
+ let h2 = handle.clone();
+ handle.update(move |state| {
+ let cmd = state.conversation.current_command().map(|c| c.to_string());
+ if let Some(cmd) = cmd {
+ state.interaction.confirmation_pending = false;
+ state.exit_action = Some(ExitAction::Insert(cmd));
+ h2.exit();
+ }
+ });
+}
+
+fn on_retry(
+ handle: &Handle<Session>,
+ tx: &mpsc::Sender<AiTuiEvent>,
+ app_ctx: &AppContext,
+ client_ctx: &ClientContext,
+) {
+ launch_stream(handle, tx, app_ctx, client_ctx, |state| {
+ state.retry();
+ });
+}
+
+fn on_exit(handle: &Handle<Session>) {
+ let h2 = handle.clone();
+ handle.update(move |state| {
+ if let Some(abort) = state.stream_abort.take() {
+ abort.abort();
+ }
+ state.exit_action = Some(ExitAction::Cancel);
+ h2.exit();
+ });
+}
+
+fn on_interrupt_tool_execution(handle: &Handle<Session>) {
+ handle.update(move |state| {
+ // Find executing previews, send interrupt, and mark as interrupted
+ for tracked in state.tool_tracker.iter_mut() {
+ if let ToolPhase::ExecutingWithPreview {
+ ref mut interrupted,
+ ref mut exit_code,
+ ..
+ } = tracked.phase
+ {
+ *interrupted = true;
+ if exit_code.is_none() {
+ *exit_code = Some(-1);
+ }
+ // Send interrupt signal via the tracker entry's abort channel
+ if let Some(abort_tx) = tracked.abort_tx.take() {
+ let _ = abort_tx.send(());
+ }
+ }
+ }
+
+ // The spawned execution task will handle finalizing and sending
+ // ContinueAfterTools when the process exits. Input mode is already active.
+ });
+}
diff --git a/crates/atuin-ai/src/tui/events.rs b/crates/atuin-ai/src/tui/events.rs
index a791bb80..1a422fef 100644
--- a/crates/atuin-ai/src/tui/events.rs
+++ b/crates/atuin-ai/src/tui/events.rs
@@ -5,13 +5,20 @@
/// eye-declare's context system. The main event loop in `inline.rs`
/// receives them and mutates `AppState` accordingly.
#[derive(Debug)]
-pub enum AiTuiEvent {
+pub(crate) enum AiTuiEvent {
/// User updated the input text
InputUpdated(String),
/// User submitted text input (Enter in Input mode)
SubmitInput(String),
/// User entered a slash command (e.g. "/help")
+ #[allow(unused)]
SlashCommand(String),
+ /// Check the permission for a tool call
+ CheckToolCallPermission(String),
+ /// User selected a permission
+ SelectPermission(PermissionResult),
+ /// Continue after client tools have completed
+ ContinueAfterTools,
/// Cancel active generation or streaming (Esc during Generating/Streaming)
CancelGeneration,
/// Execute the suggested command
@@ -20,8 +27,18 @@ pub enum AiTuiEvent {
InsertCommand,
/// Cancel confirmation of dangerous command
CancelConfirmation,
+ /// Interrupt a running tool execution (Ctrl+C during ExecutingPreview)
+ InterruptToolExecution,
/// Retry after error
Retry,
/// Exit the application
Exit,
}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum PermissionResult {
+ Allow,
+ AlwaysAllowInDir,
+ AlwaysAllow,
+ Deny,
+}
diff --git a/crates/atuin-ai/src/tui/mod.rs b/crates/atuin-ai/src/tui/mod.rs
index acb251a7..afd63312 100644
--- a/crates/atuin-ai/src/tui/mod.rs
+++ b/crates/atuin-ai/src/tui/mod.rs
@@ -1,6 +1,7 @@
-pub mod components;
-pub mod events;
-pub mod state;
-pub mod view;
+pub(crate) mod components;
+pub(crate) mod dispatch;
+pub(crate) mod events;
+pub(crate) mod state;
+pub(crate) mod view;
-pub use state::{AppMode, AppState, ConversationEvent, ExitAction};
+pub(crate) use state::{ConversationEvent, Session};
diff --git a/crates/atuin-ai/src/tui/state.rs b/crates/atuin-ai/src/tui/state.rs
index 4c5c2a1e..69b35909 100644
--- a/crates/atuin-ai/src/tui/state.rs
+++ b/crates/atuin-ai/src/tui/state.rs
@@ -5,9 +5,11 @@
use tokio::task::AbortHandle;
+use crate::tools::{ClientToolCall, ToolOutcome, ToolTracker};
+
/// Streaming status indicators from server
#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum StreamingStatus {
+pub(crate) enum StreamingStatus {
Processing,
Searching,
Thinking,
@@ -15,7 +17,7 @@ pub enum StreamingStatus {
}
impl StreamingStatus {
- pub fn from_status_str(s: &str) -> Self {
+ pub(crate) fn from_status_str(s: &str) -> Self {
match s {
"processing" => Self::Processing,
"searching" => Self::Searching,
@@ -23,20 +25,11 @@ impl StreamingStatus {
_ => Self::Thinking,
}
}
-
- pub fn display_text(&self) -> &'static str {
- match self {
- Self::Processing => "Processing...",
- Self::Searching => "Searching...",
- Self::Thinking => "Thinking...",
- Self::WaitingForTools => "Waiting for tools...",
- }
- }
}
/// Conversation event types matching the API protocol
#[derive(Debug, Clone)]
-pub enum ConversationEvent {
+pub(crate) enum ConversationEvent {
/// User message (what the user typed)
UserMessage { content: String },
/// Text content from assistant (streamed or complete)
@@ -62,48 +55,8 @@ pub enum ConversationEvent {
}
impl ConversationEvent {
- /// Convert to JSON for API calls
- pub fn to_json(&self) -> serde_json::Value {
- match self {
- ConversationEvent::UserMessage { content } => serde_json::json!({
- "type": "user_message",
- "content": content
- }),
- ConversationEvent::Text { content } => serde_json::json!({
- "type": "text",
- "content": content
- }),
- ConversationEvent::ToolCall { id, name, input } => serde_json::json!({
- "type": "tool_call",
- "id": id,
- "name": name,
- "input": input
- }),
- ConversationEvent::ToolResult {
- tool_use_id,
- content,
- is_error,
- } => serde_json::json!({
- "type": "tool_result",
- "tool_use_id": tool_use_id,
- "content": content,
- "is_error": is_error
- }),
- ConversationEvent::OutOfBandOutput {
- name,
- command,
- content,
- } => serde_json::json!({
- "type": "out_of_band_output",
- "name": name,
- "command": command,
- "content": content
- }),
- }
- }
-
/// Extract command from a suggest_command tool call
- pub fn as_command(&self) -> Option<&str> {
+ pub(crate) fn as_command(&self) -> Option<&str> {
if let ConversationEvent::ToolCall { name, input, .. } = self
&& name == "suggest_command"
{
@@ -113,8 +66,9 @@ impl ConversationEvent {
}
}
+/// Application mode for key handling and footer text.
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
-pub enum AppMode {
+pub(crate) enum AppMode {
/// User is typing input
Input,
/// Waiting for generation (showing spinner)
@@ -126,7 +80,7 @@ pub enum AppMode {
}
#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ExitAction {
+pub(crate) enum ExitAction {
/// Run the command
Execute(String),
/// Insert command without running
@@ -135,47 +89,20 @@ pub enum ExitAction {
Cancel,
}
-/// Application state — the domain model
-///
-/// Conversation is stored as a sequence of events matching the API protocol.
-/// The view function derives the UI from this state.
+/// Owned event log and session ID
#[derive(Debug)]
-pub struct AppState {
- /// Current application mode
- pub mode: AppMode,
+pub(crate) struct Conversation {
/// Conversation events (source of truth, matches API protocol)
pub events: Vec<ConversationEvent>,
- /// Current error message
- pub error: Option<String>,
- /// Exit action (set when exiting)
- pub exit_action: Option<ExitAction>,
/// Session ID from server
pub session_id: Option<String>,
- /// Current streaming status
- pub streaming_status: Option<StreamingStatus>,
- /// Whether the input is blank
- pub is_input_blank: bool,
- /// Whether current turn was interrupted by user
- pub was_interrupted: bool,
- /// True when user has pressed Enter once on a dangerous command
- pub confirmation_pending: bool,
- /// Abort handle for the active streaming task, if any
- pub stream_abort: Option<AbortHandle>,
}
-impl AppState {
+impl Conversation {
pub fn new() -> Self {
Self {
- mode: AppMode::Input,
events: Vec::new(),
- error: None,
- exit_action: None,
session_id: None,
- streaming_status: None,
- is_input_blank: false,
- was_interrupted: false,
- confirmation_pending: false,
- stream_abort: None,
}
}
@@ -195,16 +122,57 @@ impl AppState {
i += 1;
}
ConversationEvent::Text { content } => {
- messages.push(serde_json::json!({
- "role": "assistant",
- "content": content
- }));
- i += 1;
+ // Check if the next event(s) are ToolCalls — if so, combine
+ // into a single assistant message with mixed content blocks.
+ let next_is_tool_call = events
+ .get(i + 1)
+ .is_some_and(|e| matches!(e, ConversationEvent::ToolCall { .. }));
+
+ if next_is_tool_call {
+ let mut content_blocks = Vec::new();
+
+ if !content.is_empty() {
+ content_blocks.push(serde_json::json!({
+ "type": "text",
+ "text": content
+ }));
+ }
+
+ while let Some(ConversationEvent::ToolCall {
+ id, name, input, ..
+ }) = events.get(i + 1)
+ {
+ content_blocks.push(serde_json::json!({
+ "type": "tool_use",
+ "id": id,
+ "name": name,
+ "input": input
+ }));
+ i += 1;
+ }
+
+ messages.push(serde_json::json!({
+ "role": "assistant",
+ "content": content_blocks
+ }));
+ i += 1;
+ } else {
+ messages.push(serde_json::json!({
+ "role": "assistant",
+ "content": content
+ }));
+ i += 1;
+ }
}
ConversationEvent::ToolCall { .. } => {
+ // ToolCalls without preceding Text (shouldn't normally happen,
+ // but handle defensively)
let mut tool_uses = Vec::new();
while i < events.len() {
- if let ConversationEvent::ToolCall { id, name, input } = &events[i] {
+ if let ConversationEvent::ToolCall {
+ id, name, input, ..
+ } = &events[i]
+ {
tool_uses.push(serde_json::json!({
"type": "tool_use",
"id": id,
@@ -247,53 +215,42 @@ impl AppState {
messages
}
- // ===== Generation lifecycle methods =====
-
- /// Start generating from submitted input
- pub fn start_generating(&mut self, input: String) {
- self.events
- .push(ConversationEvent::UserMessage { content: input });
- self.mode = AppMode::Generating;
- }
-
- /// Generation error occurred
- pub fn generation_error(&mut self, error: String) {
- self.error = Some(error);
- self.mode = AppMode::Error;
- }
-
- /// Cancel during generation
- pub fn cancel_generation(&mut self) {
- if let Some(abort) = self.stream_abort.take() {
- abort.abort();
- }
- if let Some(ConversationEvent::UserMessage { .. }) = self.events.last() {
- self.events.pop();
- }
- self.mode = AppMode::Input;
- }
-
- // ===== Streaming lifecycle methods =====
-
- /// Start streaming response.
- /// Pushes an empty Text event that will be mutated in-place as chunks arrive.
- pub fn start_streaming(&mut self) {
- self.events.push(ConversationEvent::Text {
- content: String::new(),
- });
- self.streaming_status = None;
- self.was_interrupted = false;
- self.mode = AppMode::Streaming;
+ /// Get the most recent command from events
+ pub fn current_command(&self) -> Option<&str> {
+ self.events.iter().rev().find_map(|e| e.as_command())
}
- /// Store session ID from server response
- pub fn store_session_id(&mut self, session_id: String) {
- self.session_id = Some(session_id);
+ /// Check if any turn in the conversation has a command
+ pub fn has_any_command(&self) -> bool {
+ self.events.iter().any(|e| {
+ if let ConversationEvent::ToolCall { name, input, .. } = e {
+ name == "suggest_command" && input.get("command").and_then(|v| v.as_str()).is_some()
+ } else {
+ false
+ }
+ })
}
- /// Update streaming status from SSE event
- pub fn update_streaming_status(&mut self, status: &str) {
- self.streaming_status = Some(StreamingStatus::from_status_str(status));
+ /// Check if the most recent command is marked dangerous
+ pub fn is_current_command_dangerous(&self) -> bool {
+ self.events
+ .iter()
+ .rev()
+ .find_map(|e| {
+ if let ConversationEvent::ToolCall { name, input, .. } = e
+ && name == "suggest_command"
+ {
+ let danger_level = input
+ .get("danger")
+ .and_then(|v| v.as_str())
+ .unwrap_or("low");
+ return Some(
+ danger_level == "high" || danger_level == "medium" || danger_level == "med",
+ );
+ }
+ None
+ })
+ .unwrap_or(false)
}
/// Get a mutable reference to the last Text event's content (the streaming buffer).
@@ -307,28 +264,15 @@ impl AppState {
})
}
- /// Cancel streaming with context preservation
- pub fn cancel_streaming(&mut self) {
- if let Some(abort) = self.stream_abort.take() {
- abort.abort();
- }
- self.was_interrupted = true;
-
- if let Some(content) = self.streaming_content_mut() {
- let trimmed = content.trim_start().to_string();
- if trimmed.is_empty() {
- // Remove the empty text event
- *content = String::new();
+ /// Remove trailing empty Text events from the events list
+ fn remove_empty_trailing_text(&mut self) {
+ while let Some(ConversationEvent::Text { content }) = self.events.last() {
+ if content.is_empty() {
+ self.events.pop();
} else {
- *content = format!("{trimmed}\n\n[User cancelled this generation]");
+ break;
}
}
- // Remove trailing empty Text events
- self.remove_empty_trailing_text();
-
- self.streaming_status = None;
- self.confirmation_pending = false;
- self.mode = AppMode::Input;
}
/// Append text chunk during streaming (mutates the last Text event in-place)
@@ -354,26 +298,6 @@ impl AppState {
}
}
- /// Add a tool call event during streaming.
- /// The current streaming text is already in events, so we just push the tool call.
- pub fn add_tool_call(&mut self, id: String, name: String, input: serde_json::Value) {
- // Trim the streaming text event
- if let Some(content) = self.streaming_content_mut() {
- let trimmed = content.trim_start().to_string();
- *content = trimmed;
- }
- self.remove_empty_trailing_text();
-
- let is_suggest_command = name == "suggest_command";
- self.events
- .push(ConversationEvent::ToolCall { id, name, input });
-
- if is_suggest_command {
- self.streaming_status = None;
- self.mode = AppMode::Input;
- }
- }
-
/// Add a tool result event during streaming
pub fn add_tool_result(&mut self, tool_use_id: String, content: String, is_error: bool) {
self.events.push(ConversationEvent::ToolResult {
@@ -383,47 +307,9 @@ impl AppState {
});
}
- /// Finalize streaming — trim the accumulated text and change mode
- pub fn finalize_streaming(&mut self) {
- if let Some(content) = self.streaming_content_mut() {
- let trimmed = content.trim_start().to_string();
- *content = trimmed;
- }
- self.remove_empty_trailing_text();
- self.streaming_status = None;
- self.mode = AppMode::Input;
- }
-
- /// Streaming error — remove the partial text event
- pub fn streaming_error(&mut self, error: String) {
- self.remove_empty_trailing_text();
- self.error = Some(error);
- self.mode = AppMode::Error;
- }
-
- /// Remove trailing empty Text events from the events list
- fn remove_empty_trailing_text(&mut self) {
- while let Some(ConversationEvent::Text { content }) = self.events.last() {
- if content.is_empty() {
- self.events.pop();
- } else {
- break;
- }
- }
- }
-
- // ===== Edit mode and exit methods =====
-
- /// Start edit mode for refinement
- pub fn start_edit_mode(&mut self) {
- self.confirmation_pending = false;
- self.mode = AppMode::Input;
- }
-
- /// Retry after error
- pub fn retry(&mut self) {
- self.error = None;
- self.mode = AppMode::Generating;
+ /// Store session ID from server response
+ pub fn store_session_id(&mut self, session_id: String) {
+ self.session_id = Some(session_id);
}
/// Handle a slash command
@@ -445,85 +331,247 @@ impl AppState {
}),
}
}
+}
- // ===== Query methods =====
+/// Ephemeral UI/presentation state
+#[derive(Debug)]
+pub(crate) struct Interaction {
+ /// Current application mode
+ pub mode: AppMode,
+ /// Whether the input is blank
+ pub is_input_blank: bool,
+ /// True when user has pressed Enter once on a dangerous command
+ pub confirmation_pending: bool,
+ /// Current streaming status
+ pub streaming_status: Option<StreamingStatus>,
+ /// Whether current turn was interrupted by user
+ pub was_interrupted: bool,
+ /// Current error message
+ pub error: Option<String>,
+}
- /// Get the most recent command from events
- pub fn current_command(&self) -> Option<&str> {
- self.events.iter().rev().find_map(|e| e.as_command())
+impl Interaction {
+ pub fn new() -> Self {
+ Self {
+ mode: AppMode::Input,
+ is_input_blank: false,
+ confirmation_pending: false,
+ streaming_status: None,
+ was_interrupted: false,
+ error: None,
+ }
}
+}
- /// Check if the most recent command is marked dangerous
- pub fn is_current_command_dangerous(&self) -> bool {
- self.events
- .iter()
- .rev()
- .find_map(|e| {
- if let ConversationEvent::ToolCall { name, input, .. } = e
- && name == "suggest_command"
- {
- let danger_level = input
- .get("danger")
- .and_then(|v| v.as_str())
- .unwrap_or("low");
- return Some(
- danger_level == "high" || danger_level == "medium" || danger_level == "med",
- );
- }
- None
- })
- .unwrap_or(false)
+/// Top-level session state
+///
+/// Decomposed into `Conversation` (event log + session ID) and
+/// `Interaction` (ephemeral UI state). Session methods that cross
+/// both sub-structs live here.
+#[derive(Debug)]
+pub(crate) struct Session {
+ pub conversation: Conversation,
+ pub interaction: Interaction,
+ /// Tracks all tool calls through their full lifecycle.
+ pub tool_tracker: ToolTracker,
+ /// Whether the session is running inside a git project (for permission UI labels).
+ pub in_git_project: bool,
+ /// Exit action (set when exiting)
+ pub exit_action: Option<ExitAction>,
+ /// Abort handle for the active streaming task, if any
+ pub stream_abort: Option<AbortHandle>,
+}
+
+impl Session {
+ pub fn new(in_git_project: bool) -> Self {
+ Self {
+ conversation: Conversation::new(),
+ interaction: Interaction::new(),
+ tool_tracker: ToolTracker::new(),
+ in_git_project,
+ exit_action: None,
+ stream_abort: None,
+ }
}
- /// Count non-suggest_command tool calls since the last user message
- pub fn tool_count_since_last_user(&self) -> usize {
- let last_user_idx = self
+ // ===== Generation lifecycle methods =====
+
+ /// Start generating from submitted input
+ pub fn start_generating(&mut self, input: String) {
+ self.conversation
.events
- .iter()
- .rposition(|e| matches!(e, ConversationEvent::UserMessage { .. }))
- .unwrap_or(0);
+ .push(ConversationEvent::UserMessage { content: input });
+ self.interaction.mode = AppMode::Generating;
+ }
- let mut completed = 0;
- let mut in_flight = false;
+ /// Generation error occurred
+ #[expect(dead_code)]
+ pub fn generation_error(&mut self, error: String) {
+ self.interaction.error = Some(error);
+ self.interaction.mode = AppMode::Error;
+ }
- for event in &self.events[last_user_idx..] {
- match event {
- ConversationEvent::ToolCall { name, .. } if name != "suggest_command" => {
- if in_flight {
- completed += 1;
- }
- in_flight = true;
- }
- ConversationEvent::ToolResult { .. } => {
- if in_flight {
- completed += 1;
- in_flight = false;
- }
- }
- _ => {}
- }
+ /// Cancel during generation
+ pub fn cancel_generation(&mut self) {
+ if let Some(abort) = self.stream_abort.take() {
+ abort.abort();
+ }
+ if let Some(ConversationEvent::UserMessage { .. }) = self.conversation.events.last() {
+ self.conversation.events.pop();
}
+ self.interaction.mode = AppMode::Input;
+ }
- completed
+ // ===== Streaming lifecycle methods =====
+
+ /// Start streaming response.
+ /// Pushes an empty Text event that will be mutated in-place as chunks arrive.
+ pub fn start_streaming(&mut self) {
+ self.conversation.events.push(ConversationEvent::Text {
+ content: String::new(),
+ });
+ self.interaction.streaming_status = None;
+ self.interaction.was_interrupted = false;
+ self.interaction.mode = AppMode::Streaming;
}
- /// Check if any turn in the conversation has a command
- pub fn has_any_command(&self) -> bool {
- self.events.iter().any(|e| {
- if let ConversationEvent::ToolCall { name, input, .. } = e {
- name == "suggest_command" && input.get("command").and_then(|v| v.as_str()).is_some()
+ /// Update streaming status from SSE event
+ pub fn update_streaming_status(&mut self, status: &str) {
+ self.interaction.streaming_status = Some(StreamingStatus::from_status_str(status));
+ }
+
+ /// Cancel streaming with context preservation
+ pub fn cancel_streaming(&mut self) {
+ if let Some(abort) = self.stream_abort.take() {
+ abort.abort();
+ }
+ self.interaction.was_interrupted = true;
+
+ if let Some(content) = self.conversation.streaming_content_mut() {
+ let trimmed = content.trim_start().to_string();
+ if trimmed.is_empty() {
+ // Remove the empty text event
+ *content = String::new();
} else {
- false
+ *content = format!("{trimmed}\n\n[User cancelled this generation]");
}
- })
+ }
+ // Remove trailing empty Text events
+ self.conversation.remove_empty_trailing_text();
+
+ self.interaction.streaming_status = None;
+ self.interaction.confirmation_pending = false;
+ self.interaction.mode = AppMode::Input;
+ }
+
+ /// Add a tool call event during streaming.
+ /// The current streaming text is already in events, so we just push the tool call.
+ pub fn add_tool_call(&mut self, id: String, name: String, input: serde_json::Value) {
+ // Trim the streaming text event
+ if let Some(content) = self.conversation.streaming_content_mut() {
+ let trimmed = content.trim_start().to_string();
+ *content = trimmed;
+ }
+ self.conversation.remove_empty_trailing_text();
+
+ let is_suggest_command = name == "suggest_command";
+ self.conversation
+ .events
+ .push(ConversationEvent::ToolCall { id, name, input });
+
+ if is_suggest_command {
+ self.interaction.streaming_status = None;
+ self.interaction.mode = AppMode::Input;
+ }
+ }
+
+ /// Finalize streaming — trim the accumulated text and change mode
+ pub fn finalize_streaming(&mut self) {
+ if let Some(content) = self.conversation.streaming_content_mut() {
+ let trimmed = content.trim_start().to_string();
+ *content = trimmed;
+ }
+ self.conversation.remove_empty_trailing_text();
+ self.interaction.streaming_status = None;
+ self.interaction.mode = AppMode::Input;
+ }
+
+ /// Streaming error — remove the partial text event
+ pub fn streaming_error(&mut self, error: String) {
+ self.conversation.remove_empty_trailing_text();
+ self.interaction.error = Some(error);
+ self.interaction.mode = AppMode::Error;
+ }
+
+ pub(crate) fn handle_client_tool_call(
+ &mut self,
+ id: String,
+ tool: ClientToolCall,
+ input: serde_json::Value,
+ ) {
+ let desc = tool.descriptor();
+ let name = desc.canonical_names[0].to_string();
+
+ self.tool_tracker.insert(id.clone(), tool);
+
+ // Add the ToolCall event to the conversation immediately so it appears
+ // in the view. Preview data is sourced from tool_tracker.
+ self.conversation
+ .events
+ .push(ConversationEvent::ToolCall { id, name, input });
+
+ // Client tool calls can only happen at the last part of a turn
+ self.interaction.streaming_status = None;
+ self.interaction.mode = AppMode::Input;
+ }
+
+ /// Retry after error
+ pub fn retry(&mut self) {
+ self.interaction.error = None;
+ self.interaction.mode = AppMode::Generating;
+ }
+
+ // ===== Tool lifecycle methods =====
+
+ /// Finish a tool call: transition tracker to Completed, push ToolResult to conversation.
+ ///
+ /// For shell commands, captures the final preview from the ExecutingWithPreview phase
+ /// and patches exit_code/interrupted from the authoritative ToolOutcome.
+ pub fn finish_tool_call(&mut self, tool_id: &str, outcome: ToolOutcome) {
+ let mut preview = self.tool_tracker.get(tool_id).and_then(|t| t.preview());
+
+ // Patch preview with authoritative outcome data (handles race where
+ // final VT100 update hasn't been applied yet).
+ if let Some(ref mut p) = preview
+ && let ToolOutcome::Structured {
+ exit_code,
+ interrupted,
+ ..
+ } = &outcome
+ {
+ p.interrupted = *interrupted;
+ if p.exit_code.is_none() {
+ p.exit_code = *exit_code;
+ }
+ }
+
+ // Transition tracker entry to Completed
+ if let Some(tracked) = self.tool_tracker.get_mut(tool_id) {
+ tracked.complete(preview);
+ }
+
+ let content = outcome.format_for_llm();
+ let is_error = outcome.is_error();
+ self.conversation
+ .add_tool_result(tool_id.to_string(), content, is_error);
}
/// Get the footer text for current mode
pub fn footer_text(&self) -> &'static str {
- match self.mode {
+ match self.interaction.mode {
AppMode::Input => {
- if self.has_any_command() && self.is_input_blank {
- if self.confirmation_pending {
+ if self.conversation.has_any_command() && self.interaction.is_input_blank {
+ if self.interaction.confirmation_pending {
"[Enter] Confirm dangerous command [Esc] Cancel"
} else {
"[Enter] Execute suggested command [Tab] Insert Command"
@@ -542,9 +590,3 @@ impl AppState {
self.exit_action.is_some()
}
}
-
-impl Default for AppState {
- fn default() -> Self {
- Self::new()
- }
-}
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('_', " ")))
}
}