Skip to main content

recursive/tui/app/
mod.rs

1//! Application state for the Recursive TUI.
2//!
3//! [`App`] owns everything visible to the user: the transcript blocks,
4//! the input buffer, the current screen, scroll position, and bookkeeping
5//! for streaming, usage, and per-turn timing.
6//!
7//! This module contains only the struct definition and re-exports.
8//! Implementation is split across:
9//! - [`state`]     — constructors, basic accessors, transcript mutation helpers
10//! - [`event_loop`] — `handle_ui_event` + streaming helpers
11//! - [`commands`]  — keyboard dispatch, modals, atfile, history search, permissions
12//! - [`render`]    — standalone helper functions (preview_args, verb_for_tool, parse_*)
13
14use std::collections::HashSet;
15use std::sync::{atomic::AtomicBool, Arc};
16use std::time::Instant;
17
18use crate::runtime::GoalState;
19
20pub mod commands;
21pub mod event_loop;
22pub mod render;
23pub mod state;
24
25// Re-export from sub-modules so the rest of the codebase can still do
26// `use crate::tui::app::Foo` without any changes.
27pub use crate::tui::completion::{
28    collect_files, default_offline_tool_catalog, glob_workspace_files, search_history,
29    MAX_ATFILE_SUGGESTIONS, MAX_HSEARCH_RESULTS,
30};
31pub use crate::tui::cost::{detect_model_name, estimate_cost, TurnState, UsageStats};
32pub use crate::tui::input_state::{
33    double_press_window, strip_history_prefix, DoublePressTracker, InputMode, PromptInputState,
34    DOUBLE_PRESS_WINDOW, HISTORY_CAPACITY,
35};
36pub use crate::tui::model::{
37    AppScreen, DiffHunk, DiffLine, DiffLineKind, ToolResultData, TranscriptBlock,
38};
39pub use render::{parse_apply_patch_input, parse_v4a_patch, preview_args, verb_for_tool};
40
41// ──────────────────────────────────────────────────────────────────────
42// Top-level App struct
43// ──────────────────────────────────────────────────────────────────────
44
45pub struct App {
46    /// Multi-mode input state (Goal 145).
47    pub prompt: PromptInputState,
48    pub blocks: Vec<TranscriptBlock>,
49    pub should_quit: bool,
50    pub session_id: Option<String>,
51    pub connected: bool,
52    pub scroll_offset: u16,
53    pub screen: AppScreen,
54    /// Tracks when the TUI session started. Used by `/status` to report uptime.
55    pub start_time: Instant,
56    pub usage: UsageStats,
57    pub turn: TurnState,
58    pub turn_count: u64,
59    pub pending_latency_ms: Option<u64>,
60    pub model_name: String,
61    pub spinner_frame: usize,
62    /// Goal-146: stack of overlay modals. The topmost (last) modal
63    /// receives keys; an empty stack means chat keys are active.
64    pub modals: Vec<crate::tui::ui::modal::Modal>,
65    /// Goal-146: registry of `/`-prefixed slash commands. Lazily
66    /// initialised in [`App::new`] with [`CommandRegistry::default_set`].
67    pub commands: crate::tui::commands::CommandRegistry,
68    /// Goal-146: list of tools the runtime has registered. Populated
69    /// by `main.rs` from `Backend::tool_specs()` after the worker
70    /// boots, and read by the `/tools` command. Defaults to a static
71    /// list when running offline.
72    pub tool_catalog: Vec<(String, String)>,
73    /// Goal-146: cursor / selected index into the command-menu
74    /// completion popup. `None` means the user hasn't navigated
75    /// (Enter executes the literal buffer).
76    pub command_menu_selected: Option<usize>,
77    /// Goal-146: planning-mode flag mirrored on the UI side. Reflects
78    /// the latest `/plan on|off` invocation. Used to render an
79    /// indicator and to seed `/status`.
80    pub planning_mode_on: bool,
81    /// Set when the agent has proposed a plan via `exit_plan_mode` and we are
82    /// waiting for the user to approve or reject it. Cleared by
83    /// `PlanConfirmed` / `PlanRejected` events. Used to show a status-bar
84    /// indicator so the user knows input is expected.
85    pub plan_awaiting_approval: bool,
86    /// Goal-202: set when the agent has called `request_plan_mode` and we are
87    /// waiting for the user to allow or skip planning. Cleared by
88    /// `PlanModeApproved` / `PlanModeRejected` events.
89    pub plan_mode_request_pending: bool,
90    /// Goal-147: tracks the most recent Esc / Ctrl+C presses so the
91    /// second press within [`double_press_window`] can promote a soft
92    /// action (interrupt / clear) into a real exit. See
93    /// [`App::handle_key`].
94    pub double_press: DoublePressTracker,
95    // ── Goal-158: @file autocomplete ─────────────────────────────────
96    /// The text the user has typed after `@` while in AtFile mode.
97    pub atfile_query: String,
98    /// Candidate file paths matching [`atfile_query`]. Refreshed on
99    /// every keystroke in AtFile mode. Contains at most
100    /// [`MAX_ATFILE_SUGGESTIONS`] entries.
101    pub atfile_suggestions: Vec<String>,
102    /// Currently highlighted row in the AtFile popup. `None` means
103    /// nothing is highlighted yet (typing narrows the list).
104    pub atfile_selected: Option<usize>,
105    // ── Goal-160: Ctrl+R history search ──────────────────────────────
106    /// Current search query in HistorySearch mode.
107    pub hsearch_query: String,
108    /// Indices into `prompt.history` that match [`hsearch_query`],
109    /// in priority order (prefix matches first). Capped at
110    /// [`MAX_HSEARCH_RESULTS`].
111    pub hsearch_matches: Vec<usize>,
112    /// Currently highlighted row in the history-search popup.
113    pub hsearch_selected: usize,
114    // ── Goal-161: Permission Request Modal ───────────────────────────
115    /// A pending tool-permission request delivered from the backend
116    /// worker via the side-channel. `None` means no permission dialog
117    /// is open. When `Some`, the modal is rendered and all keys are
118    /// routed to `handle_permission_key`.
119    pub pending_permission: Option<PendingPermission>,
120    /// Set of tool names the user has chosen to "Allow All" for the
121    /// current session. Requests for these tools skip the modal.
122    pub auto_allowed_tools: HashSet<String>,
123    /// Whether the runtime permission hook is currently active.
124    /// Toggled by `/permissions on|off`. Shared with the backend worker.
125    pub permission_hook_enabled: Arc<AtomicBool>,
126    /// Goal-167: current task list maintained by `todo_write` calls.
127    /// Empty when no task list has been set this session.
128    pub current_todos: Vec<crate::tools::todo::TodoItem>,
129    /// Goal-168: mirrored goal state, updated by `UiEvent::Goal*` events.
130    pub active_goal: Option<GoalState>,
131    /// Goal-171: workspace root path, used by /resume to list sessions.
132    pub workspace_path: std::path::PathBuf,
133    /// Goal-174: active colour palette. Defaults to [`DARK`]; switchable
134    /// via `/theme <name>` without restart.
135    pub theme: &'static crate::tui::ui::theme::Theme,
136
137    // ── Progressive output ───────────────────────────────────────────────
138    /// Blocks from `self.blocks[0..last_printed_idx]` have already been
139    /// flushed to the terminal's scrollback buffer via
140    /// `terminal.insert_before()`. The inline viewport only renders
141    /// blocks at index `>= last_printed_idx` (in-flight content).
142    pub last_printed_idx: usize,
143    /// Queue of rendered lines waiting to be pushed to the scrollback
144    /// buffer in the next event-loop iteration. Drained by the main
145    /// loop using `terminal.insert_before()`.
146    pub print_queue: Vec<Vec<ratatui::text::Line<'static>>>,
147
148    // ── Modal scroll ─────────────────────────────────────────────────────
149    /// Vertical scroll offset (in lines) for the currently-active modal.
150    /// Reset to 0 whenever a new modal is pushed. For list-based modals
151    /// (ResumePicker, McpServers, Journal) the key handler auto-updates
152    /// this to keep the selection visible.
153    pub modal_scroll: u16,
154}
155
156// ── Goal-161: PendingPermission ──────────────────────────────────────────────
157
158/// Holds the state for the permission-request modal while it is open.
159/// The `reply` sender is consumed exactly once when the user presses Y or N.
160pub struct PendingPermission {
161    pub tool_name: String,
162    pub args_preview: String,
163    pub reply: tokio::sync::oneshot::Sender<bool>,
164}