Skip to main content

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;