diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-21 13:07:27 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 13:07:27 -0700 |
| commit | 2f702ad446fcd6a261a3bea0ab2807d70eca43e2 (patch) | |
| tree | 4cfa6276257cefbe73f7fa46a74026170aaf8435 /crates/atuin-ai/src/tui/view | |
| parent | docs: document show_numeric_shortcuts (#3433) (diff) | |
| download | atuin-2f702ad446fcd6a261a3bea0ab2807d70eca43e2.zip | |
refactor: Replace ad-hoc dispatch with FSM + driver architecture (#3434)
Replaces the tangled dispatch handler system (`tui/dispatch.rs`,
`tui/state.rs`) with a pure finite state machine + driver architecture.
The FSM handles all state transitions as explicit `(State, Event) →
(NewState, Effects)` mappings. The driver executes IO effects and
bridges the TUI to the FSM.
Diffstat (limited to 'crates/atuin-ai/src/tui/view')
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 62 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 13 |
2 files changed, 49 insertions, 26 deletions
diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs index 6e13e406..d40a44d4 100644 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -5,7 +5,9 @@ use eye_declare::{ }; use ratatui_core::style::{Color, Modifier, Style}; -use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview, TrackedTool}; +use crate::driver::ViewState; +use crate::fsm::{AgentState, StreamPhase}; +use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview}; use crate::tui::components::select::SelectOption; use crate::tui::components::session_continue::SessionContinue; use crate::tui::events::{AiTuiEvent, PermissionResult}; @@ -14,10 +16,23 @@ use super::components::atuin_ai::AtuinAi; use super::components::input_box::InputBox; use super::components::markdown::Markdown; use super::components::select::Select; -use super::state::{AppMode, Session}; +use super::state::AppMode; mod turn; +impl From<&AgentState> for AppMode { + fn from(state: &AgentState) -> Self { + match state { + AgentState::Idle { .. } => AppMode::Input, + AgentState::Turn { + stream: StreamPhase::Connecting, + } => AppMode::Generating, + AgentState::Turn { .. } => AppMode::Streaming, + AgentState::Error(_) => AppMode::Error, + } + } +} + /// Build the element tree from current state. /// /// Layout (top to bottom): @@ -26,28 +41,27 @@ mod turn; /// - Error display (if in error state) /// - Spacer /// - Input box (bordered, with contextual keybindings) -pub(crate) fn ai_view(state: &Session) -> Elements { - let mut turn_builder = turn::TurnBuilder::new(&state.tool_tracker); +pub(crate) fn ai_view(state: &ViewState) -> Elements { + let mut turn_builder = turn::TurnBuilder::new(&state.tools); - for event in &state.archived_view_events { + for event in &state.archived_events { turn_builder.add_event(event); } - for event in &state.conversation.events[state.view_start_index..] { + for event in &state.visible_events { turn_builder.add_event(event); } let turns = turn_builder.build(); - let busy = state.interaction.mode == AppMode::Streaming - || state.interaction.mode == AppMode::Generating; + let busy = state.is_busy(); let last_index = turns.len().saturating_sub(1); element! { AtuinAi( - mode: state.interaction.mode, - has_command: state.conversation.has_any_command(), - is_input_blank: state.interaction.is_input_blank, - pending_confirmation: state.interaction.confirmation_pending, - has_executing_preview: state.tool_tracker.has_executing_preview(), + mode: AppMode::from(&state.agent_state), + has_command: state.has_command(), + is_input_blank: state.is_input_blank, + pending_confirmation: state.has_confirmation(), + has_executing_preview: state.tools.has_executing_preview(), ) { #(if state.is_resumed && (!state.is_exiting() || !turns.is_empty()) { SessionContinue(key: "continuation-notice", continued_at: state.last_event_time) @@ -77,6 +91,15 @@ pub(crate) fn ai_view(state: &Session) -> Elements { } }) + #(if let AgentState::Error(ref msg) = state.agent_state { + View(key: "error-display", padding_left: Cells::from(2), padding_top: Cells::from(1)) { + Text { + Span(text: "Error: ", style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) + Span(text: msg, style: Style::default().fg(Color::Red)) + } + } + }) + #(if !state.is_exiting() { #(input_view(state)) }) @@ -84,11 +107,10 @@ pub(crate) fn ai_view(state: &Session) -> Elements { } } -fn input_view(state: &Session) -> Elements { - let asking_tool = state.tool_tracker.asking_for_permission(); +fn input_view(state: &ViewState) -> Elements { + let asking_tool = state.tools.awaiting_permission(); let in_git_project = state.in_git_project; let slash_results = state - .interaction .slash_command_search_results .iter() .take(4) @@ -107,12 +129,12 @@ fn input_view(state: &Session) -> Elements { title: "Generate a command or ask a question", title_right: "Atuin AI", footer: state.footer_text(), - active: state.interaction.mode == AppMode::Input && !state.interaction.confirmation_pending, + active: state.is_input_active(), slash_suggestion: first_slash_result.cloned() ) - #(if state.interaction.is_input_blank && state.conversation.has_any_command() && state.interaction.mode == AppMode::Input { - #(if state.interaction.confirmation_pending { + #(if state.is_input_blank && state.has_command() && state.is_input_active() { + #(if state.has_confirmation() { Text { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) } } else { Text { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) } @@ -140,7 +162,7 @@ fn input_view(state: &Session) -> Elements { } } -fn tool_call_view(tool_call: &TrackedTool, in_git_project: bool) -> Elements { +fn tool_call_view(tool_call: &crate::fsm::tools::TrackedTool, in_git_project: bool) -> Elements { let verb = tool_call.tool.descriptor().display_verb; let tool_desc = match &tool_call.tool { ClientToolCall::Read(tool) => tool.path.display().to_string(), diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs index 6c3d5c29..98ae5eff 100644 --- a/crates/atuin-ai/src/tui/view/turn.rs +++ b/crates/atuin-ai/src/tui/view/turn.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; +use crate::fsm::tools::ToolManager; use crate::tools::descriptor; -use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview, ToolTracker}; +use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview}; use crate::tui::ConversationEvent; /// Server-sent danger level for a suggested command @@ -210,12 +211,12 @@ pub(crate) enum UiTurn { pub(crate) struct TurnBuilder<'a> { turns: Vec<UiTurn>, current_turn: Option<UiTurn>, - tracker: &'a ToolTracker, + tracker: &'a ToolManager, } /// A struct to iteratively build [UiTurn] events from [ConversationEvent]s. impl<'a> TurnBuilder<'a> { - pub(crate) fn new(tracker: &'a ToolTracker) -> Self { + pub(crate) fn new(tracker: &'a ToolManager) -> Self { Self { turns: Vec::new(), current_turn: None, @@ -441,18 +442,18 @@ impl<'a> TurnBuilder<'a> { match &tracked.tool { ClientToolCall::Shell(shell) => ToolRenderData::Shell { command: shell.command.clone(), - preview: tracked.preview(), + preview: tracked.shell_preview(), }, ClientToolCall::Read(read) => ToolRenderData::FileRead { path: read.path.clone(), }, ClientToolCall::Edit(edit) => ToolRenderData::FileEdit { path: edit.path.clone(), - preview: tracked.edit_preview.clone(), + preview: tracked.edit_preview().cloned(), }, ClientToolCall::Write(write) => ToolRenderData::FileWrite { path: write.path.clone(), - preview: tracked.write_preview.clone(), + preview: tracked.write_preview().cloned(), }, ClientToolCall::AtuinHistory(history) => ToolRenderData::HistorySearch { query: history.query.clone(), |
