aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/view/mod.rs
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-04-21 13:07:27 -0700
committerGitHub <noreply@github.com>2026-04-21 13:07:27 -0700
commit2f702ad446fcd6a261a3bea0ab2807d70eca43e2 (patch)
tree4cfa6276257cefbe73f7fa46a74026170aaf8435 /crates/atuin-ai/src/tui/view/mod.rs
parentdocs: document show_numeric_shortcuts (#3433) (diff)
downloadatuin-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/mod.rs')
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs62
1 files changed, 42 insertions, 20 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(),