aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/components/atuin_ai.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/components/atuin_ai.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/components/atuin_ai.rs')
-rw-r--r--crates/atuin-ai/src/tui/components/atuin_ai.rs140
1 files changed, 140 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/tui/components/atuin_ai.rs b/crates/atuin-ai/src/tui/components/atuin_ai.rs
new file mode 100644
index 00000000..680b93ed
--- /dev/null
+++ b/crates/atuin-ai/src/tui/components/atuin_ai.rs
@@ -0,0 +1,140 @@
+//! Top-level AtuinAi component that translates key events into AiTuiEvents.
+//!
+//! This component wraps the entire view and handles key events that bubble up
+//! from child components (or aren't consumed by them). It maps raw key events
+//! to semantic `AiTuiEvent` variants based on the current `AppMode`.
+
+use std::sync::mpsc;
+
+use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
+use eye_declare::{Component, EventResult, Hooks, impl_slot_children};
+
+use crate::tui::events::AiTuiEvent;
+use crate::tui::state::AppMode;
+
+/// Top-level wrapper component for the AI TUI.
+///
+/// Props carry the current mode so `handle_event` can translate keys
+/// into the right `AiTuiEvent`. Children are rendered via slot children.
+pub struct AtuinAi {
+ pub mode: AppMode,
+ pub has_command: bool,
+ pub is_input_blank: bool,
+ pub pending_confirmation: bool,
+}
+
+impl Default for AtuinAi {
+ fn default() -> Self {
+ Self {
+ mode: AppMode::Input,
+ has_command: false,
+ is_input_blank: false,
+ pending_confirmation: false,
+ }
+ }
+}
+
+impl_slot_children!(AtuinAi);
+
+#[derive(Default)]
+pub struct AtuinAiState {
+ tx: Option<mpsc::Sender<AiTuiEvent>>,
+}
+
+impl Component for AtuinAi {
+ type State = AtuinAiState;
+
+ fn initial_state(&self) -> Option<Self::State> {
+ Some(AtuinAiState::default())
+ }
+
+ fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) {
+ hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, state| {
+ state.tx = tx.cloned();
+ });
+ }
+
+ fn render(
+ &self,
+ _area: ratatui::layout::Rect,
+ _buf: &mut ratatui::buffer::Buffer,
+ _state: &Self::State,
+ ) {
+ // Rendering is handled by slot children
+ }
+
+ fn desired_height(&self, _width: u16, _state: &Self::State) -> u16 {
+ 0
+ }
+
+ fn handle_event(&self, event: &Event, state: &mut Self::State) -> EventResult {
+ let Event::Key(KeyEvent {
+ code,
+ kind: KeyEventKind::Press,
+ modifiers,
+ ..
+ }) = event
+ else {
+ return EventResult::Ignored;
+ };
+
+ let Some(ref tx) = state.tx else {
+ return EventResult::Ignored;
+ };
+
+ // Ctrl+C always exits
+ if modifiers.contains(KeyModifiers::CONTROL) && *code == KeyCode::Char('c') {
+ let _ = tx.send(AiTuiEvent::Exit);
+ return EventResult::Consumed;
+ }
+
+ match self.mode {
+ AppMode::Input => match code {
+ KeyCode::Esc => {
+ if self.pending_confirmation {
+ let _ = tx.send(AiTuiEvent::CancelConfirmation);
+ return EventResult::Consumed;
+ }
+
+ let _ = tx.send(AiTuiEvent::Exit);
+ EventResult::Consumed
+ }
+ KeyCode::Tab => {
+ if self.has_command && self.is_input_blank {
+ let _ = tx.send(AiTuiEvent::InsertCommand);
+ return EventResult::Consumed;
+ }
+
+ EventResult::Ignored
+ }
+ KeyCode::Enter => {
+ if self.has_command && self.is_input_blank {
+ let _ = tx.send(AiTuiEvent::ExecuteCommand);
+ return EventResult::Consumed;
+ }
+
+ EventResult::Ignored
+ }
+ _ => EventResult::Ignored,
+ },
+ AppMode::Generating | AppMode::Streaming => match code {
+ KeyCode::Esc => {
+ let _ = tx.send(AiTuiEvent::CancelGeneration);
+ EventResult::Consumed
+ }
+ _ => EventResult::Ignored,
+ },
+ AppMode::Error => match code {
+ KeyCode::Esc => {
+ let _ = tx.send(AiTuiEvent::Exit);
+ EventResult::Consumed
+ }
+ KeyCode::Enter | KeyCode::Char('r') => {
+ let _ = tx.send(AiTuiEvent::Retry);
+ EventResult::Consumed
+ }
+ _ => EventResult::Ignored,
+ },
+ }
+ }
+}