diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-26 19:19:47 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-27 02:19:47 +0000 |
| commit | b649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch) | |
| tree | ca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/tui/view | |
| parent | fix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff) | |
| download | atuin-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.rs | 342 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 409 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view_model.rs | 413 |
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", - } - } -} |
