opendev_tui/app/mod.rs
1//! Main TUI application struct and event loop.
2//!
3//! This module is split into focused sub-modules:
4//! - [`enums`] — OperationMode, AutonomyLevel
5//! - [`types`] — DisplayMessage, DisplayRole, RoleStyle, DisplayToolCall, ToolState, ToolExecution
6//! - [`state`] — AppState struct and Default impl
7//! - [`cache`] — Conversation message caching and incremental rebuild
8//! - [`render`] — UI layout composition and main rendering orchestration
9//! - [`render_popups`] — Popup panels and modal dialog rendering
10//! - [`event_dispatch`] — Event routing and state mutations
11//! - [`key_handler`] — Keyboard input handling
12//! - [`slash_commands`] — Slash command execution
13//! - [`tick`] — Tick-based animations and scroll acceleration
14
15mod cache;
16mod enums;
17mod event_dispatch;
18mod handle_agent;
19mod handle_background;
20mod handle_subagent;
21mod handle_tools;
22mod handle_ui;
23mod key_handler;
24mod render;
25mod render_popups;
26mod slash_commands;
27mod state;
28mod tick;
29mod types;
30
31pub use enums::{AutonomyLevel, OperationMode, ReasoningLevel};
32pub use state::AppState;
33pub use types::{
34 DisplayMessage, DisplayRole, DisplayToolCall, PendingItem, RoleStyle, ToolExecution, ToolState,
35};
36
37use std::io;
38use std::sync::Arc;
39use std::time::Duration;
40
41use crate::controllers::{
42 ApprovalController, AskUserController, McpCommandController, MessageController,
43 ModelPickerController, PlanApprovalController,
44};
45use crate::event::{AppEvent, EventHandler};
46use crate::managers::BackgroundTaskManager;
47use crossterm::{
48 event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags},
49 execute,
50 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
51};
52use ratatui::{Terminal, backend::CrosstermBackend};
53use tokio::sync::mpsc;
54
55/// The main TUI application.
56pub struct App {
57 /// Application state.
58 pub state: AppState,
59 /// Event handler for terminal + agent events.
60 event_handler: EventHandler,
61 /// Channel for sending events back into the loop (e.g., from key handlers).
62 event_tx: mpsc::UnboundedSender<AppEvent>,
63 /// Message controller for handling user submissions.
64 message_controller: MessageController,
65 /// Ask-user controller for interactive question prompts.
66 ask_user_controller: AskUserController,
67 /// Oneshot sender to forward the ask-user answer back to the tool.
68 ask_user_response_tx: Option<tokio::sync::oneshot::Sender<String>>,
69 /// Approval controller for inline command approval prompts.
70 approval_controller: ApprovalController,
71 /// Oneshot sender to forward the approval decision back to the react loop.
72 approval_response_tx:
73 Option<tokio::sync::oneshot::Sender<opendev_runtime::ToolApprovalDecision>>,
74 /// Plan approval controller for plan review prompts.
75 plan_approval_controller: PlanApprovalController,
76 /// Oneshot sender to forward the plan decision back to the tool.
77 plan_approval_response_tx: Option<tokio::sync::oneshot::Sender<opendev_runtime::PlanDecision>>,
78 /// Interrupt token for signaling cancellation to the agent (set per-query).
79 interrupt_token: Option<opendev_runtime::InterruptToken>,
80 /// Optional channel for forwarding user messages to the agent backend.
81 user_message_tx: Option<mpsc::UnboundedSender<String>>,
82 /// MCP command controller for managing MCP servers.
83 mcp_controller: McpCommandController,
84 /// Model picker controller for interactive model selection.
85 model_picker_controller: Option<ModelPickerController>,
86 /// Background task manager (shared with async kill tasks).
87 task_manager: Arc<tokio::sync::Mutex<BackgroundTaskManager>>,
88}
89
90impl Default for App {
91 fn default() -> Self {
92 App::new()
93 }
94}
95
96impl App {
97 fn should_render_before_draining(event: &AppEvent) -> bool {
98 matches!(
99 event,
100 AppEvent::ReasoningContent(_)
101 | AppEvent::AgentChunk(_)
102 | AppEvent::AgentMessage(_)
103 | AppEvent::ToolStarted { .. }
104 | AppEvent::ToolResult { .. }
105 | AppEvent::ToolFinished { .. }
106 | AppEvent::SubagentStarted { .. }
107 | AppEvent::SubagentToolCall { .. }
108 | AppEvent::SubagentToolComplete { .. }
109 | AppEvent::SubagentFinished { .. }
110 )
111 }
112
113 /// Create a new TUI application with default state.
114 pub fn new() -> Self {
115 let event_handler = EventHandler::new(Duration::from_millis(60));
116 let event_tx = event_handler.sender();
117 Self {
118 state: AppState::default(),
119 event_handler,
120 event_tx,
121 message_controller: MessageController::new(),
122 ask_user_controller: AskUserController::new(),
123 ask_user_response_tx: None,
124 approval_controller: ApprovalController::new(),
125 approval_response_tx: None,
126 plan_approval_controller: PlanApprovalController::new(),
127 plan_approval_response_tx: None,
128 interrupt_token: None,
129 user_message_tx: None,
130 mcp_controller: McpCommandController::new(vec![]),
131 model_picker_controller: None,
132 task_manager: Arc::new(tokio::sync::Mutex::new(BackgroundTaskManager::default())),
133 }
134 }
135
136 /// Attach a channel for forwarding user-submitted messages to the agent backend.
137 ///
138 /// When set, every `UserSubmit` event will also send the message text through
139 /// this channel so the backend can process it.
140 pub fn with_message_channel(mut self, tx: mpsc::UnboundedSender<String>) -> Self {
141 self.user_message_tx = Some(tx);
142 self
143 }
144
145 /// Get a sender for pushing events into the application loop.
146 ///
147 /// Agent and tool runners use this to notify the UI of state changes.
148 pub fn event_sender(&self) -> mpsc::UnboundedSender<AppEvent> {
149 self.event_tx.clone()
150 }
151
152 /// Run the TUI application.
153 ///
154 /// Sets up the terminal, enters the event loop, and restores the
155 /// terminal on exit or panic.
156 pub async fn run(&mut self) -> io::Result<()> {
157 // Terminal setup
158 enable_raw_mode()?;
159 let mut stdout = io::stdout();
160 execute!(stdout, EnterAlternateScreen)?;
161
162 // Enable alternate scroll mode: terminal converts mouse wheel / trackpad
163 // scroll into Up/Down arrow key sequences. Works reliably on macOS Terminal.app
164 // where EnableMouseCapture doesn't produce scroll events for trackpad gestures.
165 // Also enable focus change reporting for FocusGained/FocusLost redraws.
166 {
167 use std::io::Write;
168 stdout.write_all(b"\x1b[?1007h")?;
169 stdout.flush()?;
170 }
171 execute!(stdout, crossterm::event::EnableFocusChange)?;
172
173 // Enable Kitty keyboard protocol so terminals report Shift+Enter distinctly.
174 // Always attempt to push the flags — unsupported terminals silently ignore the
175 // escape sequence, and `supports_keyboard_enhancement()` is unreliable (it queries
176 // the terminal and can timeout, returning false on terminals that DO support it).
177 let keyboard_enhanced = execute!(
178 io::stdout(),
179 PushKeyboardEnhancementFlags(
180 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
181 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
182 )
183 )
184 .is_ok();
185
186 let backend = CrosstermBackend::new(stdout);
187 let mut terminal = Terminal::new(backend)?;
188 // Start the event reader
189 self.event_handler.start();
190
191 // Main loop
192 let result = self.event_loop(&mut terminal).await;
193
194 // Terminal teardown (always runs)
195 if keyboard_enhanced {
196 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
197 }
198 disable_raw_mode()?;
199 {
200 use std::io::Write;
201 let _ = terminal.backend_mut().write_all(b"\x1b[?1007l");
202 }
203 execute!(terminal.backend_mut(), crossterm::event::DisableFocusChange)?;
204 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
205 terminal.show_cursor()?;
206
207 result
208 }
209
210 /// The core event loop: render -> wait for event -> drain queued events -> repeat.
211 ///
212 /// Draining all pending events before each render avoids redundant frames
213 /// when typing fast (5 queued keys = 1 render instead of 5).
214 /// The dirty flag skips renders when no state has changed.
215 async fn event_loop(
216 &mut self,
217 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
218 ) -> io::Result<()> {
219 while self.state.running {
220 // Cache terminal dimensions for tick-time access
221 let size = terminal.size()?;
222 self.state.terminal_width = size.width;
223 self.state.terminal_height = size.height;
224
225 // Force a full screen repaint when needed (overlay close, focus regain, etc.).
226 if self.state.force_clear {
227 // Re-enable alternate scroll mode after focus regain (some terminals
228 // reset it on focus change).
229 {
230 use std::io::Write;
231 let _ = terminal.backend_mut().write_all(b"\x1b[?1007h");
232 }
233 // Full terminal clear: clear the backend screen AND reset both
234 // internal ratatui diff buffers so the next draw() rewrites every cell.
235 //
236 // terminal.clear() sends ESC[2J (visual clear) and resets the back buffer.
237 // swap_buffers() then resets the old current buffer (which may have stale
238 // overlay content) and swaps, leaving both buffers empty.
239 // This ensures the next draw() produces a complete diff with every cell
240 // updated, eliminating stale overlay artifacts.
241 let _ = terminal.clear();
242 terminal.swap_buffers();
243 self.state.force_clear = false;
244 self.state.dirty = true;
245 }
246
247 // Only render when state has changed
248 if self.state.dirty {
249 // Rebuild cached conversation lines if messages changed, scroll
250 // moved (scroll affects viewport culling boundaries), or terminal
251 // width changed (cached lines are pre-wrapped to a specific width).
252 let content_width = self.state.terminal_width.saturating_sub(1);
253 if self.state.lines_generation != self.state.message_generation
254 || self.state.cached_scroll_offset != self.state.scroll_offset
255 || self.state.cached_width != content_width
256 {
257 self.rebuild_cached_lines();
258 self.state.lines_generation = self.state.message_generation;
259 self.state.cached_scroll_offset = self.state.scroll_offset;
260 }
261
262 terminal.draw(|frame| self.render(frame))?;
263 self.state.dirty = false;
264 // Update selection geometry after render so mouse mapping uses fresh layout
265 self.update_selection_geometry();
266 }
267
268 // Wait for at least one event
269 let mut should_render_now = false;
270 if let Some(event) = self.event_handler.next().await {
271 should_render_now = Self::should_render_before_draining(&event);
272 self.handle_event(event);
273 }
274
275 // Drain all remaining queued events before next render
276 while !should_render_now {
277 let Some(event) = self.event_handler.try_next() else {
278 break;
279 };
280 should_render_now = Self::should_render_before_draining(&event);
281 self.handle_event(event);
282 if !self.state.running {
283 break;
284 }
285 }
286 }
287 Ok(())
288 }
289}
290
291#[cfg(test)]
292mod tests;