aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/view/mod.rs
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/mod.rs
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 'crates/atuin-ai/src/tui/view/mod.rs')
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs342
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