aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/view
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-26 19:19:47 -0700
committerGitHub <noreply@github.com>2026-03-27 02:19:47 +0000
commitb649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch)
treeca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/tui/view
parentfix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff)
downloadatuin-b649a7ab8de6488c1341e94c37d032c07d5b3f13.zip
feat: Use eye-declare for more performant and flexible AI TUI (#3343)
This PR replaces the mess of custom rendering code in Atuin AI with [eye-declare](https://github.com/BinaryMuse/eye-declare), and updates the TUI to feel more terminal-native: output appears inline and persists in scrollback, so you can scroll up and look at previous conversations for reference. The "review" state — which used to exist between the LLM generating a response and the user either executing or following up — has been removed; just start typing to follow up with the LLM, or press `enter` at the empty input box to execute the suggested command. <img width="1203" height="633" alt="image" src="https://github.com/user-attachments/assets/159ee447-9a2a-4edd-b56e-a79bf1aaaa94" />
Diffstat (limited to '')
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs342
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs409
-rw-r--r--crates/atuin-ai/src/tui/view_model.rs413
3 files changed, 751 insertions, 413 deletions
diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs
new file mode 100644
index 00000000..a1b32518
--- /dev/null
+++ b/crates/atuin-ai/src/tui/view/mod.rs
@@ -0,0 +1,342 @@
+//! View function that builds the eye-declare element tree from app state.
+
+use eye_declare::{
+ Column, Component, Elements, HStack, Line, Span, Spinner, TextBlock, VStack, WidthConstraint,
+ element, impl_slot_children,
+};
+use ratatui_core::style::{Color, Modifier, Style};
+
+use super::components::atuin_ai::AtuinAi;
+use super::components::input_box::InputBox;
+use super::components::markdown::Markdown;
+use super::state::{AppMode, AppState};
+
+mod turn;
+
+#[derive(Default)]
+struct Padding {
+ top: u16,
+ left: u16,
+ right: u16,
+ bottom: u16,
+}
+
+impl Component for Padding {
+ type State = ();
+
+ fn content_inset(&self, _state: &Self::State) -> eye_declare::Insets {
+ eye_declare::Insets::ZERO
+ .left(self.left)
+ .right(self.right)
+ .top(self.top)
+ .bottom(self.bottom)
+ }
+
+ fn desired_height(&self, _width: u16, _state: &Self::State) -> u16 {
+ 0
+ }
+
+ fn render(
+ &self,
+ _area: ratatui::layout::Rect,
+ _buf: &mut ratatui::buffer::Buffer,
+ _state: &(),
+ ) {
+ }
+}
+
+impl_slot_children!(Padding);
+
+/// Build the element tree from current state.
+///
+/// Layout (top to bottom):
+/// - Conversation messages (user messages, agent responses, tool status)
+/// - Streaming content (if actively streaming)
+/// - 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();
+
+ for event in &state.events {
+ turn_builder.add_event(event);
+ }
+ let turns = turn_builder.build();
+
+ let busy = state.mode == AppMode::Streaming || state.mode == AppMode::Generating;
+ let last_index = turns.len().saturating_sub(1);
+
+ element! {
+ AtuinAi(
+ mode: state.mode.clone(),
+ has_command: state.has_any_command(),
+ is_input_blank: state.is_input_blank,
+ pending_confirmation: state.confirmation_pending,
+ ) {
+ #(for (index, turn) in turns.iter().enumerate() {
+ #(match turn {
+ turn::UiTurn::User { events } => {
+ user_turn_view(events, index == 0)
+ }
+ turn::UiTurn::Agent { events } => {
+ agent_turn_view(events, busy && index == last_index)
+ }
+ turn::UiTurn::OutOfBand { events } => {
+ out_of_band_turn_view(events)
+ }
+ })
+ })
+
+ #(if !state.is_exiting() {
+ TextBlock { Line { Span(text: "") } }
+ 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,
+ )
+
+ #(if state.is_input_blank && state.has_any_command() && state.mode == AppMode::Input {
+ #(if state.confirmation_pending {
+ TextBlock { Line { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) } }
+ } else {
+ TextBlock { Line { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) } }
+ })
+ })
+ })
+ }
+ }
+}
+
+fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements {
+ let label_style = Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD);
+
+ element! {
+ VStack {
+ TextBlock {
+ #(if !first_turn {
+ Line { Span() }
+ })
+ Line {
+ Span(text: "You", style: label_style)
+ }
+ }
+ #(for event in events {
+ #(match event {
+ turn::UiEvent::Text { content } => {
+ element! {
+ Padding(left: 2u16) {
+ TextBlock {
+ Line {
+ Span(text: content, style: Style::default())
+ }
+ }
+ }
+ }
+ },
+ _ => element!{}
+ })
+ })
+ }
+ }
+}
+
+fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements {
+ let label_style = Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD);
+
+ element! {
+ VStack {
+ Spinner(
+ label: "Atuin AI",
+ label_style: label_style,
+ done_label_style: label_style,
+ hide_checkmark: true,
+ label_first: true,
+ done: !busy,
+ )
+ #(for event in events {
+ #(match event {
+ turn::UiEvent::Text { content } => {
+ element! {
+ Padding(left: 2u16) {
+ Markdown(source: content)
+ }
+ }
+ },
+ turn::UiEvent::ToolSummary(summary) => {
+ tool_summary_view(summary)
+ },
+ turn::UiEvent::SuggestedCommand(details) => {
+ suggested_command_view(details)
+ },
+ _ => element!{}
+ })
+ })
+ }
+ }
+}
+
+fn out_of_band_turn_view(events: &[turn::UiEvent]) -> Elements {
+ element! {
+ VStack {
+ TextBlock {
+ Line { Span(text: "System", style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) }
+ }
+ #(for event in events {
+ #(match event {
+ turn::UiEvent::OutOfBandOutput(details) => {
+ out_of_band_output_view(details)
+ }
+ _ => element!{}
+ })
+ })
+ }
+ }
+}
+
+fn out_of_band_output_view(details: &turn::OutOfBandOutputDetails) -> Elements {
+ element! {
+ Padding(left: 2u16) {
+ #(if details.command.is_some() {
+ TextBlock {
+ Line {
+ Span(text: details.command.as_ref().unwrap(), style: Style::default().fg(Color::Blue))
+ }
+ }
+ })
+ Markdown(source: details.content.clone())
+ }
+ }
+}
+
+fn tool_summary_view(summary: &turn::ToolSummary) -> Elements {
+ element! {
+ Spinner(label: summary.summary(), done: !summary.any_pending())
+ }
+}
+
+fn suggested_command_view(details: &turn::SuggestedCommandDetails) -> Elements {
+ let is_dangerous = matches!(
+ details.danger_level,
+ turn::DangerLevel::High(_) | turn::DangerLevel::Medium(_)
+ );
+ let danger_notes = details.danger_level.notes();
+ let danger_style = match details.danger_level {
+ turn::DangerLevel::High(_) => Style::default().fg(Color::Red),
+ turn::DangerLevel::Medium(_) => Style::default().fg(Color::Yellow),
+ turn::DangerLevel::Low(_) => Style::default().fg(Color::Green),
+ turn::DangerLevel::Unknown(_) => Style::default().fg(Color::Green),
+ };
+ let danger_text = match details.danger_level {
+ turn::DangerLevel::High(_) => "High",
+ turn::DangerLevel::Medium(_) => "Medium",
+ turn::DangerLevel::Low(_) => "Low",
+ turn::DangerLevel::Unknown(_) => "Unknown",
+ };
+
+ let low_confidence = matches!(
+ details.confidence_level,
+ turn::ConfidenceLevel::Low(_) | turn::ConfidenceLevel::Medium(_)
+ );
+
+ let confidence_level = match details.confidence_level {
+ turn::ConfidenceLevel::Low(_) => "Low",
+ turn::ConfidenceLevel::Medium(_) => "Medium",
+ turn::ConfidenceLevel::High(_) => "High",
+ turn::ConfidenceLevel::Unknown(_) => "Unknown",
+ };
+
+ let confidence_notes = details.confidence_level.notes();
+
+ element! {
+ VStack {
+ TextBlock {
+ #(if !details.first_event_in_turn {
+ Line { Span() }
+ })
+ Line {
+ Span(text: " Suggested command:", style: Style::default().fg(Color::Cyan))
+ }
+ }
+ HStack {
+ Column(width: WidthConstraint::Fixed(2)) {
+ TextBlock {
+ Line {
+ #(if is_dangerous || low_confidence {
+ Span(text: "! ", style: Style::default().fg(Color::Yellow))
+ } else {
+ Span(text: "$ ", style: Style::default().fg(Color::Blue))
+ })
+ }
+ }
+ }
+ Column {
+ TextBlock {
+ Line {
+ Span(text: &details.command, style: Style::default().fg(Color::Green))
+ }
+ }
+ }
+ }
+ #(if is_dangerous {
+ Padding(left: 2u16) {
+ TextBlock {
+ Line {
+ Span(text: "Danger: ", style: danger_style)
+ Span(text: danger_text, style: danger_style.add_modifier(Modifier::BOLD))
+ }
+ }
+ }
+ })
+ #(if is_dangerous && danger_notes.is_some() {
+ Padding(left: 2u16) {
+ HStack {
+ Column(width: WidthConstraint::Fixed(2)) {
+ TextBlock {
+ Line {
+ Span(text: "└")
+ }
+ }
+ }
+ Column(width: WidthConstraint::Fill) {
+ Markdown(source: danger_notes.unwrap())
+ }
+ }
+ }
+ })
+ #(if low_confidence {
+ Padding(left: 2u16) {
+ TextBlock {
+ Line {
+ Span(text: "Confidence: ", style: Style::default().fg(Color::Blue))
+ Span(text: confidence_level, style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD))
+ }
+ }
+ }
+ })
+ #(if low_confidence && confidence_notes.is_some() {
+ Padding(left: 2u16) {
+ HStack {
+ Column(width: WidthConstraint::Fixed(2)) {
+ TextBlock {
+ Line {
+ Span(text: "└")
+ }
+ }
+ }
+ Column(width: WidthConstraint::Fill) {
+ Markdown(source: confidence_notes.unwrap())
+ }
+ }
+ }
+ })
+ }
+ }
+}
+
+// ai_view_old removed — superseded by ai_view above
diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs
new file mode 100644
index 00000000..861da64c
--- /dev/null
+++ b/crates/atuin-ai/src/tui/view/turn.rs
@@ -0,0 +1,409 @@
+use crate::tui::ConversationEvent;
+
+#[derive(Debug)]
+pub(crate) enum DangerLevel {
+ Low(Option<String>),
+ Medium(Option<String>),
+ High(Option<String>),
+ Unknown(Option<String>),
+}
+
+impl DangerLevel {
+ pub(crate) fn notes(&self) -> Option<&String> {
+ match self {
+ DangerLevel::Low(notes) => notes.as_ref(),
+ DangerLevel::Medium(notes) => notes.as_ref(),
+ DangerLevel::High(notes) => notes.as_ref(),
+ DangerLevel::Unknown(notes) => notes.as_ref(),
+ }
+ }
+}
+
+impl From<(&String, &String)> for DangerLevel {
+ fn from((danger_level, danger_notes): (&String, &String)) -> Self {
+ let notes = if danger_notes.is_empty() {
+ None
+ } else {
+ Some(danger_notes.to_string())
+ };
+
+ match danger_level.as_str() {
+ "low" => DangerLevel::Low(notes),
+ "medium" => DangerLevel::Medium(notes),
+ "med" => DangerLevel::Medium(notes),
+ "high" => DangerLevel::High(notes),
+ _ => DangerLevel::Unknown(notes),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) enum ConfidenceLevel {
+ Low(Option<String>),
+ Medium(Option<String>),
+ High(Option<String>),
+ Unknown(Option<String>),
+}
+
+impl ConfidenceLevel {
+ pub(crate) fn notes(&self) -> Option<&String> {
+ match self {
+ ConfidenceLevel::Low(notes) => notes.as_ref(),
+ ConfidenceLevel::Medium(notes) => notes.as_ref(),
+ ConfidenceLevel::High(notes) => notes.as_ref(),
+ ConfidenceLevel::Unknown(notes) => notes.as_ref(),
+ }
+ }
+}
+
+impl From<(&String, &String)> for ConfidenceLevel {
+ fn from((confidence_level, confidence_notes): (&String, &String)) -> Self {
+ let notes = if confidence_notes.is_empty() {
+ None
+ } else {
+ Some(confidence_notes.to_string())
+ };
+
+ match confidence_level.as_str() {
+ "low" => ConfidenceLevel::Low(notes),
+ "medium" => ConfidenceLevel::Medium(notes),
+ "med" => ConfidenceLevel::Medium(notes),
+ "high" => ConfidenceLevel::High(notes),
+ _ => ConfidenceLevel::Unknown(notes),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) enum UiEvent {
+ Text { content: String },
+ ToolCall(ToolCallDetails),
+ ToolSummary(ToolSummary),
+ SuggestedCommand(SuggestedCommandDetails),
+ OutOfBandOutput(OutOfBandOutputDetails),
+}
+
+#[derive(Debug)]
+pub(crate) struct ToolCallDetails {
+ tool_use_id: String,
+ name: String,
+ status: ToolResultStatus,
+}
+
+#[derive(Debug)]
+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)]
+pub(crate) struct OutOfBandOutputDetails {
+ pub(crate) command: Option<String>,
+ pub(crate) content: String,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) enum ToolResultStatus {
+ Pending,
+ Success,
+ Error,
+}
+
+#[derive(Debug)]
+pub(crate) enum UiTurn {
+ User { events: Vec<UiEvent> },
+ Agent { events: Vec<UiEvent> },
+ OutOfBand { events: Vec<UiEvent> },
+}
+
+pub(crate) struct TurnBuilder {
+ turns: Vec<UiTurn>,
+ current_turn: Option<UiTurn>,
+}
+
+impl TurnBuilder {
+ pub(crate) fn new() -> Self {
+ Self {
+ turns: Vec::new(),
+ current_turn: None,
+ }
+ }
+
+ pub(crate) fn add_event(&mut self, event: &ConversationEvent) {
+ match event {
+ ConversationEvent::UserMessage { content } => {
+ self.add_user_message(content);
+ }
+ ConversationEvent::Text { content } => {
+ self.add_agent_text(content);
+ }
+ ConversationEvent::ToolCall { id, name, input } => {
+ if name == "suggest_command" {
+ self.add_suggested_command(input);
+ } else {
+ self.add_tool_call(id, name, input);
+ }
+ }
+ ConversationEvent::ToolResult {
+ tool_use_id,
+ content,
+ is_error,
+ } => {
+ self.add_tool_result(tool_use_id, content, *is_error);
+ }
+ ConversationEvent::OutOfBandOutput {
+ name,
+ command,
+ content,
+ } => {
+ self.add_out_of_band_output(name, command.as_deref(), content);
+ }
+ }
+ }
+
+ pub(crate) fn build(&mut self) -> Vec<UiTurn> {
+ self.commit_turn();
+
+ // Collapse consecutive tool calls within each agent turn into ToolSummary
+ 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();
+
+ for event in events.drain(..) {
+ match event {
+ UiEvent::ToolCall(details) => {
+ pending_tools.push(details);
+ }
+ other => {
+ if !pending_tools.is_empty() {
+ new_events.push(UiEvent::ToolSummary(ToolSummary {
+ tool_calls: std::mem::take(&mut pending_tools),
+ }));
+ }
+ new_events.push(other);
+ }
+ }
+ }
+
+ if !pending_tools.is_empty() {
+ new_events.push(UiEvent::ToolSummary(ToolSummary {
+ tool_calls: pending_tools,
+ }));
+ }
+
+ *events = new_events;
+ }
+ }
+
+ std::mem::take(&mut self.turns)
+ }
+
+ fn commit_turn(&mut self) {
+ if let Some(turn) = self.current_turn.take() {
+ self.turns.push(turn);
+ }
+ }
+
+ fn start_user_turn(&mut self) {
+ if !matches!(self.current_turn, Some(UiTurn::User { .. })) {
+ self.commit_turn();
+ self.current_turn = Some(UiTurn::User { events: vec![] });
+ }
+ }
+
+ fn start_agent_turn(&mut self) {
+ if !matches!(self.current_turn, Some(UiTurn::Agent { .. })) {
+ self.commit_turn();
+ self.current_turn = Some(UiTurn::Agent { events: vec![] });
+ }
+ }
+
+ fn start_out_of_band_turn(&mut self) {
+ if !matches!(self.current_turn, Some(UiTurn::OutOfBand { .. })) {
+ self.commit_turn();
+ self.current_turn = Some(UiTurn::OutOfBand { events: vec![] });
+ }
+ }
+
+ fn turn_mut_unsafe(&mut self) -> &mut UiTurn {
+ self.current_turn.as_mut().unwrap()
+ }
+
+ fn add_user_message(&mut self, content: &str) {
+ self.start_user_turn();
+ if let UiTurn::User { events } = self.turn_mut_unsafe() {
+ events.push(UiEvent::Text {
+ content: content.to_string(),
+ });
+ }
+ }
+
+ fn add_agent_text(&mut self, content: &str) {
+ self.start_agent_turn();
+ if let UiTurn::Agent { events } = self.turn_mut_unsafe() {
+ events.push(UiEvent::Text {
+ content: content.to_string(),
+ });
+ }
+ }
+
+ fn add_suggested_command(&mut self, input: &serde_json::Value) {
+ let command = input
+ .get("command")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ if command.is_empty() {
+ return;
+ }
+
+ self.start_agent_turn();
+ if let UiTurn::Agent { events } = self.turn_mut_unsafe() {
+ let danger_level = input
+ .get("danger")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let confidence_level = input
+ .get("confidence")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let danger_notes = input
+ .get("danger_notes")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let confidence_notes = input
+ .get("confidence_notes")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ 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")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .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) {
+ 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,
+ }));
+ }
+ }
+
+ 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() {
+ let event = events.iter_mut().find(|e| match e {
+ UiEvent::ToolCall(ToolCallDetails {
+ tool_use_id: id, ..
+ }) => id == tool_use_id,
+ _ => false,
+ });
+ if let Some(UiEvent::ToolCall(ToolCallDetails { status, .. })) = event {
+ *status = if is_error {
+ ToolResultStatus::Error
+ } else {
+ ToolResultStatus::Success
+ };
+ }
+ }
+ }
+
+ fn add_out_of_band_output(&mut self, _name: &str, command: Option<&str>, content: &str) {
+ self.start_out_of_band_turn();
+ if let UiTurn::OutOfBand { events } = self.turn_mut_unsafe() {
+ events.push(UiEvent::OutOfBandOutput(OutOfBandOutputDetails {
+ command: command.map(|c| c.to_string()),
+ content: content.to_string(),
+ }));
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct ToolSummary {
+ tool_calls: Vec<ToolCallDetails>,
+}
+
+impl ToolSummary {
+ /// Determines the summary line:
+ /// - If any call is pending, use present tense verb with `-ing`
+ /// - If multiple calls are complete, say "Used n tools"
+ /// - If a single call is complete, use past tense verb
+ pub(crate) fn summary(&self) -> String {
+ if self.any_pending() {
+ // Find the last pending tool for the active verb
+ if let Some(pending) = self
+ .tool_calls
+ .iter()
+ .rev()
+ .find(|t| t.status == ToolResultStatus::Pending)
+ {
+ return Self::progressive_verb(&pending.name);
+ }
+ }
+
+ if self.tool_calls.len() == 1 {
+ return Self::past_verb(&self.tool_calls[0].name);
+ }
+
+ format!("Used {} tools", self.tool_calls.len())
+ }
+
+ /// Determines if the spinner should be spinning
+ pub(crate) fn any_pending(&self) -> bool {
+ self.tool_calls
+ .iter()
+ .any(|tool_call| tool_call.status == ToolResultStatus::Pending)
+ }
+
+ /// 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('_', " ")),
+ }
+ }
+
+ /// 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('_', " ")),
+ }
+ }
+}
diff --git a/crates/atuin-ai/src/tui/view_model.rs b/crates/atuin-ai/src/tui/view_model.rs
deleted file mode 100644
index 0a296065..00000000
--- a/crates/atuin-ai/src/tui/view_model.rs
+++ /dev/null
@@ -1,413 +0,0 @@
-//! View model types for the TUI application
-//!
-//! This module contains the view model types that represent the rendering
-//! specification. These types are derived from the domain state (conversation
-//! events) via the `Blocks::from_state()` function.
-
-use super::state::{AppMode, AppState, ConversationEvent};
-
-/// Warning classification for command suggestions
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum WarningKind {
- /// Dangerous command (! indicator, AlertError color)
- Danger,
- /// Low confidence answer (? indicator, AlertWarn color)
- LowConfidence,
-}
-
-/// Content variants for blocks - each variant is fully self-describing
-#[derive(Debug, Clone)]
-pub enum Content {
- Input {
- text: String,
- active: bool,
- cursor_pos: usize,
- },
- /// Command suggestion (from suggest_command tool call)
- Command {
- text: String,
- faded: bool, // Phase 5 feature
- },
- Text {
- markdown: String,
- },
- Error {
- message: String,
- },
- /// Warning for dangerous or low-confidence commands
- Warning {
- kind: WarningKind,
- text: String,
- pending_confirm: bool, // true when awaiting second Enter
- },
- Spinner {
- frame: usize, // 0-3 for animation
- status_text: String, // Status-based text (Processing..., Thinking..., etc.)
- },
- /// Tool call status display (in-flight or completed summary)
- ToolStatus {
- /// Number of non-suggest_command tools completed
- completed_count: usize,
- /// Current in-flight tool description (None if all done)
- current_label: Option<String>,
- /// Spinner frame for in-flight display
- frame: usize,
- },
-}
-
-impl Content {
- /// Get the prefix symbol for this content type
- pub fn prefix_symbol(&self) -> &'static str {
- match self {
- Content::Input { .. } => ">",
- Content::Command { .. } => "$",
- Content::Text { .. } => " ",
- Content::Error { .. } => "!",
- Content::Warning { kind, .. } => match kind {
- WarningKind::Danger => "!",
- WarningKind::LowConfidence => "?",
- },
- Content::Spinner { .. } => "/",
- Content::ToolStatus { current_label, .. } => {
- if current_label.is_some() {
- "/"
- } else {
- "\u{2713}"
- } // spinner or checkmark
- }
- }
- }
-}
-
-/// A visual block in the UI
-#[derive(Debug, Clone)]
-pub struct Block {
- pub content: Vec<Content>,
- pub separator_above: bool,
- pub title: Option<String>,
-}
-
-/// Status bar content shown on the bottom border during processing
-#[derive(Debug, Clone)]
-pub struct StatusBar {
- /// Spinner animation frame
- pub frame: usize,
- /// Status text to display (e.g., "Thinking...", "run_bash (used 2 tools)")
- pub text: String,
-}
-
-/// Complete view model - the rendering specification
-#[derive(Debug, Clone)]
-pub struct Blocks {
- pub items: Vec<Block>,
- pub footer: &'static str,
- /// Transient status shown on bottom border during streaming/generating
- pub status_bar: Option<StatusBar>,
-}
-
-/// Count non-suggest_command tool calls since the last user message
-fn count_tool_calls_since_last_user(events: &[ConversationEvent]) -> (usize, Option<String>) {
- let last_user_idx = events
- .iter()
- .rposition(|e| matches!(e, ConversationEvent::UserMessage { .. }))
- .unwrap_or(0);
-
- let mut completed = 0;
- let mut in_flight: Option<String> = None;
-
- for event in &events[last_user_idx..] {
- match event {
- ConversationEvent::ToolCall { name, .. } if name != "suggest_command" => {
- // New tool call starts as in-flight
- if in_flight.is_some() {
- // Previous tool is now completed
- completed += 1;
- }
- in_flight = Some(name.clone());
- }
- ConversationEvent::ToolResult { .. } => {
- // Tool completed
- if in_flight.is_some() {
- completed += 1;
- in_flight = None;
- }
- }
- _ => {}
- }
- }
-
- (completed, in_flight)
-}
-
-/// Check if any turn in the conversation has a command
-fn has_any_command(events: &[ConversationEvent]) -> bool {
- 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
- }
- })
-}
-
-impl Blocks {
- /// Pure function: derive the complete view model from state
- ///
- /// Iterates through conversation events and builds visual blocks.
- /// Also handles streaming text and mode-dependent UI.
- pub fn from_state(state: &AppState) -> Self {
- let mut items = Vec::new();
- let mut status_bar = None;
-
- // 1. Build blocks from conversation events
- for event in &state.events {
- match event {
- ConversationEvent::UserMessage { content } => {
- items.push(Block {
- content: vec![Content::Input {
- text: content.clone(),
- active: false,
- cursor_pos: 0,
- }],
- separator_above: false,
- title: None,
- });
- }
- ConversationEvent::Text { content } => {
- // In Review mode with completed tool calls, prepend ToolStatus to this Text block
- let (completed, _) = count_tool_calls_since_last_user(&state.events);
- let mut block_content = Vec::new();
-
- if state.mode == AppMode::Review && completed > 0 {
- block_content.push(Content::ToolStatus {
- completed_count: completed,
- current_label: None,
- frame: 0,
- });
- }
-
- block_content.push(Content::Text {
- markdown: content.clone(),
- });
-
- items.push(Block {
- content: block_content,
- separator_above: false,
- title: None,
- });
- }
- ConversationEvent::ToolCall { name, input, .. } => {
- // Only render suggest_command tool calls with a command
- if name == "suggest_command" {
- let command = input.get("command").and_then(|v| v.as_str());
-
- // Build block content - only render if command is present
- // When command is null, this is a conversation-only turn and the
- // response text comes via a separate Text event
- let mut block_content = Vec::new();
-
- if let Some(cmd) = command {
- block_content.push(Content::Command {
- text: cmd.to_string(),
- faded: false,
- });
- }
-
- // Extract warning data from tool call input
- // danger: "high" | "medium" | "med" | "low" - high/medium/med trigger warning
- let danger_level = input
- .get("danger")
- .and_then(|v| v.as_str())
- .unwrap_or("low");
- let is_dangerous = danger_level == "high"
- || danger_level == "medium"
- || danger_level == "med";
- let danger_notes = input.get("danger_notes").and_then(|v| v.as_str());
-
- // confidence: "high" | "medium" | "low" - low triggers warning
- let confidence_level = input
- .get("confidence")
- .and_then(|v| v.as_str())
- .unwrap_or("high");
- let is_low_confidence = confidence_level == "low";
- let confidence_notes =
- input.get("confidence_notes").and_then(|v| v.as_str());
-
- // Add warning content if applicable (danger takes precedence)
- if is_dangerous {
- if let Some(notes) = danger_notes {
- block_content.push(Content::Warning {
- kind: WarningKind::Danger,
- text: notes.to_string(),
- pending_confirm: state.confirmation_pending,
- });
- }
- } else if is_low_confidence && let Some(notes) = confidence_notes {
- block_content.push(Content::Warning {
- kind: WarningKind::LowConfidence,
- text: notes.to_string(),
- pending_confirm: false, // low confidence doesn't require confirm
- });
- }
-
- // Only add block if there's content
- if !block_content.is_empty() {
- items.push(Block {
- content: block_content,
- separator_above: false,
- title: None,
- });
- }
- }
- // Other tool calls are not rendered (internal protocol)
- }
- ConversationEvent::ToolResult { .. } => {
- // Tool results are not rendered (internal protocol)
- }
- }
- }
-
- // 2. AI response block (streaming text only) - shown during Streaming only
- // Transient status (spinner, tool progress) goes to status_bar on the bottom border.
- // In Review mode, ToolStatus is handled inline with ConversationEvent::Text above.
- if state.mode == AppMode::Streaming {
- let (completed, in_flight) = count_tool_calls_since_last_user(&state.events);
-
- // Tool status -> status bar
- if let Some(ref label) = in_flight {
- let text = if completed > 0 {
- format!(
- "{} (used {} tool{})",
- label,
- completed,
- if completed == 1 { "" } else { "s" }
- )
- } else {
- label.clone()
- };
- status_bar = Some(StatusBar {
- frame: state.spinner_frame,
- text,
- });
- }
-
- // Spinner -> status bar (only when no text yet and no tool in-flight)
- if state.streaming_text.is_empty() {
- let should_show_spinner = state.streaming_status.is_some()
- || state
- .streaming_started
- .map(|start| start.elapsed() >= std::time::Duration::from_millis(200))
- .unwrap_or(true);
-
- if should_show_spinner && in_flight.is_none() {
- let status_text = state
- .streaming_status
- .as_ref()
- .map(|s| s.display_text().to_string())
- .unwrap_or_else(|| "Generating...".to_string());
-
- status_bar = Some(StatusBar {
- frame: state.spinner_frame,
- text: status_text,
- });
- }
- } else {
- // Show streaming text as content
- items.push(Block {
- content: vec![Content::Text {
- markdown: state.streaming_text.clone(),
- }],
- separator_above: false,
- title: None,
- });
- }
- }
-
- // 3. Mode-dependent UI
- match state.mode {
- AppMode::Input => {
- // Active input uses TextArea widget, rendered directly
- // We add a placeholder block that will be replaced by textarea rendering
- items.push(Block {
- content: vec![Content::Input {
- text: state.input(),
- active: true,
- cursor_pos: 0, // Not used for active input - textarea handles cursor
- }],
- separator_above: false,
- title: None,
- });
- }
- AppMode::Generating => {
- let status_text = state
- .streaming_status
- .as_ref()
- .map(|s| s.display_text().to_string())
- .unwrap_or_else(|| "Generating...".to_string());
-
- status_bar = Some(StatusBar {
- frame: state.spinner_frame,
- text: status_text,
- });
- }
- AppMode::Streaming => {
- // Handled above in streaming text section
- }
- AppMode::Review | AppMode::Error => {
- // No additional UI elements
- }
- }
-
- // 4. Error if present (renders at end)
- if let Some(ref err) = state.error {
- items.push(Block {
- content: vec![Content::Error {
- message: err.clone(),
- }],
- separator_above: false,
- title: None,
- });
- }
-
- // 5. Set separator flags (first has no separator)
- for (idx, block) in items.iter_mut().enumerate() {
- block.separator_above = idx > 0;
- }
-
- // 6. Set title on first block only
- if let Some(first) = items.first_mut() {
- first.title = Some("Ask questions or generate a command:".to_string());
- }
-
- // 7. Derive footer from mode and events
- let footer = Self::footer_for_mode(&state.mode, &state.events, state.confirmation_pending);
-
- Self {
- items,
- footer,
- status_bar,
- }
- }
-
- /// Derive footer text from current mode and conversation state
- fn footer_for_mode(
- mode: &AppMode,
- events: &[ConversationEvent],
- confirmation_pending: bool,
- ) -> &'static str {
- match mode {
- AppMode::Input => "[Enter]: Accept [Esc]: Cancel",
- AppMode::Generating | AppMode::Streaming => "[Esc]: Cancel",
- AppMode::Review => {
- if confirmation_pending {
- "[Enter]: Confirm dangerous command [Esc]: Cancel"
- } else if has_any_command(events) {
- "[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel"
- } else {
- "[f]: Follow-up [Esc]: Cancel"
- }
- }
- AppMode::Error => "[Enter]/[r]: Retry [Esc]: Cancel",
- }
- }
-}