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/mod.rs | |
| 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 'crates/atuin-ai/src/tui/view/mod.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 342 |
1 files changed, 342 insertions, 0 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 |
