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}