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