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/event.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/event.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/event.rs | 303 |
1 files changed, 0 insertions, 303 deletions
diff --git a/crates/atuin-ai/src/tui/event.rs b/crates/atuin-ai/src/tui/event.rs deleted file mode 100644 index 8efbf522..00000000 --- a/crates/atuin-ai/src/tui/event.rs +++ /dev/null @@ -1,303 +0,0 @@ -use crate::tui::App; -use crossterm::event::{Event, EventStream, KeyEvent, KeyEventKind}; -use eyre::{Result, eyre}; -use futures::StreamExt; -use std::time::Duration; -use tokio::time; - -/// Base tick interval for the event loop (fast for responsive streaming) -const BASE_TICK_INTERVAL: Duration = Duration::from_millis(50); - -/// Application events that drive the TUI state machine. -/// -/// # Event Types -/// - `Key`: Keyboard input (filtered to KeyEventKind::Press only) -/// - `Tick`: Periodic event for updates (50ms base interval) -/// - `Resize`: Terminal window resize -/// - `StreamChunk/StreamDone/StreamError`: Placeholders for Phase 3 streaming -/// -/// # Design Decisions -/// - Fast 50ms base tick for responsive streaming; spinner timing handled in AppState -/// - Stream events are placeholders - will be wired to channels in Phase 3 -/// - Resize handling enables responsive layout adjustments -#[derive(Debug, Clone)] -pub enum AppEvent { - /// Keyboard input event (filtered to Press events only) - Key(KeyEvent), - - /// Periodic tick for updates (50ms base interval; spinner timing in AppState) - Tick, - - /// Terminal resize event (width, height) - Resize(u16, u16), - - /// Stream chunk received (Phase 3 placeholder) - StreamChunk(String), - - /// Stream completed successfully (Phase 3 placeholder) - StreamDone, - - /// Stream error occurred (Phase 3 placeholder) - StreamError(String), -} - -/// Async event loop that drives the TUI with prioritized event handling. -/// -/// # Priority Model (Biased Select) -/// 1. **Stream data** - Highest priority (future Phase 3 streaming) -/// 2. **Keyboard input** - Medium priority (user responsiveness) -/// 3. **Tick events** - Lowest priority (spinner animation) -/// -/// This ensures stream data is processed immediately when available, -/// keyboard input is responsive, and spinner updates don't block higher priority events. -/// -/// # Graceful Shutdown -/// - SIGINT (Ctrl+C) sets shutdown flag and breaks the loop -/// - EventStream close (stdin EOF) triggers shutdown -/// - Shutdown flag can be checked/set externally for controlled termination -/// -/// # Example -/// ```no_run -/// use atuin_ai::tui::EventLoop; -/// -/// # async fn example() -> eyre::Result<()> { -/// let mut event_loop = EventLoop::new(); -/// loop { -/// let event = event_loop.run().await?; -/// // Handle event... -/// # break; -/// } -/// # Ok(()) -/// # } -/// ``` -pub struct EventLoop { - /// Tick interval timer (created lazily on first run) - tick_timer: Option<time::Interval>, - - /// Flag indicating a render was requested (future use in Phase 2) - #[allow(dead_code)] - render_requested: bool, - - /// Shutdown flag - when true, event loop will terminate - shutdown: bool, -} - -impl EventLoop { - /// Create a new EventLoop with default settings. - /// - /// # Defaults - /// - Tick interval: 50ms base rate (spinner timing handled separately in AppState) - /// - Render requested: false - /// - Shutdown: false - pub fn new() -> Self { - Self { - tick_timer: None, - render_requested: false, - shutdown: false, - } - } - - /// Run the event loop, returning the next application event. - /// - /// # Priority Model - /// Uses `tokio::select!` with `biased;` mode to enforce priority: - /// 1. Stream data (placeholder for Phase 3) - /// 2. Keyboard input with rapid keypress batching - /// 3. Tick for spinner animation - /// - /// # Keyboard Handling - /// - Filters to KeyEventKind::Press on all platforms for safety - /// - Batching of rapid keypresses will be implemented in Phase 2 - /// - Currently returns individual key events - /// - /// # Graceful Shutdown - /// - SIGINT (Ctrl+C) triggers shutdown and returns last event - /// - EventStream close (stdin EOF) triggers shutdown - /// - Shutdown flag can be checked after this returns - /// - /// # Errors - /// - Returns error if terminal event stream encounters an error - /// - EventStream close is handled gracefully as shutdown signal - /// - /// # Example - /// ```no_run - /// # use atuin_ai::tui::EventLoop; - /// # async fn example() -> eyre::Result<()> { - /// let mut event_loop = EventLoop::new(); - /// while !event_loop.is_shutdown() { - /// match event_loop.run().await? { - /// // Handle events... - /// # _ => break, - /// } - /// } - /// # Ok(()) - /// # } - /// ``` - pub async fn run(&mut self) -> Result<AppEvent> { - // Create async event stream for keyboard/terminal events - let mut reader = EventStream::new(); - - // Get or create the tick timer (reused across calls to maintain timing) - // Uses fast base tick for responsive streaming; spinner timing handled in AppState - let tick_timer = self.tick_timer.get_or_insert_with(|| { - let mut interval = time::interval(BASE_TICK_INTERVAL); - // Skip the first immediate tick - interval.reset(); - interval - }); - - loop { - if self.shutdown { - break; - } - - // Biased select: prioritize stream > keyboard > tick - let event = tokio::select! { - biased; - - // Priority 1: Stream data (placeholder for Phase 3) - // In Phase 3, this will be: - // Some(chunk) = stream_rx.recv() => { ... } - - // Priority 2: Keyboard input - maybe_event = reader.next() => { - match maybe_event { - Some(Ok(Event::Key(key))) => { - // Filter to Press events only for cross-platform safety - if key.kind == KeyEventKind::Press { - // Note: Rapid keypress batching will be implemented in Phase 2 - // when we integrate with the state machine. - // For now, just return individual key events. - Some(AppEvent::Key(key)) - } else { - None - } - } - Some(Ok(Event::Resize(w, h))) => { - Some(AppEvent::Resize(w, h)) - } - Some(Err(e)) => { - return Err(eyre!("terminal event error: {}", e)); - } - None => { - // EventStream closed (stdin EOF) - trigger shutdown - self.shutdown = true; - None - } - _ => { - // Ignore other event types (mouse, focus, etc.) - None - } - } - } - - // Priority 3: Tick for spinner animation - _ = tick_timer.tick() => { - Some(AppEvent::Tick) - } - - // SIGINT handling (Ctrl+C) - cross-platform - _ = tokio::signal::ctrl_c() => { - self.shutdown = true; - // Return one more event to allow graceful shutdown handling - Some(AppEvent::Tick) - } - }; - - if let Some(app_event) = event { - return Ok(app_event); - } - } - - // Loop exited due to shutdown - return final tick to allow cleanup - Ok(AppEvent::Tick) - } - - /// Check if the event loop has been signaled to shut down. - /// - /// This can be used to cleanly exit the main TUI loop after receiving - /// a shutdown signal (Ctrl+C, stdin close, etc.) - pub fn is_shutdown(&self) -> bool { - self.shutdown - } - - /// Signal the event loop to shut down. - /// - /// The shutdown will take effect on the next iteration of `run()`. - pub fn shutdown(&mut self) { - self.shutdown = true; - } - - /// Poll for next event and apply to app state. - /// - /// This is a convenience method that combines `run()` with `App` state updates. - /// Returns true if app should continue, false if should exit. - /// - /// # Example - /// ```no_run - /// # use atuin_ai::tui::{EventLoop, App}; - /// # async fn example() -> eyre::Result<()> { - /// let mut event_loop = EventLoop::new(); - /// let mut app = App::new(); - /// - /// while event_loop.poll_and_apply(&mut app).await? { - /// // Render app state... - /// } - /// # Ok(()) - /// # } - /// ``` - pub async fn poll_and_apply(&mut self, app: &mut App) -> Result<bool> { - let event = self.run().await?; - - match event { - AppEvent::Key(key) => { - app.handle_key(key); - } - AppEvent::Tick => { - app.state.tick(); - } - AppEvent::Resize(_, _) => { - // Render will be triggered anyway - } - AppEvent::StreamChunk(_) | AppEvent::StreamDone | AppEvent::StreamError(_) => { - // Placeholder for Phase 3 - } - } - - Ok(!app.state.should_exit) - } -} - -impl Default for EventLoop { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_event_loop_creation() { - let event_loop = EventLoop::new(); - assert!(!event_loop.shutdown); - } - - #[test] - fn test_shutdown_flag() { - let mut event_loop = EventLoop::new(); - assert!(!event_loop.is_shutdown()); - - event_loop.shutdown(); - assert!(event_loop.is_shutdown()); - } - - // Note: Cannot easily test run() in unit tests since it requires a TTY. - // Integration tests should verify: - // 1. Tick events are generated at 150ms intervals - // 2. Keyboard events are properly filtered to Press only - // 3. Rapid keypresses are batched - // 4. SIGINT triggers graceful shutdown - // 5. Resize events are propagated correctly -} |
