返回 CodeWhale
event_loop.rs
根目录 / crates / tui / src / tui / ui / event_loop.rs
1 //! The TUI event loops.
2 //!
3 //! Moved verbatim out of `ui.rs`, which had grown past 19k lines. `run_tui`
4 //! owns terminal setup and teardown; `run_event_loop` is the frame, input, and
5 //! engine-event pump it drives.
6
7 use super::*;
8
9 /// Run the interactive TUI event loop.
10 ///
11 /// # Examples
12 ///
13 /// ```ignore
14 /// # use crate::config::Config;
15 /// # use crate::tui::TuiOptions;
16 /// # async fn example(config: &Config, options: TuiOptions) -> anyhow::Result<()> {
17 /// crate::tui::run_tui(config, options).await
18 /// # }
19 /// ```
20 pub async fn run_tui(
21 config: &Config,
22 options: TuiOptions,
23 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
24 ) -> Result<()> {
25 let use_alt_screen = options.use_alt_screen;
26 let use_mouse_capture = options.use_mouse_capture;
27 let use_bracketed_paste = options.use_bracketed_paste;
28
29 // Apply OSC 8 hyperlink toggle from config.
30 //
31 // #3029: OSC 8 hyperlinks are emitted out-of-band. Markdown wrapping keeps
32 // visible spans and per-line targets in separate structures; each render
33 // seam translates those targets into absolute `LinkRegion`s without ever
34 // placing an escape byte in a ratatui buffer cell. `ColorCompatBackend`
35 // then emits the OSC 8 escapes through its `Write` impl around the matching
36 // cell runs. Hyperlinks are on by default for terminals that handle the OSC
37 // terminator (`ESC \`) cleanly. Windows legacy consoles (conhost) still
38 // mishandle the terminator, so the default stays off there; opt in via
39 // `[tui] osc8_links = true` on any platform.
40 let osc8_default_on = !cfg!(target_os = "windows");
41 crate::tui::osc8::set_enabled(
42 config
43 .tui
44 .as_ref()
45 .and_then(|tui| tui.osc8_links)
46 .unwrap_or(osc8_default_on),
47 );
48
49 // Fail fast with a clear message when the interactive TUI is launched
50 // without a controlling TTY (#4716). Without this, enable_raw_mode fails
51 // with opaque "Device not configured" / "Input/output error" and some
52 // terminal hosts surface only "[Process completed]".
53 require_interactive_terminal(io::stdin().is_terminal(), io::stdout().is_terminal())?;
54
55 // Terminal probe with timeout to prevent hanging on unresponsive terminals.
56 //
57 // The blocking task cannot be cancelled once the timeout fires, so a slow
58 // `enable_raw_mode` may still succeed *after* we've bailed out, leaking
59 // raw mode. Both sides run `raw_mode_probe_handshake`; whichever observes
60 // the other's flag disables raw mode again.
61 let probe_timeout = terminal_probe_timeout(config);
62 let probe_abandoned = Arc::new(AtomicBool::new(false));
63 let probe_enabled = Arc::new(AtomicBool::new(false));
64 let task_abandoned = Arc::clone(&probe_abandoned);
65 let task_enabled = Arc::clone(&probe_enabled);
66 let enable_raw = tokio::task::spawn_blocking(move || {
67 let result =
68 enable_raw_mode().map_err(|e| anyhow::anyhow!("Failed to enable raw mode: {e}"));
69 if result.is_ok() && raw_mode_probe_handshake(&task_enabled, &task_abandoned) {
70 // The probe timed out while we were blocked; the caller already
71 // gave up, so undo the late enable instead of leaking raw mode.
72 let _ = disable_raw_mode();
73 }
74 result
75 });
76
77 match tokio::time::timeout(probe_timeout, enable_raw).await {
78 Ok(inner_result) => {
79 inner_result??; // propagate both join and raw-mode errors
80 }
81 Err(_) => {
82 if raw_mode_probe_handshake(&probe_abandoned, &probe_enabled) {
83 // The blocking task finished enabling raw mode right as the
84 // timeout fired and may have missed the abandoned flag.
85 let _ = disable_raw_mode();
86 }
87 tracing::warn!(
88 "Terminal probe timed out after {}ms - terminal may be unresponsive",
89 probe_timeout.as_millis()
90 );
91 return Err(anyhow::anyhow!(
92 "Terminal probe timed out after {}ms",
93 probe_timeout.as_millis()
94 ));
95 }
96 }
97
98 #[cfg(target_os = "windows")]
99 enable_windows_ime_console_mode();
100
101 let mut stdout = io::stdout();
102 // Initialize the file-backed TUI log and redirect raw stderr away from
103 // the alt-screen for the lifetime of this guard. MUST run BEFORE
104 // EnterAlternateScreen; otherwise logging between alt-screen entry and
105 // redirect init leaks raw bytes into the TUI buffer, causing the "scroll
106 // demon" on Windows (#1909) and garbled output on all platforms (#1085).
107 // The guard is held until the function returns; dropping it after
108 // LeaveAlternateScreen restores the original stderr handle/fd so shutdown
109 // messages reach the user's terminal. We accept the init failing (e.g.,
110 // read-only $HOME) and continue without the redirect rather than refusing
111 // to start the TUI.
112 let _tui_log_guard = match crate::runtime_log::init() {
113 Ok(guard) => Some(guard),
114 Err(err) => {
115 tracing::warn!(target: "runtime_log", ?err, "TUI log init failed; stderr leaks may render as scroll-demon");
116 None
117 }
118 };
119 if use_alt_screen {
120 execute!(stdout, EnterAlternateScreen)?;
121 // Windows also suppresses Codewhale's own verbose CLI logger while
122 // the alt-screen is active. The stderr redirect above catches raw
123 // writes; this prevents the known verbose source at the origin.
124 #[cfg(windows)]
125 crate::logging::snapshot_verbose_state();
126 #[cfg(windows)]
127 crate::logging::set_verbose(false);
128 }
129 // Mouse capture, bracketed paste, focus events, and the Kitty
130 // keyboard-protocol escape-disambiguation flag (#442). Single source
131 // of truth shared with the FocusGained recovery path and
132 // resume_terminal — see recover_terminal_modes.
133 //
134 // Focus events are necessary for IME compositor re-activation on
135 // macOS when the user switches away (Cmd+Tab) and returns. The Kitty
136 // keyboard protocol opt-in is best-effort: terminals that don't
137 // support it (iTerm2, Terminal.app, Windows 10 conhost) silently
138 // discard the escape, while supporting terminals (Kitty, Ghostty,
139 // Alacritty 0.13+, WezTerm, recent Konsole, recent xterm) report
140 // unambiguous events for Option/Alt-modified keys and plain Esc.
141 //
142 // Only `DISAMBIGUATE_ESCAPE_CODES` is pushed — the higher tiers
143 // (`REPORT_EVENT_TYPES`, `REPORT_ALL_KEYS_AS_ESCAPE_CODES`) emit
144 // release events that the existing key handlers would mis-route
145 // as duplicate presses.
146 //
147 // On Windows, crossterm's `PushKeyboardEnhancementFlags` command always
148 // reports the terminal as unsupported (`is_ansi_code_supported` returns
149 // false), so the escape is written directly instead. VSCode's integrated
150 // terminal and Windows Terminal ≥1.17 honour the kitty keyboard protocol
151 // and will correctly disambiguate Shift+Enter from plain Enter once this
152 // sequence is received. Terminals that do not understand it silently
153 // ignore it.
154 recover_terminal_modes(&mut stdout, use_mouse_capture, use_bracketed_paste);
155 let mut cleanup_guard = TerminalCleanupGuard {
156 use_alt_screen,
157 use_mouse_capture,
158 use_bracketed_paste,
159 defused: false,
160 };
161 let color_depth = palette::ColorDepth::detect();
162 // Raw mode is on and the event loop has not started, which is the only
163 // window where the OSC 11 background query is safe to issue — see
164 // `palette::probe_terminal_background`. The result is cached process-wide,
165 // so every later `PaletteMode::detect()` sees the same answer.
166 let background = palette::probe_terminal_background();
167 let palette_mode = background.mode();
168 tracing::debug!(
169 ?color_depth,
170 ?palette_mode,
171 background_source = ?background.source(),
172 background_color = ?background.color(),
173 "terminal color profile detected"
174 );
175 let mut backend = ColorCompatBackend::new(stdout, color_depth, palette_mode);
176 backend.set_detected_background(background.color());
177 let mut terminal = Terminal::new(backend)?;
178 // At this point Settings hasn't loaded yet, so we can't read the
179 // user's `synchronized_output` knob. Use the same env-based terminal
180 // quirk detection that `Settings::apply_env_overrides` uses, so the
181 // startup viewport reset matches what every later draw will do on
182 // flicker-sensitive hosts. A user who has explicitly set
183 // `synchronized_output = "on"` to override detection will get sync wrap
184 // from the main draw loop onward; the one-time startup viewport reset
185 // stays opt-out for them, which is the safe default because the cost is
186 // at most brief tearing on the first frame.
187 let sync_output_at_init = !crate::settings::detected_ptyxis_terminal()
188 && !crate::settings::detected_legacy_windows_console_host();
189 reset_terminal_viewport(&mut terminal, sync_output_at_init)?;
190 let event_broker = EventBroker::new();
191
192 // Local mutable copy so runtime config flips (e.g. `/provider` switch)
193 // can rebuild the API client without restarting the process.
194 let mut config = config.clone();
195 let config = &mut config;
196 let mut app = App::new_with_plugin_registry(options.clone(), config, plugin_registry);
197 crate::startup_trace::mark("app_constructed");
198 sync_config_provider_from_app(config, &app);
199 surface_prompt_override_notices(&mut app);
200
201 if options.resume_session_id.is_none() && !app.launch.visible {
202 let opened_setup = open_setup_checkpoint_if_due(&mut app, config, options.skip_onboarding);
203 // One-time Fleet + Hotbar intro for returning (non-resuming) users.
204 // First-time users see it when they finish onboarding. Gated by a
205 // persisted flag, so it shows exactly once and never inside a resumed
206 // session transcript or behind the constitution checkpoint.
207 if !opened_setup {
208 app.maybe_show_feature_intro();
209 }
210 }
211
212 // Load existing session if resuming.
213 if let Some(ref session_id) = options.resume_session_id
214 && let Ok(manager) = SessionManager::default_location()
215 {
216 // Try to load by prefix or full ID
217 let load_result: std::io::Result<Option<crate::session_manager::SavedSession>> =
218 if session_id == "latest" {
219 // Special case: resume the most recent session in this workspace.
220 match manager.get_latest_session_for_workspace(&options.workspace) {
221 Ok(Some(meta)) => manager.load_session(&meta.id).map(Some),
222 Ok(None) => Ok(None),
223 Err(e) => Err(e),
224 }
225 } else {
226 manager.load_session_by_prefix(session_id).map(Some)
227 };
228
229 match load_result {
230 Ok(Some(saved)) => match apply_loaded_session(&mut app, config, &saved) {
231 Ok(()) => {
232 app.status_message = Some(format!(
233 "Resumed session: {}",
234 crate::session_manager::truncate_id(&saved.metadata.id)
235 ));
236 }
237 Err(err) => {
238 app.status_message = Some(format!("Failed to restore session: {err}"));
239 }
240 },
241 Ok(None) => {
242 app.status_message = Some("No sessions found to resume".to_string());
243 }
244 Err(e) => {
245 app.status_message = Some(format!("Failed to load session: {e}"));
246 }
247 }
248 }
249
250 // Auto-resume's receipt (#2934). It overrides the generic resume message
251 // because it is the more specific truth: it names what was reattached, or
252 // why nothing was. It never overwrites a *failure* message from the load
253 // path above — a real error outranks a decision receipt.
254 if let Some(notice) = options.startup_notice.clone()
255 && app
256 .status_message
257 .as_deref()
258 .is_none_or(|current| !current.starts_with("Failed to"))
259 {
260 app.status_message = Some(notice);
261 }
262
263 if let Ok(manager) = SessionManager::default_location() {
264 match manager.load_offline_queue_state() {
265 Ok(Some(state)) => {
266 if restore_matching_offline_queue_state(&mut app, state) {
267 if app.status_message.is_none() && app.queued_message_count() > 0 {
268 app.status_message = Some(format!(
269 "Restored {} queued message(s) from previous session — ↑ to edit, Ctrl+X to discard",
270 app.queued_message_count()
271 ));
272 }
273 } else {
274 // Session mismatch - clear the stale queue
275 let _ = manager.clear_offline_queue_state();
276 }
277 }
278 Ok(None) => {}
279 Err(err) => {
280 if app.status_message.is_none() {
281 app.status_message = Some(format!("Failed to restore offline queue: {err}"));
282 }
283 }
284 }
285 }
286
287 let task_manager = TaskManager::start(
288 TaskManagerConfig::from_runtime(
289 config,
290 app.workspace.clone(),
291 Some(app.model.clone()),
292 Some(app.max_subagents.clamp(1, 4)),
293 ),
294 config.clone(),
295 std::sync::Arc::clone(&app.plugin_registry),
296 )
297 .await?;
298 let automations = std::sync::Arc::new(tokio::sync::Mutex::new(
299 AutomationManager::default_location()?,
300 ));
301 let automation_cancel = tokio_util::sync::CancellationToken::new();
302 let automation_scheduler = spawn_scheduler(
303 automations.clone(),
304 task_manager.clone(),
305 automation_cancel.clone(),
306 AutomationSchedulerConfig::default(),
307 );
308 let shell_manager = app
309 .runtime_services
310 .shell_manager
311 .clone()
312 .unwrap_or_else(|| crate::tools::shell::new_shared_shell_manager(app.workspace.clone()));
313 // #2511: ensure hook_executor is initialized for fresh sessions — it is
314 // only set by apply_workspace_runtime_state (session resume / workspace
315 // switch), so a brand-new session would otherwise leave it None and both
316 // exec_shell shell_env hooks and ToolCallBefore gate would silently no-op.
317 if app.runtime_services.hook_executor.is_none() {
318 app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
319 }
320 app.runtime_services = RuntimeToolServices {
321 shell_manager: Some(shell_manager),
322 task_manager: Some(task_manager.clone()),
323 automations: Some(automations),
324 task_data_dir: Some(task_manager.data_dir()),
325 active_task_id: None,
326 active_thread_id: None,
327 dynamic_tool_executor: None,
328 work: app.runtime_services.work.clone(),
329 // #456: plumb the App's HookExecutor so `exec_shell` can surface
330 // the configured `shell_env` hooks. Clone the shared Arc.
331 hook_executor: app.runtime_services.hook_executor.clone(),
332 handle_store: app.runtime_services.handle_store.clone(),
333 rlm_sessions: app.runtime_services.rlm_sessions.clone(),
334 };
335 crate::startup_trace::mark("task_manager_ready");
336 refresh_active_task_panel(&mut app, &task_manager).await;
337
338 let engine_config = build_engine_config(&app, config);
339
340 // Spawn the Engine - it will handle all API communication
341 let engine_handle = spawn_tui_engine(engine_config, config);
342 crate::startup_trace::mark("engine_spawned");
343 // The translation client is optional: it never crashes the TUI on
344 // startup, even when the API key is missing, the base URL is malformed,
345 // or the network is unavailable.
346 // Translations are skipped with a logged warning until a key is saved.
347 let translation_client = match DeepSeekClient::new(config) {
348 Ok(client) => Some(Arc::new(client)),
349 Err(err) => {
350 if app.onboarding == OnboardingState::None {
351 tracing::warn!("Translation client initialization failed: {err}");
352 }
353 None
354 }
355 };
356
357 if !app.api_messages.is_empty() {
358 let _ = engine_handle
359 .send(Op::SyncSession {
360 session_id: app.current_session_id.clone(),
361 messages: app.api_messages.clone(),
362 system_prompt: app.system_prompt.clone(),
363 system_prompt_override: false,
364 model: app.model.clone(),
365 workspace: app.workspace.clone(),
366 mode: app.mode,
367 })
368 .await;
369 }
370
371 // The engine owns the canonical model-facing prompt from startup. Mirror
372 // that exact value before the first draw so `/context` never reports an
373 // empty system prompt merely because no user turn has been submitted yet.
374 match engine_handle.get_session_snapshot().await {
375 Ok(snapshot) => app.system_prompt = snapshot.system_prompt,
376 Err(err) => tracing::warn!("could not mirror initial engine system prompt: {err:#}"),
377 }
378
379 // Fire session start hook
380 {
381 let context = app.base_hook_context();
382 let hooks = app.hooks.clone();
383 if let Err(error) =
384 tokio::task::spawn_blocking(move || hooks.execute(HookEvent::SessionStart, &context))
385 .await
386 {
387 tracing::error!(target: "hooks", %error, "session_start executor task was lost");
388 app.status_message = Some("session_start hook executor did not run".to_string());
389 }
390 }
391
392 // Spawn the persistence actor so checkpoint/session-save I/O stays off
393 // the UI thread. The actor serialises + writes to disk in a dedicated
394 // task; the UI just `try_send`s a request and returns immediately.
395 let persistence_runtime = SessionManager::default_location()
396 .ok()
397 .map(|persist_manager| {
398 let (handle, task) = persistence_actor::spawn_persistence_actor(persist_manager);
399 persistence_actor::init_actor(handle.clone());
400 (handle, task)
401 });
402
403 // Returning users with a missing key begin directly in the same canonical
404 // provider setup picker as first-run. Focus their persisted route so
405 // recovery does not silently replace a Kimi Code bare-K3 endpoint.
406 if app.onboarding == OnboardingState::Provider {
407 open_onboarding_provider_picker(&mut app, config, &engine_handle, true).await;
408 }
409
410 // #4605: create the dispatch completion channel before any submit path so
411 // initial input and queued follow-ups can dispatch without blocking the
412 // startup sequence.
413 // At most one user dispatch is allowed in flight. A two-slot completion
414 // mailbox covers the hook stage plus the send stage without turning a
415 // stalled UI into an unbounded queue of captured App mutations.
416 let (dispatch_completion_tx, dispatch_completion_rx) =
417 tokio::sync::mpsc::channel::<crate::tui::app::DispatchApplyFn>(2);
418 app.dispatch_completion_tx = Some(dispatch_completion_tx);
419
420 if std::mem::take(&mut app.start_remote_control_on_launch) {
421 start_remote_control_session(&mut app);
422 }
423 submit_initial_input_if_ready(&mut app, config, &engine_handle).await?;
424
425 crate::startup_trace::log_summary();
426 // Pin the cold-start measurement at the same moment the summary is logged.
427 // `log_summary` computes the same number into a local, emits it, clears its
428 // buffer, and returns `()`, so this reads `PROCESS_START` directly rather
429 // than through it. Only this path calls it, which is what keeps the
430 // cold-start bucket absent on surfaces with no event loop.
431 crate::startup_trace::mark_cold_start();
432 let result = run_event_loop(
433 &mut terminal,
434 &mut app,
435 config,
436 engine_handle,
437 task_manager,
438 &event_broker,
439 translation_client,
440 dispatch_completion_rx,
441 )
442 .await;
443 automation_cancel.cancel();
444 automation_scheduler.abort();
445
446 // Join the startup-default writer before anything else tears down.
447 //
448 // The last thing a user does before quitting is very often the selection
449 // they most want to survive — Tab into Operate, then Ctrl+C. Those writes
450 // are queued off the event loop on purpose, so at this point one may still
451 // be in flight or not yet started. Draining here is what makes "the last
452 // immediate selection lands" true rather than a race against process exit.
453 //
454 // Failures are collected, not toasted: the event loop has already drawn its
455 // final frame, so a toast would never be painted. They are printed below,
456 // after the alternate screen is gone and stderr is back on the user's real
457 // terminal.
458 let startup_default_failures = app.startup_defaults.shutdown();
459 for failure in &startup_default_failures {
460 tracing::warn!(
461 target: "settings",
462 subjects = ?failure.subjects,
463 detail = %failure.detail,
464 "startup default was not persisted before shutdown",
465 );
466 }
467 let startup_default_failures: Vec<String> = startup_default_failures
468 .iter()
469 .map(|failure| app.startup_default_failure_message(failure))
470 .collect();
471
472 // Fire session end hook
473 {
474 let context = app.base_hook_context();
475 let _ = app.execute_hooks(HookEvent::SessionEnd, &context);
476 }
477
478 // Flush the persistence actor: clear this session's checkpoint, collect
479 // the durability report (write failures are surfaced, not discarded),
480 // then shut down gracefully.
481 if let Some((handle, task)) = persistence_runtime {
482 if let Some(session_id) = app.current_session_id.clone() {
483 handle.try_send(PersistRequest::ClearCheckpoint { session_id });
484 }
485 let (report_tx, report_rx) = tokio::sync::oneshot::channel();
486 handle.try_send(PersistRequest::FlushAndReport { reply: report_tx });
487 if let Ok(report) = report_rx.await
488 && !report.failures.is_empty()
489 {
490 tracing::warn!(
491 target: "persistence",
492 failures = ?report.failures,
493 "session persistence reported write failures during shutdown",
494 );
495 }
496 handle.try_send(PersistRequest::Shutdown);
497 let _ = task.await;
498 }
499
500 cleanup_guard.defused = true;
501 pop_keyboard_enhancement_flags(terminal.backend_mut());
502 disable_alternate_scroll_mode(terminal.backend_mut());
503 execute!(terminal.backend_mut(), DisableFocusChange)?;
504 disable_raw_mode()?;
505 if use_alt_screen {
506 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
507 #[cfg(windows)]
508 crate::logging::restore_verbose_state();
509 }
510 if use_mouse_capture {
511 execute!(terminal.backend_mut(), DisableMouseCapture)?;
512 }
513 if use_bracketed_paste {
514 disable_bracketed_paste_mode(terminal.backend_mut());
515 }
516 terminal.show_cursor()?;
517 drop(terminal);
518
519 // Back on the primary screen, so this is somewhere the user can actually
520 // read. A settings write that did not land would otherwise be invisible
521 // until the next launch quietly came up in the old mode.
522 for failure in &startup_default_failures {
523 tracing::error!(target: "settings", "{failure}");
524 // Printed AFTER `LeaveAlternateScreen` / `drop(terminal)`, so this is on
525 // the restored primary screen. The module-level
526 // `#![deny(clippy::print_stderr)]` would otherwise refuse it.
527 #[allow(clippy::print_stderr)]
528 {
529 eprintln!("codewhale: {failure}");
530 }
531 }
532
533 if result.is_ok() && should_show_resume_hint(app.current_session_id.as_deref()) {
534 // Printed AFTER `LeaveAlternateScreen` / `drop(terminal)` above,
535 // so we're back on the primary screen — this is the one
536 // legitimate stdout write in the TUI module tree. The
537 // module-level `#![deny(clippy::print_stdout)]` would otherwise
538 // refuse it.
539 #[allow(clippy::print_stdout)]
540 {
541 println!("{}", resume_hint_text());
542 }
543 }
544
545 result
546 }
547
548 #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
549 pub(crate) async fn run_event_loop(
550 terminal: &mut AppTerminal,
551 app: &mut App,
552 config: &mut Config,
553 mut engine_handle: EngineHandle,
554 task_manager: SharedTaskManager,
555 event_broker: &EventBroker,
556 translation_client: Option<Arc<DeepSeekClient>>,
557 mut dispatch_completion_rx: tokio::sync::mpsc::Receiver<crate::tui::app::DispatchApplyFn>,
558 ) -> Result<()> {
559 // Track streaming state
560 let mut current_streaming_text = String::new();
561 let mut stream_display_clock = StreamDisplayClock::default();
562 let (translation_tx, mut translation_rx) =
563 tokio::sync::mpsc::unbounded_channel::<TranslationEvent>();
564 let mut pending_translations = 0usize;
565 let mut pending_thinking_translations = 0usize;
566 let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone());
567 let mut last_queue_was_empty = app.queued_messages.is_empty() && app.queued_draft.is_none();
568 let mut last_task_refresh = Instant::now()
569 .checked_sub(Duration::from_secs(2))
570 .unwrap_or_else(Instant::now);
571 let mut last_status_frame = Instant::now()
572 .checked_sub(Duration::from_millis(UI_STATUS_ANIMATION_MS))
573 .unwrap_or_else(Instant::now);
574 // 120 FPS draw cap. Without this we redraw on every SSE chunk during a
575 // long stream — wasted work the user can't perceive. See
576 // `tui::frame_rate_limiter` for the rationale; ports the small piece of
577 // codex's frame coalescing that maps cleanly onto our poll-based loop.
578 // Measured display Hz may raise the floor toward the panel refresh rate
579 // (still never faster than MIN_FRAME_INTERVAL); low_motion always wins.
580 let mut frame_rate_limiter = crate::tui::frame_rate_limiter::FrameRateLimiter::default();
581 {
582 let probe = crate::tui::display_refresh::probe_display_refresh();
583 frame_rate_limiter.set_adaptive_interval(Some(
584 crate::tui::display_refresh::draw_min_interval_for_hz(probe.hz, false),
585 ));
586 }
587 // Widgets request future animation frames here; the poll loop remains the
588 // sole `terminal.draw` emitter (no competing animation loop).
589 let mut frame_requester = FrameRequester::new();
590 let mut web_config_session: Option<WebConfigSession> = None;
591 let mut prev_input_snapshot = String::new();
592 let mut terminal_paused_at: Option<Instant> = None;
593 let mut force_terminal_repaint = false;
594 // FocusGained debounce: some terminal emulators (e.g. Tabby) re-trigger
595 // FocusGained when we re-arm focus-change reporting inside
596 // recover_terminal_modes, creating a tight repaint loop. Skip
597 // mode recovery (but still mark a repaint) within the debounce window.
598 const FOCUS_RECOVERY_DEBOUNCE: Duration = Duration::from_millis(200);
599 let mut last_focus_recovery = Instant::now()
600 .checked_sub(Duration::from_secs(60))
601 .unwrap_or_else(Instant::now);
602 let mut terminal_input = TerminalInputPump::spawn()?;
603 let mut pending_terminal_events: VecDeque<Event> = VecDeque::new();
604 let mut last_terminal_input_recovery = Instant::now()
605 .checked_sub(TERMINAL_INPUT_RECOVERY_COOLDOWN)
606 .unwrap_or_else(Instant::now);
607 let mut last_recovery_snapshot_at: Option<Instant> = None;
608
609 // Fire-and-forget version check — runs once per session in the
610 // background. On success, a short status toast advertises the update
611 // without replacing the user's configured footer/status-line chips.
612 let mut version_check: Option<tokio::task::JoinHandle<Option<UpdateNotice>>> =
613 spawn_startup_version_check(config.update_config());
614
615 // Fire a one-shot initial balance fetch for DeepSeek providers
616 // so the footer chip shows balance on the first frame without
617 // waiting for a turn to complete.
618 if !app.balance_initiated && should_fetch_deepseek_balance(app) {
619 let cell = app.balance_cell.clone();
620 let api_key = config.deepseek_api_key().unwrap_or_default();
621 let base_url = config.deepseek_base_url();
622 if !api_key.is_empty() {
623 app.last_balance_fetch = Some(Instant::now());
624 tokio::spawn(async move {
625 if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await
626 && let Ok(mut guard) = cell.lock()
627 {
628 *guard = Some(info);
629 }
630 });
631 }
632 app.balance_initiated = true;
633 }
634
635 let mut pending_subagent_list_refresh = false;
636
637 loop {
638 while let Some(completion) = app.clipboard.poll_write_completion() {
639 if let Err(err) = completion {
640 tracing::warn!(error = %err, "background terminal clipboard write failed");
641 app.push_status_toast(
642 format!("Clipboard copy failed: {err}"),
643 StatusToastLevel::Error,
644 None,
645 );
646 app.needs_redraw = true;
647 }
648 }
649
650 // Drain dispatch completions from spawned send tasks (#4605). The
651 // closure receives `&mut App` and applies success state or rollback.
652 while let Ok(apply) = dispatch_completion_rx.try_recv() {
653 let _ = apply(app, &engine_handle, &*config);
654 }
655
656 // Drain the version-check handle once; re-assign None so we
657 // don't poll it again.
658 let mut done = false;
659 if let Some(ref handle) = version_check {
660 done = handle.is_finished();
661 }
662 if done && let Ok(Some(notice)) = version_check.take().unwrap().await {
663 // Transient toast for immediate visibility, plus a durable
664 // in-transcript notice so the prompt survives the toast TTL and
665 // stays actionable during a busy session (#3961). The persistent
666 // header chip keeps a quiet affordance after both (#14).
667 // Which command to advertise depends on who owns this binary on
668 // disk, so resolve that here rather than hardcoding our own
669 // updater into the wording.
670 let install = codewhale_release::current_install_method();
671 app.update_available = Some(notice.chip_label());
672 app.push_status_toast(
673 notice.toast_line(install),
674 StatusToastLevel::Info,
675 Some(VERSION_HINT_TOAST_TTL_MS),
676 );
677 app.add_message(HistoryCell::System {
678 content: notice.notice_block(install),
679 });
680 }
681
682 if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await {
683 web_config_session = None;
684 }
685
686 // Non-blocking startup-default writes (mode / thinking) report their
687 // failures here rather than at the keystroke, so a settings file we
688 // could not write is visible instead of silently reverting next launch.
689 app.drain_startup_default_failures();
690
691 while let Ok(event) = translation_rx.try_recv() {
692 match event {
693 TranslationEvent::AssistantMessage {
694 history_index,
695 original_text,
696 translated,
697 thinking,
698 tool_uses,
699 } => {
700 pending_translations = pending_translations.saturating_sub(1);
701 pending_thinking_translations = pending_thinking_translations.saturating_sub(1);
702 let text = match translated {
703 Ok(text) => {
704 app.status_message = Some(
705 crate::localization::tr(
706 app.ui_locale,
707 crate::localization::MessageId::TranslationComplete,
708 )
709 .to_string(),
710 );
711 text
712 }
713 Err(err) => {
714 tracing::warn!("assistant translation failed: {err}");
715 app.status_message = Some(format!(
716 "{}: {err}",
717 crate::localization::tr(
718 app.ui_locale,
719 crate::localization::MessageId::TranslationFailed,
720 )
721 ));
722 crate::localization::hidden_translation_failed(app.ui_locale)
723 .to_string()
724 }
725 };
726
727 if let Some(index) = history_index
728 && let Some(HistoryCell::Assistant { content, .. }) =
729 app.history.get_mut(index)
730 {
731 *content = text.clone();
732 app.bump_history_cell(index);
733 }
734 if !replace_matching_assistant_text(app, &original_text, text.clone()) {
735 push_assistant_message(app, text, thinking, tool_uses);
736 }
737 if pending_translations == 0
738 && !matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
739 {
740 app.is_loading = pending_translations > 0;
741 }
742 app.needs_redraw = true;
743 }
744 TranslationEvent::Thinking {
745 placeholder,
746 translated,
747 } => {
748 pending_translations = pending_translations.saturating_sub(1);
749 let text = match translated {
750 Ok(text) => {
751 app.status_message = Some(
752 crate::localization::thinking_translation_complete(app.ui_locale)
753 .to_string(),
754 );
755 text
756 }
757 Err(err) => {
758 tracing::warn!("thinking translation failed: {err}");
759 app.status_message = Some(format!(
760 "{}: {err}",
761 crate::localization::thinking_translation_failed(app.ui_locale)
762 ));
763 crate::localization::hidden_translation_failed(app.ui_locale)
764 .to_string()
765 }
766 };
767 streaming_thinking::replace_pending_translation(app, &placeholder, text);
768 if pending_translations == 0
769 && !matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
770 {
771 app.is_loading = false;
772 }
773 app.needs_redraw = true;
774 }
775 }
776 }
777
778 if last_task_refresh.elapsed() >= Duration::from_millis(2500) {
779 if refresh_active_task_panel(app, &task_manager).await {
780 app.needs_redraw = true;
781 }
782 if refresh_shell_exec_live_output(app) {
783 app.needs_redraw = true;
784 }
785 if app
786 .runtime_services
787 .work
788 .as_ref()
789 .is_some_and(|work| work.has_pending_publish())
790 && let Err(err) = persist_pending_work_checkpoint(app).await
791 {
792 tracing::warn!(error = %err, "background Work lifecycle checkpoint remains pending");
793 }
794 last_task_refresh = Instant::now();
795 }
796
797 // Clear suggestion when the user modifies the input.
798 if app.input != prev_input_snapshot {
799 app.prompt_suggestion = None;
800 prev_input_snapshot = app.input.clone();
801 }
802
803 // Poll prompt suggestion cell from background generation task.
804 // Discard stale results whose generation token no longer matches.
805 if let Ok(mut guard) = app.prompt_suggestion_cell.try_lock()
806 && let Some((gen_token, suggestion)) = guard.take()
807 && gen_token
808 == app
809 .prompt_suggestion_gen
810 .load(std::sync::atomic::Ordering::Relaxed)
811 {
812 app.prompt_suggestion = Some(suggestion);
813 }
814
815 // Poll the fleet-profile model-draft cell filled by the background
816 // drafting task (#3757 review: the draft must not park the loop).
817 let fleet_draft_delivery = app
818 .fleet_draft_cell
819 .try_lock()
820 .ok()
821 .and_then(|mut guard| guard.take());
822 if let Some((draft_gen, model_label, picked_route, reasoning_effort, outcome)) =
823 fleet_draft_delivery
824 && draft_gen == app.current_draft_gen()
825 {
826 deliver_fleet_draft_result(
827 app,
828 model_label,
829 picked_route,
830 reasoning_effort,
831 outcome,
832 app.ui_locale,
833 );
834 }
835
836 // Poll the constitution model-draft cell (same background pattern).
837 let constitution_draft_delivery = app
838 .constitution_draft_cell
839 .try_lock()
840 .ok()
841 .and_then(|mut guard| guard.take());
842 if let Some((draft_gen, model_label, draft_locale, outcome)) = constitution_draft_delivery
843 && draft_gen == app.current_draft_gen()
844 {
845 deliver_constitution_draft_result(app, model_label, draft_locale, outcome);
846 }
847
848 // #1830/#2317: service any already-arrived terminal keys before a
849 // potentially long engine batch so composer/modal input stays live.
850 collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
851
852 if drain_remote_control_events(app, config, &engine_handle).await? {
853 app.needs_redraw = true;
854 }
855
856 // First, poll for engine events (non-blocking)
857 let mut received_engine_event = false;
858 let mut transcript_batch_updated = false;
859 // #freeze: coalesce per-event `Op::ListSubAgents` sends into a single
860 // trailing-edge refresh per drain. At high fanout, many spawn/complete/
861 // mailbox events in one drain otherwise each take the manager write
862 // lock and trigger a full O(N) list reconcile.
863 let mut subagent_list_refresh_requested = false;
864 let mut queued_to_send: Option<QueuedMessage> = None;
865 let mut respawn_after_provider_rollback: Option<String> = None;
866 let mut fallback_after_engine_error: Option<ProviderFallbackRollback> = None;
867 {
868 let mut rx = engine_handle.rx_event.write().await;
869 let mut progress_redraw_agents: HashSet<String> = HashSet::new();
870 let drain_started = Instant::now();
871 let mut events_drained = 0usize;
872 loop {
873 if events_drained > 0
874 && engine_drain_budget_exhausted(events_drained, drain_started, Instant::now())
875 {
876 break;
877 }
878 let event = match rx.try_recv() {
879 Ok(event) => event,
880 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
881 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
882 if recover_engine_event_disconnect(app) {
883 received_engine_event = true;
884 transcript_batch_updated = true;
885 }
886 break;
887 }
888 };
889 // #3033: remember whether an EARLIER event in this drain batch
890 // already requested a redraw. The AgentProgress throttle below
891 // may opt the current event out of repainting, but it must not
892 // cancel redraws owed to other events in the same batch.
893 let redraw_requested_before_event = received_engine_event;
894 received_engine_event = true;
895 capture_turn_started_metadata(app, &event);
896 if app.suppress_stream_events_until_turn_complete {
897 if matches!(event, EngineEvent::TurnStarted { .. }) {
898 // Ctrl+C can race with the engine's per-turn token
899 // reset: the first cancel may hit the previous token
900 // if SendMessage is queued but TurnStarted has not
901 // arrived yet. Reassert cancellation once the real
902 // turn starts, then keep hiding its queued deltas.
903 engine_handle.cancel();
904 continue;
905 }
906 if suppress_engine_event_after_local_cancel(&event) {
907 continue;
908 }
909 } else if !app.is_loading && ignore_stale_stream_event_while_idle(&event) {
910 continue;
911 }
912 if !matches!(event, EngineEvent::ApprovalRequired { .. }) {
913 app.remote_control.observe_engine_event(&event);
914 }
915 record_turn_activity(app, &event, Instant::now());
916 match event {
917 EngineEvent::MessageStarted { .. } => {
918 // Assistant text starting after parallel tool work
919 // means the tool group is done. Flush the active
920 // cell first so the message lands BELOW the
921 // committed tool group (Codex pattern: streamed
922 // assistant content always flows after work).
923 app.flush_active_cell();
924 current_streaming_text.clear();
925 app.streaming_output_token_estimate = 0;
926 app.streaming_state.reset();
927 app.streaming_state.start_text(0);
928 app.streaming_message_index = None;
929 stream_display_clock.reset();
930 }
931 EngineEvent::MessageDelta { content, .. } => {
932 let sanitized = sanitize_stream_chunk(&content);
933 if sanitized.is_empty() {
934 continue;
935 }
936 // First delta of a fresh stream has no streaming
937 // cell yet; flush active so the tool group settles
938 // before the assistant prose appears below it.
939 if app.streaming_message_index.is_none() {
940 app.flush_active_cell();
941 }
942 current_streaming_text.push_str(&sanitized);
943 ensure_streaming_assistant_history_cell(app);
944 app.streaming_state.push_content(0, &sanitized);
945 stream_display_clock.note_delta(Instant::now());
946 received_engine_event = redraw_requested_before_event;
947 }
948 EngineEvent::MessageComplete { .. } => {
949 // #861 RC3: defensive drain of a still-active thinking
950 // entry. Normally `ThinkingComplete` arrives first and
951 // populates `last_reasoning` before we get here, but
952 // when the engine bursts events the channel can
953 // deliver `MessageComplete` first, in which case
954 // `last_reasoning.take()` below would be `None` and
955 // the thinking block would be dropped from
956 // `api_messages` — causing a DeepSeek HTTP 400 on the
957 // next turn (V4 thinking-mode requires
958 // `reasoning_content` replay). Inline-finalize the
959 // thinking entry here so this branch is order-
960 // independent.
961 if app.streaming_thinking_active_entry.is_some() {
962 if streaming_thinking::finalize_current(app) {
963 transcript_batch_updated = true;
964 }
965 streaming_thinking::stash_reasoning_buffer_into_last_reasoning(app);
966 }
967 let mut completed_message_index = None;
968 if let Some(index) = app.streaming_message_index.take() {
969 completed_message_index = Some(index);
970 stream_display_clock.flush_now(Instant::now());
971 let remaining = app.streaming_state.finalize_block_text(0);
972 if !remaining.is_empty() {
973 append_streaming_text(app, index, &remaining);
974 accrue_streaming_token_estimate(app, &remaining);
975 }
976 if let Some(HistoryCell::Assistant { streaming, .. }) =
977 app.history.get_mut(index)
978 {
979 *streaming = false;
980 }
981 // Streaming flag flipped — the cell's compact /
982 // transcript variants render slightly
983 // differently, so bump its revision so the cache
984 // refreshes this row only.
985 app.bump_history_cell(index);
986 transcript_batch_updated = true;
987 stream_display_clock.reset();
988 }
989
990 let thinking = app.last_reasoning.take();
991 let tool_uses = app.pending_tool_uses.drain(..).collect::<Vec<_>>();
992 let history_index = completed_message_index;
993
994 if app.translation_enabled
995 && !current_streaming_text.is_empty()
996 && crate::tui::translation::needs_translation(&current_streaming_text)
997 && let Some(translation_client) = translation_client.as_ref()
998 {
999 app.status_message = Some(
1000 crate::localization::tr(
1001 app.ui_locale,
1002 crate::localization::MessageId::TranslationInProgress,
1003 )
1004 .to_string(),
1005 );
1006 app.is_loading = true;
1007 pending_translations = pending_translations.saturating_add(1);
1008 let tx = translation_tx.clone();
1009 let client = translation_client.clone();
1010 let original_text = current_streaming_text.clone();
1011 let translation_model = app
1012 .last_effective_model
1013 .clone()
1014 .unwrap_or_else(|| app.model.clone());
1015 let target_language =
1016 app.ui_locale.translation_target_name().to_string();
1017 tokio::spawn(async move {
1018 let translated = crate::tui::translation::translate_text(
1019 &original_text,
1020 &client,
1021 &translation_model,
1022 &target_language,
1023 )
1024 .await;
1025 let _ = tx.send(TranslationEvent::AssistantMessage {
1026 history_index,
1027 original_text,
1028 translated,
1029 thinking,
1030 tool_uses,
1031 });
1032 });
1033 } else {
1034 push_assistant_message(
1035 app,
1036 current_streaming_text.clone(),
1037 thinking,
1038 tool_uses,
1039 );
1040 }
1041 }
1042 EngineEvent::ThinkingStarted { .. } => {
1043 stream_display_clock.reset();
1044 // P2.3: thinking lives in the active cell so it groups
1045 // visually with the tool calls that follow until the
1046 // next assistant prose chunk flushes the group.
1047 if streaming_thinking::start_block(app) {
1048 transcript_batch_updated = true;
1049 }
1050 if app.translation_enabled {
1051 let entry_idx = streaming_thinking::ensure_active_entry(app);
1052 streaming_thinking::set_placeholder(app, entry_idx);
1053 transcript_batch_updated = true;
1054 }
1055 }
1056 EngineEvent::ThinkingDelta { content, .. } => {
1057 let sanitized = sanitize_stream_chunk(&content);
1058 if sanitized.is_empty() {
1059 continue;
1060 }
1061 app.reasoning_buffer.push_str(&sanitized);
1062 if app.reasoning_header.is_none() {
1063 app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer);
1064 }
1065
1066 streaming_thinking::ensure_active_entry(app);
1067 app.streaming_state.push_content(0, &sanitized);
1068 stream_display_clock.note_delta(Instant::now());
1069 received_engine_event = redraw_requested_before_event;
1070 }
1071 EngineEvent::ThinkingComplete { .. } => {
1072 stream_display_clock.flush_now(Instant::now());
1073 if app.translation_enabled {
1074 let original_thinking = app.reasoning_buffer.clone();
1075 let _ = app.streaming_state.finalize_block_text(0);
1076 let duration = app
1077 .thinking_started_at
1078 .take()
1079 .map(|t| t.elapsed().as_secs_f32());
1080 if streaming_thinking::finalize_active_entry(app, duration, "") {
1081 transcript_batch_updated = true;
1082 }
1083 if !original_thinking.is_empty()
1084 && crate::tui::translation::needs_translation(&original_thinking)
1085 && let Some(translation_client) = translation_client.as_ref()
1086 {
1087 app.status_message = Some(
1088 crate::localization::thinking_translation_in_progress(
1089 app.ui_locale,
1090 )
1091 .to_string(),
1092 );
1093 app.is_loading = true;
1094 pending_translations = pending_translations.saturating_add(1);
1095 pending_thinking_translations =
1096 pending_thinking_translations.saturating_add(1);
1097 let tx = translation_tx.clone();
1098 let client = translation_client.clone();
1099 let translation_model = app
1100 .last_effective_model
1101 .clone()
1102 .unwrap_or_else(|| app.model.clone());
1103 let placeholder =
1104 crate::localization::thinking_translation_placeholder(
1105 app.ui_locale,
1106 )
1107 .to_string();
1108 let target_language =
1109 app.ui_locale.translation_target_name().to_string();
1110 tokio::spawn(async move {
1111 let translated = crate::tui::translation::translate_text(
1112 &original_thinking,
1113 &client,
1114 &translation_model,
1115 &target_language,
1116 )
1117 .await;
1118 let _ = tx.send(TranslationEvent::Thinking {
1119 placeholder,
1120 translated,
1121 });
1122 });
1123 } else {
1124 let placeholder =
1125 crate::localization::thinking_translation_placeholder(
1126 app.ui_locale,
1127 );
1128 streaming_thinking::replace_pending_translation(
1129 app,
1130 placeholder,
1131 original_thinking,
1132 );
1133 }
1134 } else if streaming_thinking::finalize_current(app) {
1135 transcript_batch_updated = true;
1136 }
1137 streaming_thinking::stash_reasoning_buffer_into_last_reasoning(app);
1138 stream_display_clock.reset();
1139 }
1140 EngineEvent::ToolCallStarted { id, name, input } => {
1141 app.pending_tool_uses
1142 .push((id.clone(), name.clone(), input.clone()));
1143 // Note this dispatch so the next sub-agent `Started`
1144 // mailbox envelope routes into the right card kind
1145 // (delegate vs fanout).
1146 if matches!(
1147 name.as_str(),
1148 "agent" | "rlm_open" | "rlm_eval" | "rlm" | "delegate"
1149 ) {
1150 app.pending_subagent_dispatch = Some(name.clone());
1151 if matches!(name.as_str(), "rlm_open" | "rlm_eval" | "rlm") {
1152 // New fanout invocation — children should
1153 // group under a fresh card, not the
1154 // previous fanout's leftover.
1155 app.last_fanout_card_index = None;
1156 }
1157 }
1158 handle_tool_call_started(app, &id, &name, &input);
1159 }
1160 // Liveness only. `record_turn_activity` above consumes the
1161 // pulse; it must not alter transcript or status copy.
1162 EngineEvent::ToolCallHeartbeat => {}
1163 EngineEvent::ToolCallComplete { id, name, result } => {
1164 if crate::tui::tool_routing::evidence_completion_should_be_ignored(
1165 app, &id, &result,
1166 ) {
1167 tracing::debug!(tool_id = %id, tool_name = %name, "ignored foreign or replayed evidence completion");
1168 continue;
1169 }
1170 if is_model_visible_tool_call(&id) {
1171 let tool_content = match &result {
1172 Ok(output) => sanitize_stream_chunk(
1173 &tool_result_content_for_api_message(app, &id, &name, output)
1174 .await,
1175 ),
1176 Err(err) => sanitize_stream_chunk(&format!("Error: {err}")),
1177 };
1178 app.api_messages.push(Message {
1179 role: "user".to_string(),
1180 content: vec![ContentBlock::ToolResult {
1181 tool_use_id: id.clone(),
1182 content: tool_content,
1183 is_error: None,
1184 content_blocks: None,
1185 }],
1186 });
1187 } else {
1188 app.pending_tool_uses
1189 .retain(|(tool_id, _, _)| tool_id != &id);
1190 }
1191 handle_tool_call_complete(app, &id, &name, &result);
1192 if crate::mcp::McpPool::is_mcp_tool(&name)
1193 && match &result {
1194 Ok(output) => !output.success,
1195 Err(_) => true,
1196 }
1197 {
1198 let _ = app.maybe_show_behavioral_tip(
1199 crate::tui::behavioral_tips::BehavioralTip::McpValidation,
1200 );
1201 }
1202
1203 if result.is_ok()
1204 && is_work_graph_mutation_tool(&name)
1205 && let Err(err) = persist_pending_work_checkpoint(app).await
1206 {
1207 tracing::warn!(
1208 tool = %name,
1209 error = %err,
1210 "Work Graph checkpoint was not enqueued; projections remain unpublished"
1211 );
1212 app.status_message = Some(format!(
1213 "Work update is pending: checkpoint could not be queued ({err})"
1214 ));
1215 }
1216
1217 // Immediately refresh the task panel sidebar when a
1218 // tool that changes task state completes, so the
1219 // Tasks panel stays in sync with tool execution
1220 // rather than waiting up to 2.5 s for the periodic
1221 // poll. Also merge shell jobs (#373).
1222 // Only tools that actually change durable tasks or
1223 // background shell jobs force a jobs-panel refresh.
1224 // Checklist/todo/plan tools drive the To-do panel,
1225 // which reads `app.todos` directly and repaints on the
1226 // normal redraw — no forced refresh needed (avoids the
1227 // old per-checklist Tasks-panel churn).
1228 if matches!(
1229 name.as_str(),
1230 "agent"
1231 | "task_shell_start"
1232 | "exec_shell"
1233 | "exec_shell_cancel"
1234 | "exec_shell_wait"
1235 | "task_cancel"
1236 // Unified durable-task tool (piagent phase B):
1237 // create/cancel actions mutate task state, so
1238 // any `tasks` completion refreshes the panel.
1239 | "tasks"
1240 ) {
1241 refresh_active_task_panel(app, &task_manager).await;
1242 last_task_refresh = Instant::now();
1243 }
1244 if matches!(name.as_str(), "agent") {
1245 subagent_list_refresh_requested = true;
1246 }
1247 }
1248 EngineEvent::TurnStarted { turn_id, .. } => {
1249 app.session.last_tool_request_snapshot = None;
1250 app.ocean_completion_started_at = None;
1251 app.ocean_receipt_settle_start = None;
1252 app.ocean_turn_history_start = app.history.len();
1253 app.suppress_stream_events_until_turn_complete = false;
1254 app.is_loading = true;
1255 app.offline_mode = false;
1256 app.turn_error_posted = false;
1257 app.lsp_repair = crate::tui::app::LspRepairState::default();
1258 app.prompt_suggestion = None;
1259 app.prompt_suggestion_gen
1260 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1261 app.dispatch_started_at = None;
1262 current_streaming_text.clear();
1263 app.streaming_output_token_estimate = 0;
1264 app.streaming_state.reset();
1265 app.streaming_message_index = None;
1266 app.streaming_thinking_active_entry = None;
1267 stream_display_clock.reset();
1268 let now = Instant::now();
1269 app.turn_started_at = Some(now);
1270 app.turn_last_activity_at = Some(now);
1271 app.session.last_output_throughput = None;
1272 app.streaming_output_token_estimate = 0;
1273 app.provider_wait_incident_logged = false;
1274 // Discoverability hint for users who don't know how
1275 // to interrupt a long-running turn (#1367). Only
1276 // surface when the status_message slot is empty so
1277 // we don't trample over a real transient message
1278 // (e.g. "/queue saved", "Selection copied"); the
1279 // hint then auto-clears as soon as anything else
1280 // updates the slot.
1281 if app.status_message.is_none() {
1282 app.status_message = Some("Press Esc or Ctrl+C to cancel".to_string());
1283 }
1284 app.runtime_turn_id = Some(turn_id);
1285 app.runtime_turn_status = Some("in_progress".to_string());
1286 app.turn_counter = app.turn_counter.saturating_add(1);
1287 app.reasoning_buffer.clear();
1288 app.reasoning_header = None;
1289 app.last_reasoning = None;
1290 app.pending_tool_uses.clear();
1291 last_status_frame = Instant::now();
1292 }
1293 EngineEvent::ToolRequestSnapshot { snapshot } => {
1294 app.session.last_tool_request_snapshot = Some(snapshot);
1295 }
1296 EngineEvent::RouteDispatched { .. } => {}
1297 EngineEvent::TurnComplete {
1298 usage,
1299 status,
1300 error,
1301 tool_catalog,
1302 base_url,
1303 } => {
1304 let completed_turn = app.active_turn.take();
1305 app.session.last_tool_catalog = tool_catalog;
1306 // The endpoint this turn's client actually used. Kept
1307 // separately from the mutable session/config surfaces
1308 // so the prompt-suggestion gate below can require it.
1309 let turn_actual_base_url = base_url.clone();
1310 app.session.last_base_url = base_url;
1311 let was_locally_cancelled = app.suppress_stream_events_until_turn_complete;
1312 app.suppress_stream_events_until_turn_complete = false;
1313 app.active_allowed_tools = None;
1314 if app.paused_quarry.is_none() {
1315 app.pausable = false;
1316 app.paused = false;
1317 }
1318 // Turn completion is an ordinary state transition.
1319 // Clearing all 7,900 cells after a long stream was the
1320 // visible end-of-turn flash in the rejected build.
1321 // Ratatui's diff is sufficient here; full repaints stay
1322 // reserved for real terminal boundary changes (resize,
1323 // focus recovery, theme, child-terminal return).
1324 // Finalize any in-flight tool group. Cancellation
1325 // marks still-running entries as Failed so the user
1326 // sees they were interrupted rather than the spinner
1327 // hanging forever.
1328 if matches!(
1329 status,
1330 crate::core::events::TurnOutcomeStatus::Interrupted
1331 | crate::core::events::TurnOutcomeStatus::Failed
1332 ) {
1333 app.finalize_active_cell_as_interrupted();
1334 // Also mark the streaming Assistant cell (if any)
1335 // so partial reasoning/text isn't left with a
1336 // permanent spinner. Idempotent with the
1337 // optimistic call in the Esc handler.
1338 app.finalize_streaming_assistant_as_interrupted();
1339 } else {
1340 app.flush_active_cell();
1341 }
1342 app.is_loading = false;
1343 app.dispatch_started_at = None;
1344 app.pending_provider_switch = None;
1345 app.offline_mode = false;
1346 app.streaming_state.reset();
1347 stream_display_clock.reset();
1348 if was_locally_cancelled {
1349 current_streaming_text.clear();
1350 }
1351 // Capture elapsed before clearing turn_started_at so
1352 // notifications can use the real wall-clock duration.
1353 let turn_elapsed =
1354 app.turn_started_at.map(|t| t.elapsed()).unwrap_or_default();
1355 app.turn_started_at = None;
1356 app.turn_last_activity_at = None;
1357 app.streaming_output_token_estimate = 0;
1358 // Roll the just-finished turn's elapsed time into the
1359 // cumulative session work-time (#448 follow-up). The
1360 // footer's `worked Nh Mm` chip reads this so the
1361 // label reflects actual model work, not idle
1362 // uptime since launch.
1363 app.cumulative_turn_duration =
1364 app.cumulative_turn_duration.saturating_add(turn_elapsed);
1365 // Stream lock applies per-turn; clear it so the next
1366 // turn's chunks pull the view down again until the
1367 // user opts out by scrolling up.
1368 app.user_scrolled_during_stream = false;
1369 app.runtime_turn_status = Some(match status {
1370 crate::core::events::TurnOutcomeStatus::Completed => {
1371 app.ocean_completion_started_at = Some(Instant::now());
1372 app.ocean_receipt_settle_start =
1373 Some(app.ocean_turn_history_start.min(app.history.len()));
1374 "completed".to_string()
1375 }
1376 crate::core::events::TurnOutcomeStatus::Interrupted => {
1377 app.ocean_completion_started_at = None;
1378 app.ocean_receipt_settle_start = None;
1379 "interrupted".to_string()
1380 }
1381 crate::core::events::TurnOutcomeStatus::Failed => {
1382 app.ocean_completion_started_at = None;
1383 app.ocean_receipt_settle_start = None;
1384 "failed".to_string()
1385 }
1386 });
1387 if matches!(
1388 status,
1389 crate::core::events::TurnOutcomeStatus::Interrupted
1390 | crate::core::events::TurnOutcomeStatus::Failed
1391 ) {
1392 subagent_list_refresh_requested = true;
1393 }
1394 crate::tui::notifications::clear_taskbar_progress();
1395 if status != crate::core::events::TurnOutcomeStatus::Completed {
1396 crate::retry_status::clear();
1397 crate::tui::notifications::stop_title_animation_quietly();
1398 }
1399 let turn_tokens = usage.input_tokens.saturating_add(usage.output_tokens);
1400 app.session.total_tokens =
1401 app.session.total_tokens.saturating_add(turn_tokens);
1402 app.session.total_conversation_tokens = app
1403 .session
1404 .total_conversation_tokens
1405 .saturating_add(turn_tokens);
1406 app.session.total_input_tokens = app
1407 .session
1408 .total_input_tokens
1409 .saturating_add(usage.input_tokens);
1410 app.session.total_output_tokens = app
1411 .session
1412 .total_output_tokens
1413 .saturating_add(usage.output_tokens);
1414 // Only accumulate cache telemetry when the provider
1415 // reported at least one cache class. Use pricing's
1416 // canonical mutually-exclusive hit/miss/write split so
1417 // cache writes are never counted again as misses.
1418 if usage.prompt_cache_hit_tokens.is_some()
1419 || usage.prompt_cache_miss_tokens.is_some()
1420 || usage.prompt_cache_write_tokens.is_some()
1421 {
1422 let classes = crate::pricing::token_usage_for_pricing(&usage);
1423 let hit_tokens = u32::try_from(classes.cache_read).unwrap_or(u32::MAX);
1424 let miss_tokens = u32::try_from(classes.input).unwrap_or(u32::MAX);
1425 let write_tokens =
1426 u32::try_from(classes.cache_write).unwrap_or(u32::MAX);
1427 app.session.total_cache_hit_tokens = app
1428 .session
1429 .total_cache_hit_tokens
1430 .saturating_add(hit_tokens);
1431 app.session.total_cache_miss_tokens = app
1432 .session
1433 .total_cache_miss_tokens
1434 .saturating_add(miss_tokens);
1435 app.session.total_cache_write_tokens = app
1436 .session
1437 .total_cache_write_tokens
1438 .saturating_add(write_tokens);
1439 }
1440 app.session.last_prompt_tokens = Some(usage.input_tokens);
1441 app.session.last_completion_tokens = Some(usage.output_tokens);
1442 app.session.last_output_throughput =
1443 TokenThroughput::new(u64::from(usage.output_tokens), turn_elapsed);
1444 app.session.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens;
1445 app.session.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens;
1446 app.session.last_reasoning_replay_tokens = usage.reasoning_replay_tokens;
1447 let (provider, provider_identity, model, auto_model) = completed_turn
1448 .as_ref()
1449 .and_then(|turn| turn.route.as_ref())
1450 .map(|route| {
1451 (
1452 Some(route.provider),
1453 Some(route.provider_identity.clone()),
1454 Some(route.model.clone()),
1455 route.auto_model,
1456 )
1457 })
1458 .unwrap_or((None, None, None, false));
1459 let effective_turn_provider = provider.unwrap_or(app.api_provider);
1460 let effective_turn_model = model
1461 .as_deref()
1462 .filter(|model| !model.trim().is_empty())
1463 .unwrap_or_else(|| {
1464 app.last_effective_model.as_deref().unwrap_or(&app.model)
1465 })
1466 .to_string();
1467 app.last_effective_provider = Some(effective_turn_provider);
1468 app.last_effective_provider_identity = provider_identity.clone();
1469 if completed_turn
1470 .as_ref()
1471 .and_then(|turn| turn.route.as_ref())
1472 .is_some_and(|route| route.auto_model)
1473 {
1474 app.last_auto_route_receipt = completed_turn
1475 .as_ref()
1476 .and_then(|turn| turn.auto_route_receipt.clone());
1477 } else if completed_turn
1478 .as_ref()
1479 .is_some_and(|turn| turn.route.is_some())
1480 {
1481 app.last_auto_route_receipt = None;
1482 }
1483 if status == crate::core::events::TurnOutcomeStatus::Completed {
1484 app.provider_health.record_success(
1485 config,
1486 effective_turn_provider,
1487 &effective_turn_model,
1488 );
1489 }
1490 if auto_model {
1491 app.last_effective_model = Some(effective_turn_model.clone());
1492 }
1493 // Price the turn exactly once. The same audit feeds the
1494 // session total, the `/cache` row, and the `/cost`
1495 // completeness counters, so those three surfaces can
1496 // never disagree about what was counted (#4318).
1497 let cost_audit = completed_turn
1498 .as_ref()
1499 .and_then(|turn| turn.route.as_ref())
1500 .and_then(crate::core::events::TurnRoute::cost_envelope)
1501 .map(|route| route.audit(&usage));
1502 app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {
1503 provider,
1504 provider_identity,
1505 model,
1506 auto_model,
1507 input_tokens: usage.input_tokens,
1508 output_tokens: usage.output_tokens,
1509 cache_hit_tokens: usage.prompt_cache_hit_tokens,
1510 cache_miss_tokens: usage.prompt_cache_miss_tokens,
1511 reasoning_replay_tokens: usage.reasoning_replay_tokens,
1512 cache_write_tokens: usage.prompt_cache_write_tokens,
1513 reasoning_tokens: usage.reasoning_tokens,
1514 cost_audit: cost_audit.clone(),
1515 recorded_at: Instant::now(),
1516 });
1517 if let Some(error) = error.as_deref() {
1518 // Only show "Turn failed:" in the composer status
1519 // area when an EngineEvent::Error has NOT already
1520 // posted the same message into the transcript.
1521 // Otherwise the error appears twice: once in a
1522 // HistoryCell and again as a redundant status line.
1523 if !app.turn_error_posted {
1524 app.status_message = Some(format!("Turn failed: {error}"));
1525 }
1526 }
1527
1528 // Update session cost, and record what the total does
1529 // *not* cover so `/cost` can stay honest about it.
1530 //
1531 // `cost_audit` above came from `cost_envelope()`, i.e.
1532 // the billing envelope stamped at the wire boundary
1533 // and classified from this turn's frozen receipt. It
1534 // is `None` for a route that was never dispatched, and
1535 // a route whose receipt named no product classified as
1536 // Unknown — either way nothing accrues. A `/provider`
1537 // or custom-table switch since dispatch cannot
1538 // retro-bill this turn onto another route, because no
1539 // ambient `Config` is read here at all.
1540 let turn_cost = cost_audit.as_ref().and_then(|audit| audit.estimate);
1541 if let Some(audit) = cost_audit.as_ref() {
1542 app.record_turn_cost_audit(audit);
1543 // Redacted receipt for the route this money came
1544 // from: provider identity, wire model, billing
1545 // surface, and the endpoint *fingerprint* — never the
1546 // URL or any credential.
1547 if let Some(receipt) =
1548 completed_turn_cost_route_receipt(completed_turn.as_ref(), audit)
1549 {
1550 app.record_turn_cost_route_receipt(receipt);
1551 }
1552 }
1553 if let Some(cost) = turn_cost {
1554 app.accrue_session_cost_estimate(cost);
1555 }
1556
1557 // Emit OSC 9 / BEL desktop notification for long turns, and
1558 // always stop the title animation that began on TurnStarted.
1559 if status == crate::core::events::TurnOutcomeStatus::Completed {
1560 if let Some((method, threshold, include_summary)) =
1561 notifications::settings(config)
1562 {
1563 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
1564 let payload = notifications::completed_turn_payload(
1565 app,
1566 &current_streaming_text,
1567 include_summary,
1568 turn_elapsed,
1569 turn_cost,
1570 );
1571 crate::tui::notifications::notify_done(
1572 method,
1573 in_tmux,
1574 &payload,
1575 threshold,
1576 turn_elapsed,
1577 );
1578 crate::tui::notifications::stop_title_animation();
1579 } else {
1580 crate::tui::notifications::stop_title_animation_quietly();
1581 }
1582 }
1583
1584 // Generate ghost-text follow-up suggestion asynchronously.
1585 //
1586 // Privacy (#4404/#4411): the request is anchored to the
1587 // completed turn's route snapshot and to the receipt the
1588 // engine minted from the client it installed for that
1589 // turn — never to live UI selection, and never to
1590 // authority re-derived from mutable config.
1591 // Conversation context is only ever sent to that exact
1592 // endpoint with that exact credential. Providers whose
1593 // wire shape this helper does not speak produce no
1594 // background request at all — and never reach another
1595 // provider's credentials while deciding that.
1596 let suggestion_launch = completed_turn
1597 .as_ref()
1598 .and_then(|turn| {
1599 let route = turn.route.as_ref()?;
1600 let authority = turn.suggestion_authority.as_ref()?;
1601 Some(crate::tui::prompt_suggestion::SuggestionRouteSnapshot {
1602 provider: route.provider,
1603 provider_identity: route.provider_identity.as_str(),
1604 model: route.model.as_str(),
1605 authority,
1606 actual_base_url: turn_actual_base_url.as_deref(),
1607 })
1608 })
1609 .and_then(|snapshot| {
1610 crate::tui::prompt_suggestion::plan_suggestion_launch_with_config(
1611 config,
1612 status == crate::core::events::TurnOutcomeStatus::Completed,
1613 config.prompt_suggestion_enabled(),
1614 app.api_messages.len(),
1615 Some(snapshot),
1616 )
1617 });
1618 if let Some(launch) = suggestion_launch {
1619 let suggestion_cell = app.prompt_suggestion_cell.clone();
1620 let messages: Vec<crate::models::Message> = app.api_messages.clone();
1621 let gen_token = app
1622 .prompt_suggestion_gen
1623 .load(std::sync::atomic::Ordering::Relaxed);
1624 tokio::spawn(async move {
1625 let summary =
1626 crate::tui::prompt_suggestion::summarize_recent_messages(
1627 &messages, 8,
1628 );
1629 if let Some(suggestion) =
1630 crate::tui::prompt_suggestion::generate_suggestion(
1631 &launch.api_key,
1632 &launch.base_url,
1633 &launch.model,
1634 &summary,
1635 )
1636 .await
1637 && let Ok(mut guard) = suggestion_cell.lock()
1638 {
1639 *guard = Some((gen_token, suggestion));
1640 }
1641 });
1642 }
1643
1644 // Generate post-turn receipt for completed turns.
1645 // Also push a persistent status toast so users always
1646 // see the outcome in the footer (not just the 8-second
1647 // composer receipt), regardless of notification method
1648 // or platform.
1649 if status == crate::core::events::TurnOutcomeStatus::Completed {
1650 let tool_count = app.tool_evidence.len();
1651 let mut receipt = "✓ turn completed".to_string();
1652 if tool_count > 0 {
1653 let _ = write!(receipt, " · {tool_count} tool(s) used");
1654 for evidence in &app.tool_evidence {
1655 let summary = crate::utils::truncate_with_ellipsis(
1656 &evidence.summary,
1657 60,
1658 "…",
1659 );
1660 let _ = write!(receipt, " · {}: {summary}", evidence.tool_name);
1661 }
1662 }
1663 app.set_receipt_text(receipt.clone());
1664 // Mirror as a persistent status toast (10s TTL).
1665 // The footer bar visibly shows status toasts,
1666 // which is more glanceable than the composer
1667 // border receipt alone.
1668 app.push_status_toast(
1669 receipt,
1670 crate::tui::app::StatusToastLevel::Info,
1671 Some(10_000),
1672 );
1673 }
1674
1675 // Auto-save completed turn and clear crash checkpoint.
1676 // Offloaded to the persistence actor so the UI
1677 // stays responsive.
1678 let mut completed_snapshot_id: Option<String> = None;
1679 if let Ok(manager) = SessionManager::default_location()
1680 && let Ok(session) = build_session_snapshot(app, &manager)
1681 {
1682 app.current_session_id = Some(session.metadata.id.clone());
1683 completed_snapshot_id = Some(session.metadata.id.clone());
1684 let queued = persistence_actor::try_persist(
1685 PersistRequest::SessionSnapshot(session),
1686 );
1687 if queued {
1688 if let Err(err) = publish_pending_work_projection(app).await {
1689 tracing::warn!(
1690 error = %err,
1691 "completed-turn Work projections remain unpublished"
1692 );
1693 app.status_message = Some(format!(
1694 "Session queued, but Work views could not publish ({err})"
1695 ));
1696 }
1697 } else if app
1698 .runtime_services
1699 .work
1700 .as_ref()
1701 .is_some_and(|work| work.has_pending_publish())
1702 {
1703 app.status_message = Some(
1704 "Work update is pending: session snapshot could not be queued"
1705 .to_string(),
1706 );
1707 }
1708 }
1709 if let Some(session_id) = completed_snapshot_id {
1710 persistence_actor::persist(PersistRequest::ClearCheckpoint {
1711 session_id,
1712 });
1713 }
1714
1715 // Refresh DeepSeek account balance after each completed
1716 // turn so the footer balance chip stays current without
1717 // adding latency to any request path.
1718 let balance_cooldown_expired = app
1719 .last_balance_fetch
1720 .is_none_or(|t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
1721 if balance_cooldown_expired && should_fetch_deepseek_balance(app) {
1722 let cell = app.balance_cell.clone();
1723 let api_key = config.deepseek_api_key().unwrap_or_default();
1724 let base_url = config.deepseek_base_url();
1725 if !api_key.is_empty() {
1726 app.last_balance_fetch = Some(Instant::now());
1727 tokio::spawn(async move {
1728 if let Some(info) =
1729 fetch_deepseek_balance(&api_key, &base_url).await
1730 && let Ok(mut guard) = cell.lock()
1731 {
1732 *guard = Some(info);
1733 }
1734 });
1735 }
1736 }
1737
1738 // Legacy pending-steer recovery. Current keyboard
1739 // handling keeps Esc as cancel-only, but older saved
1740 // state may still carry pending steers.
1741 if status == crate::core::events::TurnOutcomeStatus::Interrupted
1742 && app.submit_pending_steers_after_interrupt
1743 {
1744 if let Some(merged) = merge_pending_steers(&mut *app) {
1745 queued_to_send = Some(merged);
1746 }
1747 } else if status == crate::core::events::TurnOutcomeStatus::Failed
1748 && !app.pending_steers.is_empty()
1749 {
1750 // Hard-fail recovery: if the engine failed before
1751 // a clean Interrupted landed, demote pending
1752 // steers to the visible queue so they're not
1753 // silently lost. User can /queue to inspect.
1754 for msg in app.drain_pending_steers() {
1755 app.queue_message(msg);
1756 }
1757 }
1758
1759 // Counted here, at the caller, never inside
1760 // `execute_turn_end_observer_hook`: that function's
1761 // first statement returns early for anyone with no
1762 // TurnEnd hooks, and the natural future optimization
1763 // hoists that check up to this call site — which would
1764 // silently zero the counter for every user who does
1765 // not use hooks.
1766 {
1767 let telemetry = codewhale_telemetry::session_counters();
1768 telemetry.bump(codewhale_telemetry::Counter::Turns);
1769 telemetry.observe_turn_secs(turn_elapsed.as_secs());
1770 }
1771
1772 if let Err(error) = execute_turn_end_observer_hook(
1773 app,
1774 completed_turn.as_ref(),
1775 &usage,
1776 completed_turn
1777 .as_ref()
1778 .and_then(|turn| turn.route.as_ref())
1779 .and_then(|route| route.billing.as_ref())
1780 .and_then(|billing| billing.billing_surface.as_deref()),
1781 turn_elapsed,
1782 error.as_deref(),
1783 ) {
1784 surface_observer_hook_submission_failure(app, error);
1785 }
1786
1787 if queued_to_send.is_none() {
1788 queued_to_send = app.pop_queued_message();
1789 }
1790 }
1791 EngineEvent::Error {
1792 envelope,
1793 recoverable: _,
1794 } => {
1795 let provider_before_error = app.api_provider;
1796 let identity_before_error = ProviderIdentity {
1797 provider: provider_before_error,
1798 key: app.provider_identity_for_persistence().to_string(),
1799 exact_id: app.provider_id_for_persistence().map(str::to_string),
1800 };
1801 let fallback_chain_before_error = app.provider_chain.clone();
1802 let (health_provider, health_model) =
1803 error_health_route(app, provider_before_error);
1804 app.provider_health.record_failure(
1805 config,
1806 health_provider,
1807 &health_model,
1808 &envelope,
1809 );
1810 let rollback_after_auth_failure =
1811 matches!(
1812 envelope.category,
1813 crate::error_taxonomy::ErrorCategory::Authentication
1814 ) && app.pending_provider_switch.is_some();
1815 apply_engine_error_to_app(app, envelope);
1816 if app.api_provider != provider_before_error && app.is_fallback_active() {
1817 // Several queued errors can be drained together.
1818 // The first route remains the rollback authority;
1819 // later chain advances must not overwrite it with
1820 // an enum/key pair from the half-applied fallback.
1821 fallback_after_engine_error.get_or_insert(ProviderFallbackRollback {
1822 identity: identity_before_error,
1823 chain: fallback_chain_before_error,
1824 });
1825 }
1826 if rollback_after_auth_failure
1827 && let Some(rollback_warning) =
1828 rollback_provider_after_auth_failure(app, config)
1829 {
1830 respawn_after_provider_rollback = Some(rollback_warning);
1831 }
1832 }
1833 EngineEvent::Status { message } => {
1834 app.status_message = Some(message);
1835 }
1836 EngineEvent::RequestManifestReady { rendered } => {
1837 // Typed manifest text, or the explicitly requested
1838 // base-prompt-only disclosure. Rendered as a system cell.
1839 app.add_message(HistoryCell::System { content: rendered });
1840 transcript_batch_updated = true;
1841 }
1842 EngineEvent::GoalUpdated { snapshot } => {
1843 if apply_goal_snapshot_to_app(app, &snapshot) {
1844 transcript_batch_updated = true;
1845 }
1846 }
1847 EngineEvent::SessionUpdated {
1848 session_id,
1849 messages,
1850 system_prompt,
1851 model,
1852 workspace,
1853 } => {
1854 app.current_session_id = Some(session_id.clone());
1855 app.context_token_cache.borrow_mut().clear();
1856 app.api_messages = messages;
1857 app.system_prompt = system_prompt;
1858 if app.auto_model {
1859 app.last_effective_model = Some(model);
1860 } else {
1861 app.set_model_selection(model);
1862 }
1863 app.update_model_compaction_budget();
1864 if app.workspace != workspace {
1865 apply_workspace_runtime_state(app, config, workspace);
1866 }
1867 if (app.is_loading || app.is_compacting || app.is_purging)
1868 && let Ok(manager) = SessionManager::default_location()
1869 {
1870 if let Ok(session) = build_session_snapshot(app, &manager) {
1871 app.session_title = Some(session.metadata.title.clone());
1872 // Pin the id so every checkpoint of this
1873 // session lands in the same per-session file
1874 // and the eventual clear targets it.
1875 if app.current_session_id.is_none() {
1876 app.current_session_id = Some(session.metadata.id.clone());
1877 }
1878 if let Err(err) = persist_with_pending_work_boundary(
1879 app,
1880 PersistRequest::SaveCheckpoint { session },
1881 ) {
1882 app.status_message = Some(format!(
1883 "Work update is pending: checkpoint could not be queued ({err})"
1884 ));
1885 }
1886 }
1887 } else if app.session_title.is_none() {
1888 // Never synchronously reload the growing session
1889 // JSON on the event-loop task just to recover a
1890 // title. The in-memory metadata cache is authoritative.
1891 let cached = app
1892 .current_session_metadata
1893 .as_ref()
1894 .filter(|metadata| metadata.id == session_id)
1895 .map(|metadata| metadata.title.clone());
1896 app.session_title =
1897 cached.or_else(|| derive_session_title(&app.api_messages));
1898 }
1899 }
1900 EngineEvent::CompactionStarted { message, .. } => {
1901 app.is_compacting = true;
1902 app.status_message = Some(message);
1903 }
1904 EngineEvent::CompactionCompleted { message, .. } => {
1905 app.is_compacting = false;
1906 app.status_message = Some(message);
1907 }
1908 EngineEvent::CompactionFailed { message, .. } => {
1909 app.is_compacting = false;
1910 app.status_message = Some(message);
1911 }
1912 EngineEvent::PurgeStarted { message } => {
1913 app.is_purging = true;
1914 app.status_message = Some(message);
1915 }
1916 EngineEvent::PurgeCompleted { message, .. } => {
1917 app.is_purging = false;
1918 app.status_message = Some(message);
1919 }
1920 EngineEvent::PurgeFailed { message } => {
1921 app.is_purging = false;
1922 app.status_message = Some(message);
1923 }
1924 EngineEvent::PrefixCacheChange {
1925 description,
1926 stability_pct,
1927 changed,
1928 pinned_combined_hash,
1929 ..
1930 } => {
1931 app.prefix_checks_total = app.prefix_checks_total.saturating_add(1);
1932 app.prefix_stability_pct = Some(stability_pct);
1933 app.last_pinned_prefix_hash =
1934 (!pinned_combined_hash.is_empty()).then_some(pinned_combined_hash);
1935 if changed {
1936 app.prefix_change_count = app.prefix_change_count.saturating_add(1);
1937 if !description.is_empty() {
1938 app.last_prefix_change_desc = Some(description);
1939 }
1940 }
1941 }
1942 EngineEvent::LspRepairUpdate {
1943 diagnostics_found,
1944 files,
1945 injected,
1946 } => {
1947 let repair = &mut app.lsp_repair;
1948 repair.diagnostics_found =
1949 repair.diagnostics_found.saturating_add(diagnostics_found);
1950 repair.files_touched = repair.files_touched.saturating_add(files);
1951 if injected {
1952 // Injection itself is not a repair attempt — the model
1953 // has only been shown the diagnostics so far (#4107).
1954 repair.injected = true;
1955 if repair.latest == "unavailable" || repair.latest.is_empty() {
1956 repair.latest = "unknown";
1957 }
1958 } else if repair.injected {
1959 // Diagnostics after a prior injection imply the model
1960 // edited again (a repair attempt). Zero findings = resolved.
1961 repair.repair_attempted = true;
1962 repair.latest = if diagnostics_found == 0 {
1963 "resolved"
1964 } else {
1965 "still_failing"
1966 };
1967 } else {
1968 repair.latest = "unknown";
1969 }
1970 }
1971 EngineEvent::PauseEvents { ack } => {
1972 if !event_broker.is_paused() {
1973 pause_terminal(
1974 terminal,
1975 app.use_alt_screen,
1976 app.use_mouse_capture,
1977 app.use_bracketed_paste,
1978 )?;
1979 event_broker.pause_events();
1980 terminal_paused_at = Some(Instant::now());
1981 }
1982 if let Some(ack) = ack {
1983 ack.notify_one();
1984 }
1985 }
1986 EngineEvent::ResumeEvents => {
1987 if event_broker.is_paused() {
1988 resume_terminal(
1989 terminal,
1990 app.use_alt_screen,
1991 app.use_mouse_capture,
1992 app.use_bracketed_paste,
1993 app.synchronized_output_enabled,
1994 )?;
1995 event_broker.resume_events();
1996 terminal_paused_at = None;
1997 }
1998 }
1999 EngineEvent::AgentSpawned {
2000 id,
2001 prompt,
2002 parent_run_id,
2003 spawn_depth,
2004 } => {
2005 let prompt_summary = bound_agent_activity_text(&prompt);
2006 app.agent_progress
2007 .insert(id.clone(), format!("starting: {prompt_summary}"));
2008 let meta = app.agent_progress_meta.entry(id.clone()).or_default();
2009 meta.parent_run_id = parent_run_id;
2010 meta.spawn_depth = spawn_depth;
2011 meta.current_activity = Some(AgentCurrentActivity::bounded(
2012 AgentCurrentActivityStatus::Starting,
2013 Some(prompt_summary.clone()),
2014 None,
2015 None,
2016 ));
2017 meta.current_tool = None;
2018 if app.agent_activity_started_at.is_none() {
2019 app.agent_activity_started_at = Some(Instant::now());
2020 }
2021 // #3030: Assign a stable user-facing label for this
2022 // agent and keep the raw id out of the status bar.
2023 apply_agent_spawned_status_and_observer(app, &id, &prompt, &prompt_summary);
2024 subagent_list_refresh_requested = true;
2025 }
2026 EngineEvent::AgentProgress {
2027 id,
2028 status,
2029 activity,
2030 parent_run_id,
2031 spawn_depth,
2032 } => {
2033 let display = bound_agent_activity_text(&friendly_subagent_progress(
2034 app, &id, &status,
2035 ));
2036 if is_noisy_subagent_progress(&status) {
2037 app.agent_progress
2038 .entry(id.clone())
2039 .or_insert_with(|| display.clone());
2040 } else {
2041 app.agent_progress.insert(id.clone(), display.clone());
2042 }
2043 let meta = app.agent_progress_meta.entry(id.clone()).or_default();
2044 meta.parent_run_id = parent_run_id;
2045 meta.spawn_depth = spawn_depth;
2046 let current_tool = activity
2047 .tool_name
2048 .as_deref()
2049 .map(subagent_progress_tool_display_name)
2050 .map(str::to_string);
2051 meta.current_activity = Some(AgentCurrentActivity::bounded(
2052 activity.worker_status.into(),
2053 Some(display.clone()),
2054 current_tool.clone(),
2055 activity.step,
2056 ));
2057 meta.current_tool = current_tool;
2058 if app.agent_activity_started_at.is_none() {
2059 app.agent_activity_started_at = Some(Instant::now());
2060 }
2061 // #3030: progress can arrive before AgentSpawned is
2062 // observed — assign the stable label on first sight.
2063 let label = app.ensure_agent_label(&id);
2064 app.status_message = Some(format!("{label}: {display}"));
2065 // A progress-first agent (its AgentSpawned was dropped
2066 // under channel pressure) exists only in agent_progress
2067 // until a ListSubAgents refresh promotes it into
2068 // subagent_cache. Request that refresh like the
2069 // AgentSpawned arm does, so the sidebar row survives
2070 // reconciliation instead of flickering out.
2071 if !app.subagent_cache.iter().any(|agent| agent.agent_id == id) {
2072 subagent_list_refresh_requested = true;
2073 }
2074 // #3033: Throttle redraws from rapid AgentProgress events.
2075 // When 4+ sub-agents are running concurrently, each firing
2076 // progress events, the per-event `needs_redraw = true` saturates
2077 // the render loop and starves terminal input. Limit
2078 // progress-driven repaints to at most one per 100ms; the
2079 // status-animation timer (80ms cadence) provides a guaranteed
2080 // floor for sidebar updates. Data is still recorded immediately;
2081 // the sidebar picks it up on the next permitted redraw.
2082 if !agent_progress_redraw_permitted_for_drain(
2083 &mut app.last_agent_progress_redraw,
2084 &mut progress_redraw_agents,
2085 &id,
2086 Instant::now(),
2087 ) {
2088 // Restore the pre-event accumulator value: a
2089 // throttled progress event contributes no redraw of
2090 // its own, but earlier events' redraws survive.
2091 received_engine_event = redraw_requested_before_event;
2092 }
2093 }
2094 EngineEvent::AgentComplete { id, result } => {
2095 let subagent_elapsed = app
2096 .agent_activity_started_at
2097 .or(app.turn_started_at)
2098 .map(|started| started.elapsed())
2099 .unwrap_or_default();
2100 let has_other_running_subagents =
2101 app.agent_progress.keys().any(|agent_id| agent_id != &id)
2102 || app.subagent_cache.iter().any(|agent| {
2103 agent.agent_id != id
2104 && matches!(agent.status, SubAgentStatus::Running)
2105 });
2106 app.agent_progress.remove(&id);
2107 let terminal_status = subagent_status_from_completion_result(&result);
2108 let terminal_verb = subagent_terminal_verb(&terminal_status);
2109 apply_subagent_terminal_projection(
2110 app,
2111 &id,
2112 terminal_status.clone(),
2113 Some(bound_agent_activity_text(&result)),
2114 );
2115 // #3030: stable label with raw-id fallback.
2116 apply_agent_complete_status_and_observer(app, &id, &result, terminal_verb);
2117 if let Some(failure) = subagent_failure_notice(&result) {
2118 let message_id =
2119 if matches!(terminal_status, SubAgentStatus::BudgetExhausted) {
2120 MessageId::NotificationSubagentBudgetExhausted
2121 } else {
2122 MessageId::NotificationSubagentFailed
2123 };
2124 app.set_sticky_status(
2125 format!("{} · {failure}", app.tr(message_id)),
2126 StatusToastLevel::Error,
2127 None,
2128 );
2129 }
2130 let should_recapture_terminal =
2131 !has_other_running_subagents && app.use_alt_screen;
2132 let subagent_notification_mode =
2133 config.notifications_config().subagent_completion;
2134 let workflow_tool_running = workflow_tool_is_running(app);
2135 if should_notify_subagent_completion(
2136 subagent_notification_mode,
2137 has_other_running_subagents,
2138 workflow_tool_running,
2139 ) && let Some((method, threshold, include_summary)) =
2140 notifications::settings(config)
2141 {
2142 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
2143 let payload = notifications::subagent_terminal_payload(
2144 app.ui_locale,
2145 &id,
2146 &result,
2147 &terminal_status,
2148 include_summary,
2149 subagent_elapsed,
2150 );
2151 crate::tui::notifications::notify_done(
2152 method,
2153 in_tmux,
2154 &payload,
2155 threshold,
2156 subagent_elapsed,
2157 );
2158 }
2159 if should_recapture_terminal && event_broker.is_paused() {
2160 resume_terminal(
2161 terminal,
2162 app.use_alt_screen,
2163 app.use_mouse_capture,
2164 app.use_bracketed_paste,
2165 app.synchronized_output_enabled,
2166 )?;
2167 event_broker.resume_events();
2168 terminal_paused_at = None;
2169 app.needs_redraw = true;
2170 }
2171 subagent_list_refresh_requested = true;
2172 }
2173 EngineEvent::AgentList {
2174 agents,
2175 coordination,
2176 } => {
2177 let mut sorted = agents.clone();
2178 sort_subagents_in_place(&mut sorted);
2179 sorted.retain(|a| !a.from_prior_session);
2180 app.subagent_cache = sorted.clone();
2181 apply_coordination_detail_projection(app, coordination);
2182 reconcile_subagent_activity_state(app);
2183 let view_agents = subagent_view_agents(app, &app.subagent_cache);
2184 if app.view_stack.update_subagents(&view_agents) {
2185 app.status_message =
2186 Some(format!("Fleet workers: {} total", view_agents.len()));
2187 }
2188 // Individual spawn/complete events already log to history;
2189 // full list available via /agents command.
2190 }
2191 EngineEvent::SubAgentMailbox {
2192 turn_id,
2193 seq,
2194 message,
2195 } => {
2196 let should_refresh_subagents =
2197 subagent_message_refreshes_workspace_context(&message);
2198 let updated_transcript =
2199 handle_subagent_mailbox_for_turn(app, &turn_id, seq, &message);
2200 if let Some((agent_id, status, result)) =
2201 subagent_terminal_projection_from_mailbox(&message)
2202 {
2203 apply_subagent_terminal_projection(app, agent_id, status, result);
2204 subagent_list_refresh_requested = true;
2205 }
2206 if should_refresh_subagents {
2207 subagent_list_refresh_requested = true;
2208 }
2209 if updated_transcript {
2210 transcript_batch_updated = true;
2211 } else if !should_refresh_subagents
2212 && matches!(
2213 message,
2214 crate::tools::subagent::MailboxMessage::Progress { .. }
2215 )
2216 {
2217 // Progress mailbox envelopes mirror AgentProgress.
2218 // When the card state did not visibly change, do
2219 // not let the duplicate envelope bypass the
2220 // AgentProgress redraw throttle.
2221 received_engine_event = redraw_requested_before_event;
2222 }
2223 }
2224 EngineEvent::WorkflowUi { run_id, event } => {
2225 // #4122: live typed workflow events → panel + history card.
2226 apply_workflow_ui_event(app, &run_id, &event);
2227 // #4095 residual: budget_updated is high-frequency under
2228 // multi-agent fan-out. Data is already applied; pace the
2229 // repaint like AgentProgress so the panel does not churn.
2230 let is_budget = event
2231 .get("type")
2232 .and_then(|v| v.as_str())
2233 .is_some_and(|t| t == "budget_updated");
2234 if is_budget {
2235 if workflow_budget_redraw_permitted(
2236 &mut app.last_workflow_budget_redraw,
2237 Instant::now(),
2238 ) {
2239 app.needs_redraw = true;
2240 } else {
2241 received_engine_event = redraw_requested_before_event;
2242 }
2243 }
2244 transcript_batch_updated = true;
2245 }
2246 EngineEvent::ApprovalRequired {
2247 id,
2248 tool_name,
2249 description,
2250 input,
2251 approval_key,
2252 approval_grouping_key,
2253 intent_summary,
2254 approval_force_prompt,
2255 } => {
2256 // A count and nothing else. The tool name, the
2257 // description, the input, and the matched rule are all
2258 // user- or model-authored strings.
2259 codewhale_telemetry::session_counters()
2260 .bump(codewhale_telemetry::Counter::ApprovalModalShown);
2261 if app.remote_control.blocks_local_input() {
2262 let gate = app.remote_control.record_remote_approval(
2263 &id,
2264 &tool_name,
2265 &description,
2266 &input,
2267 &approval_key,
2268 intent_summary.as_deref(),
2269 );
2270 app.status_message = Some(format!(
2271 "Remote approval required for '{tool_name}' ({gate}); decide in the web session."
2272 ));
2273 app.sticky_status = Some(StatusToast::new(
2274 format!(
2275 "REMOTE CONTROL · approval waiting in web · {tool_name} · /rc stop"
2276 ),
2277 StatusToastLevel::Warning,
2278 None,
2279 ));
2280 continue;
2281 }
2282 use crate::core::authority::ApprovalRequestDisposition;
2283 // One disposition path for every ApprovalRequired (#4412):
2284 // session denial, Full Access policy hold, session/FA
2285 // auto-approve, Never posture, or modal prompt.
2286 match resolve_ui_approval_disposition(
2287 app,
2288 &tool_name,
2289 &approval_grouping_key,
2290 &approval_key,
2291 approval_force_prompt,
2292 ) {
2293 ApprovalRequestDisposition::AutoDenySessionDenied => {
2294 // The user already denied a matching approval key
2295 // during this process; auto-deny so the
2296 // model's retry loop doesn't keep re-prompting
2297 // (#360).
2298 auto_deny_session_approval(
2299 app,
2300 &engine_handle,
2301 &id,
2302 &tool_name,
2303 &approval_key,
2304 )
2305 .await;
2306 }
2307 ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold => {
2308 log_sensitive_event(
2309 "tool.approval.auto_deny_full_access_policy",
2310 serde_json::json!({
2311 "tool_name": tool_name,
2312 "session_id": app.current_session_id,
2313 "mode": app.mode.label(),
2314 }),
2315 );
2316 let _ = engine_handle.deny_tool_call(id.clone()).await;
2317 let notice = app
2318 .tr(MessageId::ApprovalFullAccessPolicyBlocked)
2319 .replace("{tool}", &tool_name);
2320 app.push_status_toast(
2321 notice,
2322 StatusToastLevel::Warning,
2323 Some(12_000),
2324 );
2325 }
2326 ApprovalRequestDisposition::AutoApprove => {
2327 log_sensitive_event(
2328 "tool.approval.auto_approve_session",
2329 serde_json::json!({
2330 "tool_name": tool_name,
2331 "approval_key": approval_key,
2332 "session_id": app.current_session_id,
2333 "mode": app.mode.label(),
2334 }),
2335 );
2336 let _ = engine_handle.approve_tool_call(id.clone()).await;
2337 }
2338 ApprovalRequestDisposition::AutoDenyAutoReview => {
2339 log_sensitive_event(
2340 "tool.approval.auto_deny_auto_review",
2341 serde_json::json!({
2342 "tool_name": tool_name,
2343 "session_id": app.current_session_id,
2344 "mode": app.mode.label(),
2345 }),
2346 );
2347 let _ = engine_handle.deny_tool_call(id.clone()).await;
2348 app.status_message = Some(format!(
2349 "Auto-Review held tool '{tool_name}' without pausing"
2350 ));
2351 }
2352 ApprovalRequestDisposition::AutoDenyNeverPosture => {
2353 log_sensitive_event(
2354 "tool.approval.auto_deny",
2355 serde_json::json!({
2356 "tool_name": tool_name,
2357 "session_id": app.current_session_id,
2358 "mode": app.mode.label(),
2359 }),
2360 );
2361 let _ = engine_handle.deny_tool_call(id.clone()).await;
2362 app.status_message = Some(format!(
2363 "Blocked tool '{tool_name}' (approval_mode=never)"
2364 ));
2365 }
2366 ApprovalRequestDisposition::Prompt => {
2367 let tool_input = input;
2368
2369 push_approval_request_view(
2370 app,
2371 &id,
2372 &tool_name,
2373 &description,
2374 &tool_input,
2375 &approval_key,
2376 intent_summary.as_deref(),
2377 );
2378 log_sensitive_event(
2379 "tool.approval.prompted",
2380 serde_json::json!({
2381 "tool_name": tool_name,
2382 "description": description,
2383 "session_id": app.current_session_id,
2384 "mode": app.mode.label(),
2385 }),
2386 );
2387 if let Some((method, _, _)) =
2388 crate::tui::notifications::settings(config)
2389 {
2390 let in_tmux =
2391 std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
2392 // #4834: the tool *description* is the
2393 // pending command. It stays in the
2394 // terminal, where the user can read it
2395 // in context; the banner names only the
2396 // tool. Copy is centralized (#5041) so
2397 // the action-first phrasing is tested.
2398 let payload =
2399 crate::tui::notifications::approval_needed_payload(
2400 &tool_name,
2401 );
2402 crate::tui::notifications::notify_done(
2403 method,
2404 in_tmux,
2405 &payload,
2406 Duration::ZERO,
2407 Duration::ZERO,
2408 );
2409 }
2410 app.status_message = Some(format!(
2411 "Approval required for '{tool_name}': {description}"
2412 ));
2413 }
2414 }
2415 }
2416 EngineEvent::UserInputRequired { id, request } => {
2417 if app.remote_control.blocks_local_input() {
2418 // Remote-control v1 deliberately admits only prompts, approval
2419 // decisions, and run control. Do not leak a second controller
2420 // through a local structured-question modal.
2421 log_sensitive_event(
2422 "tool.user_input.cancelled_remote_control",
2423 serde_json::json!({
2424 "tool_id": id.clone(),
2425 "session_id": app.current_session_id,
2426 }),
2427 );
2428 let _ = engine_handle.cancel_user_input(id).await;
2429 app.pending_user_input_prompt = None;
2430 let notice = "A structured question was cancelled because the web owns input; ask it as a normal web prompt instead.".to_string();
2431 app.push_status_toast(
2432 notice.clone(),
2433 StatusToastLevel::Warning,
2434 Some(8_000),
2435 );
2436 app.status_message = Some(notice);
2437 } else if should_suppress_user_input_prompt(app) {
2438 // A question may have been planned just before the
2439 // user switched to Auto-Review. Cancel the stale
2440 // request instead of opening a modal under an Auto
2441 // header; the tool result tells the model to keep
2442 // moving without inventing a user choice.
2443 log_sensitive_event(
2444 "tool.user_input.auto_cancelled_auto_review",
2445 serde_json::json!({
2446 "tool_id": id.clone(),
2447 "session_id": app.current_session_id,
2448 }),
2449 );
2450 let _ = engine_handle.cancel_user_input(id).await;
2451 app.pending_user_input_prompt = None;
2452 let notice = app.tr(MessageId::AutoReviewQuestionSkipped).into_owned();
2453 app.push_status_toast(notice, StatusToastLevel::Info, Some(6_000));
2454 } else {
2455 app.pending_user_input_prompt = Some((id.clone(), request.clone()));
2456 app.view_stack.push(UserInputView::new(id.clone(), request));
2457 if let Some((method, _, _)) =
2458 crate::tui::notifications::settings(config)
2459 {
2460 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
2461 let payload = crate::tui::notifications::input_needed_payload();
2462 crate::tui::notifications::notify_done(
2463 method,
2464 in_tmux,
2465 &payload,
2466 Duration::ZERO,
2467 Duration::ZERO,
2468 );
2469 }
2470 app.status_message = Some(
2471 "Action required: answer the popup with 1-4, arrows, or Enter"
2472 .to_string(),
2473 );
2474 }
2475 }
2476 EngineEvent::ElevationRequired {
2477 tool_id,
2478 tool_name,
2479 command,
2480 denial_reason,
2481 blocked_network,
2482 blocked_write,
2483 } => {
2484 // Auto-approved modes may retry denied tools without another prompt.
2485 if app_auto_approve_enabled(app) {
2486 log_sensitive_event(
2487 "tool.sandbox.auto_elevate",
2488 serde_json::json!({
2489 "tool_name": tool_name,
2490 "tool_id": tool_id,
2491 "reason": denial_reason,
2492 "session_id": app.current_session_id,
2493 }),
2494 );
2495 app.add_message(HistoryCell::System {
2496 content: format!(
2497 "Sandbox denied {tool_name}: {denial_reason} - auto-elevating to full access"
2498 ),
2499 });
2500 // Auto-elevate to full access (no sandbox)
2501 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
2502 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
2503 } else {
2504 log_sensitive_event(
2505 "tool.sandbox.prompt_elevation",
2506 serde_json::json!({
2507 "tool_name": tool_name,
2508 "tool_id": tool_id,
2509 "reason": denial_reason,
2510 "session_id": app.current_session_id,
2511 }),
2512 );
2513 // Show elevation dialog
2514 let request = ElevationRequest::for_shell(
2515 &tool_id,
2516 command.as_deref().unwrap_or(&tool_name),
2517 &denial_reason,
2518 blocked_network,
2519 blocked_write,
2520 );
2521 app.view_stack
2522 .push(ElevationView::new(request, app.ui_locale));
2523 if let Some((method, _, _)) =
2524 crate::tui::notifications::settings(config)
2525 {
2526 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
2527 let payload = crate::tui::notifications::elevation_needed_payload(
2528 &tool_name,
2529 &denial_reason,
2530 );
2531 crate::tui::notifications::notify_done(
2532 method,
2533 in_tmux,
2534 &payload,
2535 Duration::ZERO,
2536 Duration::ZERO,
2537 );
2538 }
2539 app.status_message =
2540 Some(format!("Sandbox blocked {tool_name}: {denial_reason}"));
2541 }
2542 }
2543 EngineEvent::TurnUsage { .. } => {
2544 // Per-step usage receipt for stream consumers (exec
2545 // stream-json). The TUI's token surfaces are driven
2546 // by the cumulative `TurnComplete` usage, so there is
2547 // nothing to render per step here.
2548 }
2549 EngineEvent::AdvisoryNote { note, .. } => {
2550 // Advisor background watcher note. Display as a
2551 // concise system message in the transcript so the
2552 // user can see it without it blocking the parent turn.
2553 if note.trim() != "ok" {
2554 app.add_message(HistoryCell::System {
2555 content: format!("⚑ Advisor: {note}"),
2556 });
2557 }
2558 }
2559 }
2560 events_drained = events_drained.saturating_add(1);
2561 }
2562 }
2563 if let Some(rollback) = fallback_after_engine_error {
2564 apply_provider_fallback_switch(app, &mut engine_handle, config, rollback).await;
2565 }
2566 if let Some(rollback_warning) = respawn_after_provider_rollback {
2567 let _ = engine_handle.send(Op::Shutdown).await;
2568 let engine_config = build_engine_config(app, config);
2569 engine_handle = spawn_tui_engine(engine_config, config);
2570 if !app.api_messages.is_empty() {
2571 let _ = engine_handle
2572 .send(Op::SyncSession {
2573 session_id: app.current_session_id.clone(),
2574 messages: app.api_messages.clone(),
2575 system_prompt: app.system_prompt.clone(),
2576 system_prompt_override: false,
2577 model: app.model.clone(),
2578 workspace: app.workspace.clone(),
2579 mode: app.mode,
2580 })
2581 .await;
2582 }
2583 let _ = engine_handle
2584 .send(Op::SetCompaction {
2585 config: app.compaction_config(),
2586 })
2587 .await;
2588 app.status_message = Some(rollback_warning);
2589 }
2590 if commit_streaming_display_tick(app, &mut stream_display_clock, Instant::now()) {
2591 transcript_batch_updated = true;
2592 }
2593 // #4022: `/lane interrupt` answers immediately with a queued receipt,
2594 // which is not an outcome. The terminal receipt lands here, under the
2595 // ticket the composer printed, so a queued write is never left looking
2596 // like it succeeded. Drain is non-blocking: it only takes the queue
2597 // mutex, and a poisoned one yields nothing rather than panicking the
2598 // event loop.
2599 for receipt in app.lane_control.drain_completed() {
2600 app.add_message(HistoryCell::System {
2601 content: receipt.render(),
2602 });
2603 transcript_batch_updated = true;
2604 }
2605 if transcript_batch_updated {
2606 app.mark_history_updated();
2607 }
2608 if received_engine_event {
2609 app.needs_redraw = true;
2610 }
2611 if subagent_list_refresh_requested {
2612 pending_subagent_list_refresh = true;
2613 }
2614 // #freeze: one trailing-edge sub-agent list refresh per drain, no
2615 // matter how many spawn/complete/mailbox events arrived this batch.
2616 // #3837: keep a sticky pending bit when the op channel is full so a
2617 // terminal lifecycle event cannot permanently lose the authoritative
2618 // ListSubAgents refresh.
2619 if pending_subagent_list_refresh {
2620 match engine_handle.try_send(Op::ListSubAgents) {
2621 Ok(()) => pending_subagent_list_refresh = false,
2622 Err(err) => {
2623 if err
2624 .downcast_ref::<tokio::sync::mpsc::error::TrySendError<Op>>()
2625 .is_some_and(|send_err| {
2626 matches!(send_err, tokio::sync::mpsc::error::TrySendError::Closed(_))
2627 })
2628 {
2629 pending_subagent_list_refresh = false;
2630 }
2631 }
2632 }
2633 }
2634
2635 if let Some(next) = queued_to_send {
2636 let _ = dispatch_user_message_with_recovery(
2637 app,
2638 config,
2639 &engine_handle,
2640 next,
2641 DispatchRecovery::Queued {
2642 restore_index: None,
2643 },
2644 )
2645 .await;
2646
2647 app.needs_redraw = true;
2648 }
2649
2650 // Avoid cloning the queued messages/draft every loop iteration
2651 // (~20-40 Hz) purely for change detection. When the queue is empty and
2652 // was empty last time — the overwhelmingly common case — there is
2653 // nothing to compare, so skip the clone entirely. A multi-KB queued
2654 // draft is only cloned while one is actually pending.
2655 let queue_now_empty = app.queued_messages.is_empty() && app.queued_draft.is_none();
2656 if !(queue_now_empty && last_queue_was_empty) {
2657 let queue_state = (app.queued_messages.clone(), app.queued_draft.clone());
2658 if queue_state != last_queue_state {
2659 persist_offline_queue_state(app);
2660 last_queue_state = queue_state;
2661 app.needs_redraw = true;
2662 }
2663 last_queue_was_empty = queue_now_empty;
2664 }
2665
2666 if !app.view_stack.is_empty() {
2667 let events = app.view_stack.tick();
2668 if !events.is_empty() {
2669 app.needs_redraw = true;
2670 if handle_view_events_boxed(
2671 terminal,
2672 app,
2673 config,
2674 &task_manager,
2675 &mut engine_handle,
2676 &mut web_config_session,
2677 events,
2678 )
2679 .await?
2680 {
2681 return Ok(());
2682 }
2683 }
2684 }
2685
2686 let has_running_agents = running_agent_count(app) > 0;
2687 if reconcile_turn_liveness(app, Instant::now(), has_running_agents) {
2688 app.needs_redraw = true;
2689 }
2690 maybe_throttled_recovery_snapshot(app, Instant::now(), &mut last_recovery_snapshot_at);
2691 let history_has_live_motion = history_has_live_motion(&app.history);
2692 let active_cell_has_live_motion = active_cell_has_live_motion(app);
2693 let translation_placeholder_has_live_motion = app.translation_enabled
2694 && (pending_thinking_translations > 0 || app.streaming_thinking_active_entry.is_some());
2695 // Idle ambient motion belongs to every underwater treatment: ombre
2696 // breathes its water column, while flat and Terminal-owned animate
2697 // foreground life only. Schedule redraws only when something can
2698 // actually move — the ombre field at any size, or ambient life once
2699 // the empty water is large enough to earn it.
2700 let ombre_field_breathes = app.ocean_treatment.is_ombre()
2701 && crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some();
2702 let browsing_history = !app.viewport.transcript_scroll.is_at_tail();
2703 let empty_water_visible = app.history.is_empty()
2704 && app
2705 .active_cell
2706 .as_ref()
2707 .is_none_or(crate::tui::active_cell::ActiveCell::is_empty)
2708 && !app.is_loading;
2709 // A paused terminal owns the eye. Modal/launch/onboarding visibility
2710 // and attention stillness are centralized in the shell motion gate.
2711 let underwater_surface_obscured = event_broker.is_paused();
2712 let underwater_motion_visible = underwater_motion_surface_visible(
2713 app.viewport.last_transcript_area,
2714 ombre_field_breathes,
2715 empty_water_visible,
2716 underwater_surface_obscured,
2717 );
2718 let shell_motion_enabled = crate::tui::underwater::decorative_shell_motion_enabled(app);
2719 let shell_phase_working = matches!(
2720 crate::tui::underwater::ShellPhase::from_app(app),
2721 crate::tui::underwater::ShellPhase::Working
2722 | crate::tui::underwater::ShellPhase::Verifying
2723 );
2724 // A fully idle shell settles: no live turn, no sub-agents, no active
2725 // durable tasks, completion exhale finished, and the user isn't
2726 // browsing. After a short grace the aquarium stops requesting frames
2727 // and the scene is genuinely still until real activity resumes
2728 // (owner pain, captains-log #16).
2729 let durable_tasks_active = app
2730 .task_panel
2731 .iter()
2732 .any(|task| matches!(task.status.as_str(), "queued" | "running" | "waiting"));
2733 let ambient_busy = shell_phase_working
2734 || app.turn_started_at.is_some()
2735 || has_running_agents
2736 || durable_tasks_active
2737 || app.is_loading
2738 || browsing_history
2739 || app.ocean_completion_started_at.is_some_and(|started| {
2740 started.elapsed()
2741 < Duration::from_millis(crate::tui::ocean::COMPLETION_SETTLE_MS as u64)
2742 });
2743 let ambient_settled = app.ambient_idle_settled(ambient_busy, Instant::now());
2744 let underwater_ambient_motion = shell_motion_enabled
2745 && underwater_motion_visible
2746 && !ambient_settled
2747 && (browsing_history || shell_phase_working || empty_water_visible);
2748 let underwater_completion_motion = shell_motion_enabled
2749 && !underwater_surface_obscured
2750 && matches!(app.runtime_turn_status.as_deref(), Some("completed"))
2751 && app.ocean_completion_started_at.is_some_and(|started| {
2752 started.elapsed()
2753 < Duration::from_millis(crate::tui::ocean::COMPLETION_SETTLE_MS as u64)
2754 });
2755 let status_motion = should_tick_status_animation(
2756 app,
2757 has_running_agents,
2758 history_has_live_motion,
2759 active_cell_has_live_motion,
2760 translation_placeholder_has_live_motion,
2761 );
2762 let animation_interval_ms = animation_interval_ms(
2763 app,
2764 status_motion,
2765 underwater_ambient_motion || underwater_completion_motion,
2766 );
2767 let motion_policy = app.motion_policy();
2768 if (status_motion || underwater_ambient_motion || underwater_completion_motion)
2769 && last_status_frame.elapsed() >= Duration::from_millis(animation_interval_ms)
2770 {
2771 let translation_animated = streaming_thinking::animate_pending_translation(
2772 app,
2773 pending_thinking_translations > 0,
2774 );
2775 if !matches!(motion_policy.mode(), MotionMode::Still)
2776 && (history_has_live_motion || active_cell_has_live_motion)
2777 {
2778 if translation_animated {
2779 if history_has_live_motion {
2780 app.mark_live_history_motion_updated();
2781 }
2782 } else {
2783 app.mark_live_motion_updated();
2784 }
2785 }
2786 // Coalesce decorative animation wakes through the shared requester.
2787 // Reduced/Still drop these requests; state-change redraws still set
2788 // needs_redraw directly below for phase/working chrome.
2789 frame_requester.request_frame(Instant::now(), motion_policy);
2790 if frame_requester.take_due(Instant::now(), motion_policy)
2791 || !motion_policy.should_request_animation_frames()
2792 {
2793 // Full: emit only when the requester fires. Reduced/Still: keep
2794 // the existing calm redraw so working/phase chrome stays truthful
2795 // without decorative spin (TUI-DOG-008).
2796 app.needs_redraw = true;
2797 }
2798 last_status_frame = Instant::now();
2799 }
2800
2801 if event_broker.is_paused() {
2802 let grace_active = terminal_paused_at
2803 .map(|paused_at| paused_at.elapsed() < Duration::from_millis(500))
2804 .unwrap_or(false);
2805 if terminal_pause_has_live_owner(app) || grace_active {
2806 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2807 continue;
2808 }
2809 resume_terminal(
2810 terminal,
2811 app.use_alt_screen,
2812 app.use_mouse_capture,
2813 app.use_bracketed_paste,
2814 app.synchronized_output_enabled,
2815 )?;
2816 event_broker.resume_events();
2817 terminal_paused_at = None;
2818 app.status_message = Some("Terminal controls restored".to_string());
2819 app.needs_redraw = true;
2820 force_terminal_repaint = true;
2821 }
2822
2823 let now = Instant::now();
2824 app.flush_paste_burst_if_enabled(now);
2825 app.sync_status_message_to_toasts();
2826 // Drain background-LLM cost (compaction summaries, seam
2827 // recompaction, cycle briefings) accumulated since the last
2828 // tick and fold it into the session-cost counter (#526).
2829 // Background callers populate `cost_status::report`; we sweep
2830 // the pool once per loop iteration so the footer chip matches
2831 // the DeepSeek website's billing.
2832 // Money and its completeness are drained as one value, so the footer
2833 // total and the `/cost` coverage line can never come from different
2834 // observations of the pool (#4318).
2835 let pending_bg = crate::cost_status::drain();
2836 if !pending_bg.is_empty() {
2837 if pending_bg.estimate.is_positive() {
2838 app.accrue_subagent_cost_estimate(pending_bg.estimate);
2839 app.needs_redraw = true;
2840 }
2841 app.absorb_background_cost_coverage(&pending_bg);
2842 }
2843 // Drain completed file-tree walks (initial build / expands) so the
2844 // spliced children repaint without waiting for an input event (#3900).
2845 if let Some(tree) = app.file_tree.as_mut()
2846 && tree.poll_background()
2847 {
2848 app.needs_redraw = true;
2849 }
2850 // Completion discovery is serialized off-thread. Polling is
2851 // non-blocking and makes a finished initial `@` scan visible even
2852 // after the user stops typing (#4365).
2853 if crate::tui::file_mention::poll_background_mention_discovery(app) {
2854 app.needs_redraw = true;
2855 }
2856 // Expire the "Press Ctrl+C again to quit" prompt silently after its
2857 // window. Triggers a redraw if the prompt was visible.
2858 app.tick_quit_armed();
2859 app.tick_receipt();
2860 crate::tui::footer_ui::maybe_log_provider_wait_incident(app);
2861 // While the user is drag-selecting past the transcript edge, advance
2862 // the viewport on a fixed cadence and extend the selection head so a
2863 // long passage can be selected in one drag (#1163).
2864 tick_selection_autoscroll(app);
2865 let allow_workspace_context_refresh =
2866 !app.is_loading && !has_running_agents && !app.is_compacting && !app.is_purging;
2867 workspace_context::refresh_if_needed(app, now, allow_workspace_context_refresh);
2868 // Native git chrome: at most one background probe per cache TTL, never
2869 // on the render path and never while a turn is live.
2870 if allow_workspace_context_refresh {
2871 static GIT_PROBE_LOCK: std::sync::OnceLock<std::sync::Mutex<Option<Instant>>> =
2872 std::sync::OnceLock::new();
2873 let slot = GIT_PROBE_LOCK.get_or_init(|| std::sync::Mutex::new(None));
2874 let should_probe = slot
2875 .lock()
2876 .map(|mut last| {
2877 let due = last.is_none_or(|t| t.elapsed() >= Duration::from_secs(2));
2878 if due {
2879 *last = Some(Instant::now());
2880 }
2881 due
2882 })
2883 .unwrap_or(false);
2884 if should_probe {
2885 let workspace = app.workspace.clone();
2886 std::thread::spawn(move || {
2887 crate::tui::git_status::refresh_if_stale(&workspace);
2888 });
2889 }
2890 }
2891
2892 // Draw is gated by the frame-rate limiter (120 FPS cap). When a
2893 // redraw is needed but the limiter says we're inside the cooldown
2894 // window, leave `needs_redraw = true` and shorten the poll timeout
2895 // so the loop wakes up exactly when drawing is allowed.
2896
2897 // Central motion contract: frame cap and stream catch-up both read
2898 // from MotionPolicy so reduced motion stays semantically calm (not a
2899 // slow typewriter) and Full motion keeps the steady display clock.
2900 let motion_policy = app.motion_policy();
2901 frame_rate_limiter.set_low_motion(motion_policy.uses_constrained_frame_rate());
2902 stream_display_clock.set_allow_catch_up(motion_policy.allows_catch_up_bursts());
2903
2904 // Content-driven cadence: atmosphere rate when only ocean life moves;
2905 // full interactive rate while streaming, selecting, typing, or hovering.
2906 {
2907 use crate::tui::display_refresh::{
2908 cadence_tier_from_signals, content_driven_draw_interval, probe_display_refresh,
2909 };
2910 let tier = cadence_tier_from_signals(
2911 app.is_loading || has_running_agents,
2912 app.viewport.transcript_selection.is_active(),
2913 !app.input.is_empty(),
2914 crate::tui::hover_layer::current_hover().is_some(),
2915 );
2916 let probe = probe_display_refresh();
2917 frame_rate_limiter.set_adaptive_interval(Some(content_driven_draw_interval(
2918 tier,
2919 probe.hz,
2920 motion_policy.uses_constrained_frame_rate(),
2921 )));
2922 }
2923
2924 let draw_wait = if app.needs_redraw {
2925 frame_rate_limiter.time_until_next_draw(now)
2926 } else {
2927 None
2928 };
2929 // Merge the per-app full-repaint hint (set by theme switches)
2930 // into the loop-level flag before the draw decision.
2931 if app.force_next_full_repaint {
2932 force_terminal_repaint = true;
2933 app.force_next_full_repaint = false;
2934 }
2935 if app.needs_redraw && draw_wait.is_none() {
2936 draw_app_frame_inner(terminal, app, config, force_terminal_repaint)?;
2937 force_terminal_repaint = false;
2938 frame_rate_limiter.mark_emitted(Instant::now());
2939 app.needs_redraw = false;
2940 }
2941
2942 let mut poll_timeout =
2943 if app.is_loading || has_running_agents || app.is_compacting || app.is_purging {
2944 Duration::from_millis(active_poll_ms(app))
2945 } else {
2946 Duration::from_millis(idle_poll_ms(app))
2947 };
2948 if let Some(until_flush) = app.paste_burst_next_flush_delay_if_enabled(now) {
2949 poll_timeout = poll_timeout.min(until_flush);
2950 }
2951 if let Some(until_draw) = draw_wait {
2952 poll_timeout = poll_timeout.min(until_draw);
2953 }
2954 if let Some(until_stream_commit) = stream_display_clock.due_in(now) {
2955 poll_timeout = poll_timeout.min(until_stream_commit);
2956 }
2957 if let Some(until_anim) = frame_requester.due_in(now) {
2958 poll_timeout = poll_timeout.min(until_anim);
2959 }
2960 if web_config_session.is_some() {
2961 poll_timeout = poll_timeout.min(Duration::from_millis(WEB_CONFIG_POLL_MS));
2962 }
2963 // While the quit-confirmation prompt is armed, ensure we wake up to
2964 // expire it on time even if no input event arrives.
2965 if let Some(deadline) = app.quit_armed_until {
2966 let remaining = deadline.saturating_duration_since(now);
2967 poll_timeout = poll_timeout.min(remaining.max(Duration::from_millis(50)));
2968 }
2969 // Drag-edge auto-scroll wakes the loop on its own cadence so the
2970 // viewport keeps advancing while the user holds the mouse outside
2971 // the transcript rect (#1163).
2972 if let Some(state) = app.viewport.selection_autoscroll {
2973 let remaining = state.next_tick.saturating_duration_since(now);
2974 poll_timeout = poll_timeout.min(remaining);
2975 }
2976 poll_timeout = clamp_event_poll_timeout(poll_timeout);
2977
2978 // #549/#3216: give the engine task a scheduler turn before waiting on
2979 // the terminal-input channel. Crossterm's blocking poll/read runs on
2980 // `TerminalInputPump`, so engine floods cannot pin the OS input read.
2981 tokio::task::yield_now().await;
2982
2983 let maybe_terminal_event =
2984 next_terminal_event(&terminal_input, &mut pending_terminal_events, poll_timeout)?;
2985 if maybe_terminal_event.is_none() {
2986 let now = Instant::now();
2987 let input_stalled_for = terminal_input.stalled_for(now);
2988 if terminal_input_recovery_relevant(app, has_running_agents)
2989 && input_stalled_for >= TERMINAL_INPUT_STALL_TIMEOUT
2990 && now.duration_since(last_terminal_input_recovery)
2991 >= TERMINAL_INPUT_RECOVERY_COOLDOWN
2992 {
2993 tracing::warn!(
2994 stalled_ms = input_stalled_for.as_millis(),
2995 "terminal input pump heartbeat stalled; attempting terminal input recovery"
2996 );
2997 recover_terminal_modes(
2998 terminal.backend_mut(),
2999 app.use_mouse_capture,
3000 app.use_bracketed_paste,
3001 );
3002 match terminal_input.restart_detached() {
3003 Ok(()) => {
3004 app.push_status_toast(
3005 if cfg!(target_os = "windows") {
3006 "Recovered terminal input after a stalled Windows console poll."
3007 } else {
3008 "Recovered terminal input after a stalled terminal read."
3009 },
3010 StatusToastLevel::Warning,
3011 None,
3012 );
3013 }
3014 Err(err) => {
3015 tracing::warn!(error = %err, "failed to restart terminal input pump");
3016 app.push_status_toast(
3017 "Terminal input stalled; recovery failed. Restart Codewhale if keys stop responding.",
3018 StatusToastLevel::Error,
3019 None,
3020 );
3021 }
3022 }
3023 terminal_input.mark_alive();
3024 last_terminal_input_recovery = now;
3025 if app.is_loading
3026 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
3027 {
3028 persist_recovery_snapshot(app);
3029 last_recovery_snapshot_at = Some(now);
3030 }
3031 force_terminal_repaint = true;
3032 app.needs_redraw = true;
3033 }
3034 }
3035
3036 if let Some(evt) = maybe_terminal_event {
3037 app.needs_redraw = true;
3038
3039 match &evt {
3040 Event::FocusGained => {
3041 crate::tui::notifications::set_terminal_focused(true);
3042 }
3043 Event::FocusLost => {
3044 crate::tui::notifications::set_terminal_focused(false);
3045 }
3046 _ => {}
3047 }
3048
3049 // Handle bracketed paste events
3050 if let Event::Paste(text) = &evt {
3051 handle_bracketed_paste(app, text);
3052 continue;
3053 }
3054
3055 // Re-establish terminal mode flags on focus-gain and force a full
3056 // viewport reset before repainting. App-switching and interactive
3057 // handoffs can leave the host terminal scrolled away from row 0
3058 // and (on macOS) can drop the keyboard, mouse-tracking, or
3059 // bracketed-paste modes — recover_terminal_modes() is the
3060 // canonical place those flags live.
3061 if terminal_event_needs_viewport_recapture(&evt) {
3062 let now = Instant::now();
3063 if now.duration_since(last_focus_recovery) >= FOCUS_RECOVERY_DEBOUNCE {
3064 recover_terminal_modes(
3065 terminal.backend_mut(),
3066 app.use_mouse_capture,
3067 app.use_bracketed_paste,
3068 );
3069 last_focus_recovery = now;
3070 }
3071 force_terminal_repaint = true;
3072 app.needs_redraw = true;
3073 }
3074 if let Event::Resize(width, height) = evt {
3075 tracing::debug!(
3076 width,
3077 height,
3078 use_alt_screen = app.use_alt_screen,
3079 "Event::Resize received; clearing terminal"
3080 );
3081 // Drain any further Resize events queued in this poll cycle so we
3082 // act on the final size only, then issue a single clear + redraw.
3083 // crossterm coalesces some resize events but rapid drag-resizes
3084 // can still queue several; processing them all here avoids the
3085 // common "stale art on the right edge" symptom (#65) caused by
3086 // the diff renderer skipping cells that match a stale back
3087 // buffer between intermediate sizes.
3088 let mut final_w = width;
3089 let mut final_h = height;
3090 while let Some(next_evt) =
3091 try_next_terminal_event(&terminal_input, &mut pending_terminal_events)?
3092 {
3093 match next_evt {
3094 Event::Resize(w, h) => {
3095 final_w = w;
3096 final_h = h;
3097 }
3098 other => {
3099 pending_terminal_events.push_back(other);
3100 break;
3101 }
3102 }
3103 }
3104
3105 if final_w == 0 || final_h == 0 {
3106 tracing::debug!(
3107 final_w,
3108 final_h,
3109 "zero-size Resize event ignored while terminal is hidden/minimized"
3110 );
3111 force_terminal_repaint = true;
3112 app.needs_redraw = true;
3113 continue;
3114 }
3115
3116 // #582: commit the event-reported size to ratatui's
3117 // viewport explicitly before the redraw, instead of
3118 // relying on `crossterm::terminal::size()` which gets
3119 // queried internally during `terminal.draw`. On
3120 // Windows ConHost specifically, `terminal::size()` has
3121 // been observed to return stale dimensions briefly
3122 // during a maximize→windowed transition; the next
3123 // `draw` then paints into a buffer that does not
3124 // match the post-restore viewport, producing the
3125 // unrecoverable black screen reported by @imakid.
3126 // The `Event::Resize` payload itself carries the
3127 // authoritative new size, so we forward it.
3128 if let Err(err) = terminal.resize(Rect::new(0, 0, final_w, final_h)) {
3129 tracing::warn!(
3130 ?err,
3131 final_w,
3132 final_h,
3133 "terminal.resize during Resize event failed; falling back to clear+draw"
3134 );
3135 }
3136
3137 app.handle_resize(final_w, final_h);
3138 // #macos-resize: some terminals (macOS Terminal.app, Windows
3139 // ConHost) briefly report stale dimensions via
3140 // `terminal::size()` after a resize. ratatui's `draw()` calls
3141 // `autoresize()` internally, which queries the backend size;
3142 // if it sees the old dimension it shrinks the viewport back,
3143 // leaving the newly-expanded area filled with stale content
3144 // from the previous frame (duplicate UI panels).
3145 //
3146 // We force the backend to report the resize-event size for
3147 // this single draw so the buffer matches the real viewport.
3148 {
3149 let backend = terminal.backend_mut();
3150 let new_size = Size::new(final_w, final_h);
3151 backend.force_size(new_size);
3152 backend.set_terminal_size(new_size);
3153 }
3154 draw_app_frame_inner(terminal, app, config, true)?;
3155 {
3156 let backend = terminal.backend_mut();
3157 backend.clear_forced_size();
3158 }
3159 app.needs_redraw = false;
3160 continue;
3161 }
3162
3163 if app.use_mouse_capture
3164 && let Event::Mouse(mouse) = evt
3165 {
3166 // Mouse interaction clears the ✅ completion marker.
3167 crate::tui::notifications::reset_title_on_interaction();
3168 if should_drop_loading_mouse_motion(app, mouse) {
3169 continue;
3170 }
3171 let events = handle_mouse_event(app, mouse);
3172 if handle_view_events_boxed(
3173 terminal,
3174 app,
3175 config,
3176 &task_manager,
3177 &mut engine_handle,
3178 &mut web_config_session,
3179 events,
3180 )
3181 .await?
3182 {
3183 return Ok(());
3184 }
3185 if let Some(action) = app.pending_launch_action.take() {
3186 match action {
3187 crate::tui::underwater::LaunchAction::None => {}
3188 crate::tui::underwater::LaunchAction::NewSession => {
3189 let result = begin_launch_session(app, None);
3190 if apply_command_result(
3191 terminal,
3192 app,
3193 &mut engine_handle,
3194 &task_manager,
3195 config,
3196 &mut web_config_session,
3197 result,
3198 )
3199 .await?
3200 {
3201 return Ok(());
3202 }
3203 }
3204 crate::tui::underwater::LaunchAction::CreateWorktree(name) => {
3205 app.launch.status =
3206 Some(app.tr(MessageId::LaunchCreatingWorktree).into_owned());
3207 match provision_launch_worktree(app.workspace.clone(), name).await {
3208 Ok(workspace) => {
3209 let result = begin_launch_session(app, Some(workspace));
3210 if apply_command_result(
3211 terminal,
3212 app,
3213 &mut engine_handle,
3214 &task_manager,
3215 config,
3216 &mut web_config_session,
3217 result,
3218 )
3219 .await?
3220 {
3221 return Ok(());
3222 }
3223 }
3224 Err(err) => {
3225 app.launch.status = Some(
3226 app.tr(MessageId::LaunchWorktreeFailed)
3227 .replace("{error}", &err.to_string()),
3228 );
3229 }
3230 }
3231 }
3232 crate::tui::underwater::LaunchAction::Resume => {
3233 if app.launch.workspace_session_count == 0 {
3234 app.launch.status =
3235 Some(app.tr(MessageId::LaunchNoSavedSessions).into_owned());
3236 } else {
3237 app.view_stack
3238 .push(SessionPickerView::new(&app.workspace, app.ui_locale));
3239 }
3240 }
3241 crate::tui::underwater::LaunchAction::Changelog => {
3242 let title = app.tr(MessageId::LaunchMenuChangelog).into_owned();
3243 open_text_pager(
3244 app,
3245 title,
3246 include_str!("../../../CHANGELOG.md").to_string(),
3247 );
3248 }
3249 crate::tui::underwater::LaunchAction::Quit => {
3250 let _ = engine_handle.send(Op::Shutdown).await;
3251 return Ok(());
3252 }
3253 }
3254 app.needs_redraw = true;
3255 }
3256 if let Some(slot) = app.pending_hotbar_slot.take()
3257 && let Some(dispatch) = dispatch_hotbar_slot(app, config, slot)?
3258 {
3259 match dispatch {
3260 HotbarDispatch::Handled => app.needs_redraw = true,
3261 HotbarDispatch::AppAction(action) => {
3262 if apply_command_result(
3263 terminal,
3264 app,
3265 &mut engine_handle,
3266 &task_manager,
3267 config,
3268 &mut web_config_session,
3269 commands::CommandResult::action(action),
3270 )
3271 .await?
3272 {
3273 return Ok(());
3274 }
3275 if let Err(err) = persist_pending_work_checkpoint(app).await {
3276 app.status_message = Some(format!(
3277 "Hotbar change applied, but its Work receipt is pending ({err})"
3278 ));
3279 }
3280 app.needs_redraw = true;
3281 }
3282 }
3283 }
3284 continue;
3285 }
3286
3287 // User interaction — clear the ✅ completion marker from the title.
3288 crate::tui::notifications::reset_title_on_interaction();
3289
3290 let Event::Key(mut key) = evt else {
3291 continue;
3292 };
3293
3294 if key.kind != KeyEventKind::Press {
3295 continue;
3296 }
3297
3298 // Normalize macOS modifiers: map SUPER (Cmd) to CONTROL so that
3299 // keyboard shortcuts work consistently across terminal emulators
3300 // (Terminal.app, iTerm2, Kitty, etc.) that may report different
3301 // modifier flags (#2938). The select-all chord is exempt: `Cmd+A`
3302 // must stay distinguishable from readline `Ctrl+A` (start of
3303 // input) on terminals that forward Cmd, so it keeps its SUPER
3304 // modifier and routes through `is_select_all_shortcut`.
3305 if !key_shortcuts::is_select_all_shortcut(&key) {
3306 let mapped = crate::tui::composer_ui::normalize_macos_modifiers(key.modifiers);
3307 key.modifiers = mapped;
3308 }
3309
3310 // Normalize the raw Ctrl+C control byte (0x03) delivered in
3311 // PTY/raw-mode — and by some kitty-keyboard-protocol terminals —
3312 // to canonical Ctrl+C so the quit-arm flow always runs (#4090).
3313 normalize_raw_ctrl_c(&mut key);
3314
3315 // A route change made in-session is temporary and stays that way
3316 // until the user EXPLICITLY persists it with a command
3317 // (/fleet save updates the selected Fleet, /fleet save-as saves a
3318 // new Fleet, /model save-default remembers the startup default).
3319 // Nothing here intercepts keys: a scripted or automated terminal
3320 // types exactly what it types, and plain typing can never trigger
3321 // a fleet write by accident.
3322
3323 // Approval is a decision boundary, not a viewport lock. Keep the
3324 // card focused for its ordinary selection keys while letting the
3325 // same transcript navigation used by the main shell review the
3326 // evidence above it (#4371).
3327 if handle_approval_transcript_key(app, &key) {
3328 continue;
3329 }
3330
3331 // Decision card keyboard routing (v0.8.43 truth-surface).
3332 // When a card is active, number keys 1-9 select options,
3333 // j/k or Up/Down navigate, and Enter confirms.
3334 // Only route keys to the decision card when no other modal
3335 // (Help, Config, Pager, etc.) is on top of the view stack (#2005).
3336 if app.view_stack.is_empty()
3337 && let Some(card) = app.decision_card.as_mut()
3338 {
3339 if let Some(n) = decision_card_number_from_key(&key) {
3340 card.select_number(n);
3341 card.confirm();
3342 app.status_message = card
3343 .confirmed_label()
3344 .map(|label| format!("Selected: {label}"));
3345 app.decision_card = None;
3346 app.needs_redraw = true;
3347 } else {
3348 match key.code {
3349 KeyCode::Char('j') | KeyCode::Down => {
3350 card.select_next();
3351 app.needs_redraw = true;
3352 }
3353 KeyCode::Char('k') | KeyCode::Up => {
3354 card.select_prev();
3355 app.needs_redraw = true;
3356 }
3357 KeyCode::Enter => {
3358 card.confirm();
3359 app.status_message = card
3360 .confirmed_label()
3361 .map(|label| format!("Selected: {label}"));
3362 app.decision_card = None;
3363 app.needs_redraw = true;
3364 }
3365 KeyCode::Esc => {
3366 app.decision_card = None;
3367 app.status_message = Some("Decision cancelled".to_string());
3368 app.needs_redraw = true;
3369 }
3370 _ => {}
3371 }
3372 }
3373 submit_initial_input_if_ready(app, config, &engine_handle).await?;
3374 continue;
3375 }
3376
3377 // Clicking the WorkflowPanel gives its non-text controls focus,
3378 // but ordinary characters always return directly to the composer.
3379 // This keeps the panel keyboard-accessible without stealing the
3380 // first t/c/j/k (or any other letter) of a new chat.
3381 if app.view_stack.is_empty() && handle_workflow_panel_key(app, &key) {
3382 submit_initial_input_if_ready(app, config, &engine_handle).await?;
3383 continue;
3384 }
3385
3386 // The Ocean work surface is a real focus owner. Route its keys
3387 // before global transcript/composer navigation so PageUp/Down,
3388 // Home/End, arrows, and row actions stay panel-local.
3389 if app.view_stack.is_empty()
3390 && let Some(action) = crate::tui::work_surface::handle_key(app, key)
3391 {
3392 if let Some(action) = action {
3393 match action {
3394 crate::tui::app::SidebarRowAction::Command(command) => {
3395 if execute_command_input(
3396 terminal,
3397 app,
3398 &mut engine_handle,
3399 &task_manager,
3400 config,
3401 &mut web_config_session,
3402 &command,
3403 )
3404 .await?
3405 {
3406 return Ok(());
3407 }
3408 }
3409 crate::tui::app::SidebarRowAction::CancelAgent { agent_id } => {
3410 app.status_message = Some(format!("Cancelling {agent_id}..."));
3411 if engine_handle
3412 .send(Op::CancelSubAgent {
3413 agent_id: agent_id.clone(),
3414 })
3415 .await
3416 .is_err()
3417 {
3418 app.status_message = Some(format!("Could not cancel {agent_id}"));
3419 }
3420 }
3421 other => {
3422 let _ = crate::tui::mouse_ui::apply_sidebar_row_action(app, other);
3423 }
3424 }
3425 }
3426 submit_initial_input_if_ready(app, config, &engine_handle).await?;
3427 continue;
3428 }
3429
3430 // Help is shell-global, including onboarding, launch, and modal
3431 // surfaces. `/help` remains the guaranteed textual route; this
3432 // handles function-key and control-key terminal encodings.
3433 if crate::tui::shell_key_routing::is_help_shortcut(&key) {
3434 if app.view_stack.top_kind() == Some(ModalKind::Help) {
3435 app.view_stack.pop();
3436 } else {
3437 let help = HelpView::new_for_shortcuts(
3438 app.ui_locale,
3439 &app.workspace,
3440 &app.cached_skills,
3441 );
3442 app.view_stack.push(help);
3443 }
3444 continue;
3445 }
3446
3447 // F2 is the shell-global typed settings route. Keep it available
3448 // from onboarding and modal surfaces just like Help; pressing it
3449 // again closes the editor without applying an in-progress value.
3450 if crate::tui::shell_key_routing::is_settings_shortcut(&key) {
3451 toggle_settings_view(app);
3452 continue;
3453 }
3454
3455 // Provider onboarding is a real ProviderPickerView, not a
3456 // parallel ten-provider key handler. Route its keys before the
3457 // legacy onboarding switch so List/Key/Model/Confirm retain the
3458 // same behavior as `/provider` and `/setup`.
3459 match onboarding_key_route(app.onboarding, app.view_stack.top_kind(), &key) {
3460 // #4763: onboarding must never be a trap. Ctrl+C terminates
3461 // from every onboarding state, including while the picker
3462 // owns the keys — the legacy handler below is unreachable
3463 // once a modal is on the stack.
3464 OnboardingKeyRoute::Quit => {
3465 let _ = engine_handle.send(Op::Shutdown).await;
3466 return Ok(());
3467 }
3468 // #3927: no provider is selected and no route is activated.
3469 // The picker (a preview surface, never route authority) is
3470 // popped without applying anything it was showing.
3471 OnboardingKeyRoute::ExploreOffline => {
3472 if app.view_stack.top_kind() == Some(ModalKind::ProviderPicker) {
3473 let _ = app.view_stack.pop();
3474 }
3475 onboarding::choose_offline_explore(app);
3476 continue;
3477 }
3478 // Every other key, Escape included, belongs to the picker.
3479 // The picker's own per-stage Escape walks key/OAuth entry
3480 // back to the list and only dismisses from the list, where
3481 // `ProviderPickerDismissed` runs the same non-mutating
3482 // onboarding back-transition the shell used to force.
3483 OnboardingKeyRoute::ProviderPicker => {
3484 if key_shortcuts::is_paste_shortcut(&key)
3485 && paste_provider_picker_from_clipboard(app)
3486 {
3487 app.needs_redraw = true;
3488 continue;
3489 }
3490 let events = app.view_stack.handle_key(key);
3491 app.needs_redraw = true;
3492 if handle_view_events_boxed(
3493 terminal,
3494 app,
3495 config,
3496 &task_manager,
3497 &mut engine_handle,
3498 &mut web_config_session,
3499 events,
3500 )
3501 .await?
3502 {
3503 return Ok(());
3504 }
3505 continue;
3506 }
3507 // #3937: the theme picker owns the appearance step, including
3508 // Escape — its revert path restores the theme the session
3509 // started with. When it closes (Enter persisted, or Escape
3510 // reverted) the step is done either way, so the spine advances.
3511 OnboardingKeyRoute::ThemePicker => {
3512 let events = app.view_stack.handle_key(key);
3513 app.needs_redraw = true;
3514 if handle_view_events_boxed(
3515 terminal,
3516 app,
3517 config,
3518 &task_manager,
3519 &mut engine_handle,
3520 &mut web_config_session,
3521 events,
3522 )
3523 .await?
3524 {
3525 return Ok(());
3526 }
3527 if app.view_stack.top_kind() != Some(ModalKind::ThemePicker) {
3528 onboarding::advance_onboarding_after_appearance(app);
3529 open_onboarding_provider_picker(app, config, &engine_handle, false).await;
3530 }
3531 continue;
3532 }
3533 OnboardingKeyRoute::Legacy => {}
3534 }
3535
3536 // Handle onboarding flow
3537 if app.onboarding != OnboardingState::None {
3538 match key.code {
3539 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3540 let _ = engine_handle.send(Op::Shutdown).await;
3541 return Ok(());
3542 }
3543 KeyCode::Esc if app.onboarding == OnboardingState::Provider => {
3544 back_from_provider_onboarding(app);
3545 }
3546 // Only reachable with the picker closed; with it open the
3547 // picker owns Escape so its theme revert runs first.
3548 KeyCode::Esc if app.onboarding == OnboardingState::Appearance => {
3549 app.onboarding = OnboardingState::Language;
3550 app.status_message = None;
3551 }
3552 KeyCode::Esc if app.onboarding == OnboardingState::Language => {
3553 app.onboarding = OnboardingState::Welcome;
3554 app.status_message = None;
3555 }
3556 KeyCode::Esc if app.onboarding == OnboardingState::MentalModels => {
3557 onboarding::back_from_mental_models(app);
3558 open_onboarding_provider_picker(app, config, &engine_handle, true).await;
3559 }
3560 _ if app.onboarding == OnboardingState::MentalModels
3561 && is_permission_cycle_shortcut(&key) =>
3562 {
3563 cycle_permission_posture(app, config, &engine_handle).await;
3564 }
3565 KeyCode::Tab
3566 if app.onboarding == OnboardingState::MentalModels
3567 && key.modifiers.is_empty() =>
3568 {
3569 app.cycle_mode();
3570 sync_mode_update(app, &engine_handle).await;
3571 }
3572 // Language picker hotkeys select + persist (#566).
3573 //
3574 // Note: this used to be a single match-guard with `&& let`,
3575 // but `if_let_guard` is a nightly-only feature on Rust
3576 // before 1.94. Rewriting as a plain guard + nested `if let`
3577 // keeps `cargo install` working on stable.
3578 KeyCode::Char(c)
3579 if app.onboarding == OnboardingState::Language
3580 && (c.is_ascii_digit() || c.is_ascii_lowercase()) =>
3581 {
3582 if let Some((_, tag, _, _)) = onboarding::language::LANGUAGE_OPTIONS
3583 .iter()
3584 .find(|(hotkey, _, _, _)| *hotkey == c)
3585 {
3586 match app.set_locale_from_onboarding(tag) {
3587 Ok(()) => {
3588 app.push_status_toast(
3589 format!("Language set to {tag}"),
3590 StatusToastLevel::Info,
3591 Some(2_500),
3592 );
3593 onboarding::advance_onboarding_after_language(app);
3594 open_onboarding_theme_picker(app);
3595 }
3596 Err(err) => {
3597 app.status_message =
3598 Some(format!("Failed to save locale: {err}"));
3599 }
3600 }
3601 }
3602 }
3603 KeyCode::Enter => match app.onboarding {
3604 OnboardingState::Welcome => {
3605 onboarding::advance_onboarding_from_welcome(app);
3606 }
3607 OnboardingState::Language => {
3608 // Enter without a digit pick keeps the existing
3609 // setting (which defaults to "auto").
3610 onboarding::advance_onboarding_after_language(app);
3611 open_onboarding_theme_picker(app);
3612 }
3613 // Reached only when the picker is not on the stack —
3614 // e.g. after walking Back from the mental-model
3615 // screen. Enter re-opens it rather than skipping the
3616 // step with no way to return.
3617 OnboardingState::Appearance => {
3618 open_onboarding_theme_picker(app);
3619 }
3620 OnboardingState::Provider => {
3621 open_onboarding_provider_picker(app, config, &engine_handle, false)
3622 .await;
3623 }
3624 OnboardingState::TrustDirectory => {
3625 // Trusting a workspace is a security boundary, so it
3626 // must be a deliberate choice. Enter — the "advance"
3627 // key on every other onboarding screen — must NOT
3628 // grant trust by reflex (accidental-trust risk). Nor
3629 // is it a silent dead key: point the user at the
3630 // explicit keys the footer advertises.
3631 app.status_message =
3632 Some(app.tr(MessageId::OnboardTrustEnterHint).to_string());
3633 }
3634 OnboardingState::MentalModels => {
3635 app.status_message = None;
3636 app.onboarding = OnboardingState::Tips;
3637 }
3638 OnboardingState::Tips => {
3639 app.finish_onboarding_without_feature_intro();
3640 if !app.launch.visible
3641 && !open_setup_checkpoint_if_due(app, config, false)
3642 {
3643 app.maybe_show_feature_intro();
3644 }
3645 }
3646 OnboardingState::None => {}
3647 },
3648 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1')
3649 if app.onboarding == OnboardingState::TrustDirectory =>
3650 {
3651 if let Err(err) = complete_trust_directory_onboarding(app, config) {
3652 app.status_message = Some(format!("Failed to trust workspace: {err}"));
3653 }
3654 }
3655 // Number keys mirror the footer's reading order (1 trust,
3656 // 2 continue untrusted, 3 quit) so the displayed digits
3657 // are sequential instead of 1/3/2.
3658 KeyCode::Char('u') | KeyCode::Char('U') | KeyCode::Char('2')
3659 if app.onboarding == OnboardingState::TrustDirectory =>
3660 {
3661 continue_without_trusting_directory(app);
3662 }
3663 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('3')
3664 if app.onboarding == OnboardingState::TrustDirectory =>
3665 {
3666 let _ = engine_handle.send(Op::Shutdown).await;
3667 return Ok(());
3668 }
3669 KeyCode::Esc if app.onboarding == OnboardingState::TrustDirectory => {
3670 let _ = engine_handle.send(Op::Shutdown).await;
3671 return Ok(());
3672 }
3673 _ => {}
3674 }
3675 continue;
3676 }
3677
3678 // The pre-session launch menu owns every key until the user has
3679 // chosen a real session/worktree action. Resume and changelog may
3680 // place a shared surface above it; those views keep their normal
3681 // handlers while the launch screen remains the stable backdrop.
3682 if app.launch.visible {
3683 if !app.view_stack.is_empty() {
3684 let events = app.view_stack.handle_key(key);
3685 app.needs_redraw = true;
3686 if handle_view_events_boxed(
3687 terminal,
3688 app,
3689 config,
3690 &task_manager,
3691 &mut engine_handle,
3692 &mut web_config_session,
3693 events,
3694 )
3695 .await?
3696 {
3697 return Ok(());
3698 }
3699 continue;
3700 }
3701
3702 let launch_locale = app.ui_locale;
3703 match crate::tui::underwater::handle_launch_key(&mut app.launch, key, launch_locale)
3704 {
3705 crate::tui::underwater::LaunchAction::None => {}
3706 crate::tui::underwater::LaunchAction::NewSession => {
3707 let result = begin_launch_session(app, None);
3708 if apply_command_result(
3709 terminal,
3710 app,
3711 &mut engine_handle,
3712 &task_manager,
3713 config,
3714 &mut web_config_session,
3715 result,
3716 )
3717 .await?
3718 {
3719 return Ok(());
3720 }
3721 }
3722 crate::tui::underwater::LaunchAction::CreateWorktree(name) => {
3723 app.launch.status =
3724 Some(app.tr(MessageId::LaunchCreatingWorktree).into_owned());
3725 match provision_launch_worktree(app.workspace.clone(), name).await {
3726 Ok(workspace) => {
3727 let result = begin_launch_session(app, Some(workspace));
3728 if apply_command_result(
3729 terminal,
3730 app,
3731 &mut engine_handle,
3732 &task_manager,
3733 config,
3734 &mut web_config_session,
3735 result,
3736 )
3737 .await?
3738 {
3739 return Ok(());
3740 }
3741 }
3742 Err(err) => {
3743 app.launch.status = Some(
3744 app.tr(MessageId::LaunchWorktreeFailed)
3745 .replace("{error}", &err.to_string()),
3746 );
3747 }
3748 }
3749 }
3750 crate::tui::underwater::LaunchAction::Resume => {
3751 if app.launch.workspace_session_count == 0 {
3752 app.launch.status =
3753 Some(app.tr(MessageId::LaunchNoSavedSessions).into_owned());
3754 } else {
3755 app.view_stack
3756 .push(SessionPickerView::new(&app.workspace, app.ui_locale));
3757 }
3758 }
3759 crate::tui::underwater::LaunchAction::Changelog => {
3760 let title = app.tr(MessageId::LaunchMenuChangelog).into_owned();
3761 open_text_pager(
3762 app,
3763 title,
3764 include_str!("../../../CHANGELOG.md").to_string(),
3765 );
3766 }
3767 crate::tui::underwater::LaunchAction::Quit => {
3768 let _ = engine_handle.send(Op::Shutdown).await;
3769 return Ok(());
3770 }
3771 }
3772 app.needs_redraw = true;
3773 continue;
3774 }
3775
3776 if key.code == KeyCode::Char('x')
3777 && key.modifiers.contains(KeyModifiers::CONTROL)
3778 && prefill_jobs_cancel_all_if_tasks_sidebar(app)
3779 {
3780 continue;
3781 }
3782
3783 if key.code == KeyCode::Char('k') && key.modifiers.contains(KeyModifiers::CONTROL) {
3784 // When the composer is the active input target (no modal/pager
3785 // intercepting keys), Ctrl+K performs an emacs-style kill to
3786 // end-of-line. If the kill is a no-op (cursor at end of empty
3787 // input), fall through to the existing command palette.
3788 if app.view_stack.is_empty() && app.kill_to_end_of_line() {
3789 continue;
3790 }
3791 codewhale_telemetry::session_counters()
3792 .bump(codewhale_telemetry::Counter::CommandPaletteOpen);
3793 app.view_stack.push(CommandPaletteView::new_for_locale(
3794 app.ui_locale,
3795 build_command_palette_entries(
3796 app.ui_locale,
3797 &app.skills_dir,
3798 app.skills_scan_codewhale_only,
3799 &app.workspace,
3800 &app.mcp_config_path,
3801 app.mcp_snapshot.as_ref(),
3802 app.plugin_registry.as_ref(),
3803 ),
3804 ));
3805 continue;
3806 }
3807
3808 // y / Y in the rail's Tasks panel: yank the current turn id (y)
3809 // or copy full task detail (Y) to the system clipboard.
3810 // Only active when the composer is empty to avoid stealing
3811 // keystrokes from typed input (#2000).
3812 if app.view_stack.is_empty()
3813 && app.work_surface.panel == crate::tui::work_surface::RailPanel::Tasks
3814 && app.work_surface.last_area.is_some()
3815 && app.input.is_empty()
3816 && !app.runtime_turn_id.as_deref().unwrap_or("").is_empty()
3817 {
3818 if key.code == KeyCode::Char('y') && key.modifiers == KeyModifiers::NONE {
3819 if let Some(turn_id) = app.runtime_turn_id.as_ref()
3820 && app.clipboard.write_text(turn_id).is_ok()
3821 {
3822 app.status_message = Some(format!("Copied turn id {turn_id}"));
3823 }
3824 continue;
3825 }
3826 if key.code == KeyCode::Char('Y') && key.modifiers == KeyModifiers::NONE {
3827 let mut detail = String::new();
3828 if let Some(turn_id) = app.runtime_turn_id.as_ref() {
3829 let _ = write!(detail, "turn {turn_id}");
3830 }
3831 if let Some(status) = app.runtime_turn_status.as_deref() {
3832 let _ = write!(detail, " status={status}");
3833 }
3834 if !detail.is_empty() && app.clipboard.write_text(&detail).is_ok() {
3835 app.status_message = Some(format!("Copied {detail}"));
3836 }
3837 continue;
3838 }
3839 }
3840
3841 // Shifted shortcuts toggle the file-tree pane. Keep plain Ctrl+E
3842 // reserved for the composer end-of-line binding used by shells.
3843 if key_shortcuts::is_file_tree_toggle_shortcut(&key) {
3844 if let Some(_state) = app.file_tree.as_mut() {
3845 // File tree visible → hide it.
3846 app.file_tree = None;
3847 app.status_message = Some("File tree closed".to_string());
3848 } else {
3849 // Build the file tree from the current workspace.
3850 let state = crate::tui::file_tree::FileTreeState::new(&app.workspace);
3851 app.file_tree = Some(state);
3852 app.status_message = Some(
3853 "File tree: \u{2191}/\u{2193} navigate Enter select Esc close"
3854 .to_string(),
3855 );
3856 }
3857 app.needs_redraw = true;
3858 continue;
3859 }
3860
3861 // Ctrl+P opens the fuzzy file-picker overlay. Bound only when the
3862 // composer is focused (no other modal or inline popup on top) and the
3863 // engine is not actively streaming a turn.
3864 if key.code == KeyCode::Char('p')
3865 && key.modifiers.contains(KeyModifiers::CONTROL)
3866 && visible_slash_menu_entries(app, SLASH_MENU_LIMIT).is_empty()
3867 && app.view_stack.is_empty()
3868 && !app.is_loading
3869 {
3870 file_picker_relevance::open_file_picker(app);
3871 continue;
3872 }
3873
3874 if matches!(key.code, KeyCode::Char('l') | KeyCode::Char('L'))
3875 && key.modifiers.contains(KeyModifiers::CONTROL)
3876 && app.view_stack.is_empty()
3877 {
3878 app.status_message = Some(if app.is_compacting {
3879 "Context compaction already in progress...".to_string()
3880 } else {
3881 "Compacting context (Ctrl+L)...".to_string()
3882 });
3883 if !app.is_compacting {
3884 match validated_app_runtime_route(app, config) {
3885 Ok(route) => {
3886 let compaction = compaction_for_validated_route(app, &route);
3887 let _ = engine_handle
3888 .send(Op::CompactContext {
3889 route: Box::new(route.into_resolved()),
3890 compaction: Box::new(compaction),
3891 })
3892 .await;
3893 }
3894 Err(err) => {
3895 app.status_message = Some(format!(
3896 "Cannot compact because the active provider route is invalid: {err}"
3897 ));
3898 }
3899 }
3900 }
3901 app.needs_redraw = true;
3902 continue;
3903 }
3904
3905 if matches!(key.code, KeyCode::Char('b') | KeyCode::Char('B'))
3906 && key_shortcuts::has_control_like_modifier(key.modifiers)
3907 && app.view_stack.is_empty()
3908 {
3909 // #3032/#3859: Ctrl+B moves the active foreground shell wait
3910 // into /jobs instead of opening a two-step shell-control menu.
3911 // When nothing is movable, the status message tells the user
3912 // what's going on.
3913 request_foreground_shell_background(app);
3914 app.needs_redraw = true;
3915 continue;
3916 }
3917
3918 if crate::tui::shell_key_routing::is_context_inspector_shortcut(&key)
3919 && app.view_stack.is_empty()
3920 {
3921 open_context_inspector(app);
3922 continue;
3923 }
3924
3925 // Shift+Tab is a shell-level permission control. Keep it live in
3926 // the composer and the Config surface, while leaving approval,
3927 // elevation, setup, and other focused workflows in full control
3928 // of their own keys. Accept both terminal encodings used for the
3929 // same chord (`BackTab` and `Tab` + SHIFT).
3930 if is_permission_cycle_shortcut(&key)
3931 && matches!(app.view_stack.top_kind(), None | Some(ModalKind::Config))
3932 {
3933 cycle_permission_posture(app, config, &engine_handle).await;
3934 continue;
3935 }
3936
3937 if !app.view_stack.is_empty() {
3938 if key_shortcuts::is_paste_shortcut(&key)
3939 && paste_provider_picker_from_clipboard(app)
3940 {
3941 app.needs_redraw = true;
3942 continue;
3943 }
3944 let closing_work_inspector = app.work_surface.opened.is_some()
3945 && app.view_stack.top_kind() == Some(ModalKind::Pager);
3946 let events = app.view_stack.handle_key(key);
3947 clear_work_inspector_after_pager_close(app, closing_work_inspector);
3948 app.needs_redraw = true;
3949 if handle_view_events_boxed(
3950 terminal,
3951 app,
3952 config,
3953 &task_manager,
3954 &mut engine_handle,
3955 &mut web_config_session,
3956 events,
3957 )
3958 .await?
3959 {
3960 return Ok(());
3961 }
3962 continue;
3963 }
3964
3965 if let Some(slot) = hotbar_slot_from_key(app, &key) {
3966 if let Some(dispatch) = dispatch_hotbar_slot(app, config, slot)? {
3967 match dispatch {
3968 HotbarDispatch::Handled => {
3969 app.needs_redraw = true;
3970 }
3971 HotbarDispatch::AppAction(action) => {
3972 if apply_command_result(
3973 terminal,
3974 app,
3975 &mut engine_handle,
3976 &task_manager,
3977 config,
3978 &mut web_config_session,
3979 commands::CommandResult::action(action),
3980 )
3981 .await?
3982 {
3983 return Ok(());
3984 }
3985 if let Err(err) = persist_pending_work_checkpoint(app).await {
3986 app.status_message = Some(format!(
3987 "Hotbar change applied, but its Work receipt is pending ({err})"
3988 ));
3989 }
3990 app.needs_redraw = true;
3991 }
3992 }
3993 }
3994 continue;
3995 }
3996
3997 // File-tree navigation: delegated to key_actions module.
3998 if key_actions::handle_file_tree_key(app, &key) {
3999 continue;
4000 }
4001
4002 if app.is_history_search_active() {
4003 handle_history_search_key(app, key);
4004 continue;
4005 }
4006
4007 if matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R'))
4008 && key.modifiers.contains(KeyModifiers::ALT)
4009 && !key.modifiers.contains(KeyModifiers::CONTROL)
4010 && !key.modifiers.contains(KeyModifiers::SUPER)
4011 {
4012 app.start_history_search();
4013 continue;
4014 }
4015
4016 let now = Instant::now();
4017 app.flush_paste_burst_if_enabled(now);
4018
4019 // On Windows, AltGr is delivered as `Ctrl+Alt`; treat
4020 // AltGr-typed chars (e.g. European layouts producing `@`, `\`,
4021 // `|`) as plain text rather than swallowing them as a modified
4022 // shortcut. `key_hint::has_ctrl_or_alt` filters AltGr out.
4023 let has_ctrl_alt_or_super =
4024 crate::tui::widgets::key_hint::has_ctrl_or_alt(key.modifiers)
4025 || key.modifiers.contains(KeyModifiers::SUPER);
4026 let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super;
4027 // Only bare Enter participates in trailing-newline paste-burst
4028 // protection. Modified Enter chords are deliberate composer
4029 // actions: flush any buffered text, then route the chord normally
4030 // so Shift/Alt+Enter newline and Ctrl+Enter steer are never eaten
4031 // after fast typing or an unbracketed paste.
4032 let is_plain_enter =
4033 matches!(key.code, KeyCode::Enter) && key.modifiers == KeyModifiers::NONE;
4034
4035 // Tool details: Alt+V / Option+V only. Bare `v` always types `v`
4036 // in every focus state (TUI-DOG-002).
4037 if crate::tui::shell_key_routing::is_tool_details_shortcut(&key) {
4038 open_tool_details_pager(app);
4039 continue;
4040 }
4041
4042 if !is_plain_char
4043 && !is_plain_enter
4044 && let Some(pending) = app.flush_paste_burst_before_modified_input_if_enabled()
4045 {
4046 app.insert_str(&pending);
4047 }
4048
4049 if (is_plain_char || is_plain_enter)
4050 && crate::tui::paste::handle_paste_burst_key(app, &key, now)
4051 {
4052 continue;
4053 }
4054
4055 let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
4056 let slash_menu_open = !slash_menu_entries.is_empty();
4057 if slash_menu_open && app.slash_menu_selected >= slash_menu_entries.len() {
4058 app.slash_menu_selected = slash_menu_entries.len().saturating_sub(1);
4059 }
4060 let mention_menu_limit = app.mention_menu_limit;
4061 let mention_menu_entries =
4062 crate::tui::file_mention::visible_mention_menu_entries(app, mention_menu_limit);
4063 let mention_menu_open = !mention_menu_entries.is_empty();
4064 if mention_menu_open && app.mention_menu_selected >= mention_menu_entries.len() {
4065 app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
4066 }
4067
4068 // Cancel a pending Esc-Esc prime as soon as any non-Esc key
4069 // arrives. Without this the prime would hang around for the
4070 // rest of the session and the user's next genuine Esc would
4071 // suddenly skip straight into the backtrack overlay.
4072 if !matches!(key.code, KeyCode::Esc)
4073 && matches!(
4074 app.backtrack.phase,
4075 crate::tui::backtrack::BacktrackPhase::Primed
4076 )
4077 {
4078 app.backtrack.reset();
4079 }
4080
4081 // Global keybindings — voice first (⌥V) so it doesn't insert a char.
4082 if handle_voice_key(app, &key) {
4083 continue;
4084 }
4085 if handle_reasoning_effort_key(app, &key) {
4086 if let Err(err) = persist_pending_work_checkpoint(app).await {
4087 app.status_message = Some(format!(
4088 "Reasoning effort changed, but its Work receipt is pending ({err})"
4089 ));
4090 }
4091 continue;
4092 }
4093
4094 // A second, empty Enter after queueing is the portable steer
4095 // gesture. Handle it before transcript/detail Enter shortcuts so
4096 // it can never open an unrelated overlay instead (#382).
4097 if matches!(key.code, KeyCode::Enter)
4098 && key.modifiers == KeyModifiers::NONE
4099 && matches!(
4100 app.decide_composer_submit(ComposerSubmitChord::Enter),
4101 ComposerSubmitAction::SendQueuedNow
4102 )
4103 {
4104 let _ = send_next_queued_message_now(app, config, &engine_handle).await?;
4105 continue;
4106 }
4107 match key.code {
4108 KeyCode::Enter
4109 if app.input.is_empty()
4110 && app.viewport.transcript_selection.is_active()
4111 && open_pager_for_selection(app) =>
4112 {
4113 continue;
4114 }
4115 KeyCode::Enter
4116 if key.modifiers == KeyModifiers::NONE
4117 && app.input.is_empty()
4118 && detail_target_cell_index(app)
4119 .is_some_and(|idx| app.toggle_tool_run_expansion_at(idx)) =>
4120 {
4121 continue;
4122 }
4123 KeyCode::Char('l')
4124 if key_shortcuts::alt_nav_modifiers(key.modifiers)
4125 && app.input.is_empty()
4126 && open_pager_for_last_message(app) =>
4127 {
4128 continue;
4129 }
4130 _ if key_shortcuts::is_reasoning_detail_shortcut(&key)
4131 && open_reasoning_detail_pager(app) =>
4132 {
4133 continue;
4134 }
4135 _ if key_shortcuts::is_turn_inspector_shortcut(&key)
4136 && open_turn_inspector_pager(app) =>
4137 {
4138 continue;
4139 }
4140 // Space toggles fold/unfold of the focused thinking block
4141 // when the composer is empty. For thinking cells, toggles
4142 // between summary and full content; for other cells, toggles
4143 // visibility (#1972, #2348). Uses virtual-cell lookup so
4144 // in-flight active reasoning works too.
4145 KeyCode::Char(' ')
4146 if key.modifiers == KeyModifiers::NONE && app.input.is_empty() =>
4147 {
4148 if let Some(idx) = detail_target_cell_index(app) {
4149 if app.toggle_tool_run_expansion_at(idx) {
4150 continue;
4151 }
4152 let is_thinking = app
4153 .cell_at_virtual_index(idx)
4154 .is_some_and(|c| matches!(c, HistoryCell::Thinking { .. }));
4155 if is_thinking {
4156 if app.folded_thinking.contains(&idx) {
4157 app.folded_thinking.remove(&idx);
4158 app.status_message = Some("Thinking block expanded".to_string());
4159 } else {
4160 app.folded_thinking.insert(idx);
4161 app.status_message = Some("Thinking block folded".to_string());
4162 }
4163 } else if app.collapsed_cells.contains(&idx) {
4164 app.collapsed_cells.remove(&idx);
4165 app.status_message = Some("Cell expanded".to_string());
4166 } else {
4167 app.collapsed_cells.insert(idx);
4168 app.status_message = Some("Cell collapsed".to_string());
4169 }
4170 app.mark_history_updated();
4171 app.needs_redraw = true;
4172 }
4173 continue;
4174 }
4175 KeyCode::Char('t') | KeyCode::Char('T')
4176 if key.modifiers.contains(KeyModifiers::CONTROL)
4177 && key.modifiers.contains(KeyModifiers::SHIFT) =>
4178 {
4179 toggle_live_transcript_overlay(app);
4180 continue;
4181 }
4182 KeyCode::Char('1')
4183 if key.modifiers.contains(KeyModifiers::ALT)
4184 && key_shortcuts::has_control_like_modifier(key.modifiers) =>
4185 {
4186 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Tasks);
4187 continue;
4188 }
4189 KeyCode::Char('2')
4190 if key.modifiers.contains(KeyModifiers::ALT)
4191 && key_shortcuts::has_control_like_modifier(key.modifiers) =>
4192 {
4193 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Agents);
4194 continue;
4195 }
4196 KeyCode::Char('3')
4197 if key.modifiers.contains(KeyModifiers::ALT)
4198 && key_shortcuts::has_control_like_modifier(key.modifiers) =>
4199 {
4200 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Context);
4201 continue;
4202 }
4203 KeyCode::Char('4')
4204 if key.modifiers.contains(KeyModifiers::ALT)
4205 && key_shortcuts::has_control_like_modifier(key.modifiers) =>
4206 {
4207 apply_alt_4_shortcut(app, key.modifiers);
4208 continue;
4209 }
4210 // Rail panel selection via Alt+! / Alt+@ / Alt+# / Alt+$ / Alt+%
4211 // AltGr on European keyboards emits Ctrl+Alt on Windows, so
4212 // exclude Ctrl to avoid swallowing AltGr-typed characters
4213 // like @ (AltGr+0 on French AZERTY) and # (AltGr+3). This
4214 // matches the has_ctrl_or_alt / is_altgr philosophy in
4215 // key_hint.rs: treat Ctrl+Alt as AltGr, not a shortcut.
4216 KeyCode::Char('!')
4217 if key.modifiers.contains(KeyModifiers::ALT)
4218 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
4219 {
4220 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Tasks);
4221 continue;
4222 }
4223 KeyCode::Char('@')
4224 if key.modifiers.contains(KeyModifiers::ALT)
4225 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
4226 {
4227 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Agents);
4228 continue;
4229 }
4230 KeyCode::Char('#')
4231 if key.modifiers.contains(KeyModifiers::ALT)
4232 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
4233 {
4234 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Context);
4235 continue;
4236 }
4237 KeyCode::Char('$') | KeyCode::Char('%')
4238 if key.modifiers.contains(KeyModifiers::ALT)
4239 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
4240 {
4241 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Pinned);
4242 continue;
4243 }
4244 KeyCode::Char('0')
4245 if key.modifiers.contains(KeyModifiers::ALT)
4246 && key.modifiers.contains(KeyModifiers::CONTROL) =>
4247 {
4248 apply_alt_0_shortcut(app, key.modifiers);
4249 continue;
4250 }
4251 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4252 // Scope the picker to the current workspace so Ctrl+R
4253 // never restores a different project's history by
4254 // surprise (#1395). Press `a` inside the picker to
4255 // broaden to every saved session.
4256 app.view_stack
4257 .push(SessionPickerView::new(&app.workspace, app.ui_locale));
4258 continue;
4259 }
4260 KeyCode::Char('c') | KeyCode::Char('C')
4261 if key_shortcuts::is_copy_shortcut(&key) =>
4262 {
4263 let sel = app.selected_text();
4264 if !sel.is_empty() {
4265 if app.clipboard.write_text(&sel).is_ok() {
4266 app.push_status_toast(
4267 "Copied to clipboard",
4268 StatusToastLevel::Info,
4269 None,
4270 );
4271 app.clear_selection();
4272 } else {
4273 app.push_status_toast("Copy failed", StatusToastLevel::Error, None);
4274 }
4275 } else {
4276 copy_active_selection(app);
4277 }
4278 }
4279 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4280 // Four behaviors layered on Ctrl+C in priority order — see
4281 // `CtrlCDisposition` for the unit-tested decision table.
4282 // 1. selection active → copy + clear (Windows convention,
4283 // #1337); 2. turn in flight → cancel; 3. quit-armed →
4284 // exit; 4. otherwise → arm the 2-second exit prompt.
4285 match ctrl_c_disposition(app) {
4286 CtrlCDisposition::CopySelection => {
4287 copy_active_selection(app);
4288 app.viewport.transcript_selection.clear();
4289 }
4290 CtrlCDisposition::CancelTurn => {
4291 engine_handle.cancel();
4292 mark_active_turn_cancelled_locally(app);
4293 current_streaming_text.clear();
4294 stream_display_clock.reset();
4295 let prompt_restored = app.restore_last_submitted_prompt_if_empty();
4296 let base = if prompt_restored {
4297 "Request cancelled; prompt restored to composer"
4298 } else {
4299 "Request cancelled"
4300 };
4301 app.status_message = Some(parent_stop_status(app, base));
4302 app.disarm_quit();
4303 }
4304 CtrlCDisposition::ConfirmExit => {
4305 let _ = engine_handle.send(Op::Shutdown).await;
4306 return Ok(());
4307 }
4308 CtrlCDisposition::ArmExit => {
4309 app.arm_quit();
4310 }
4311 }
4312 }
4313 KeyCode::Char('d')
4314 if key.modifiers.contains(KeyModifiers::CONTROL) && app.input.is_empty() =>
4315 {
4316 let _ = engine_handle.send(Op::Shutdown).await;
4317 return Ok(());
4318 }
4319 // Vim composer mode: Esc from Insert/Visual → Normal.
4320 // This arm runs before the generic Esc handler so Insert mode
4321 // Esc doesn't accidentally cancel an in-flight request.
4322 KeyCode::Esc
4323 if app.composer.vim_enabled
4324 && app.composer.vim_mode != crate::tui::app::VimMode::Normal =>
4325 {
4326 app.vim_enter_normal();
4327 continue;
4328 }
4329 KeyCode::Esc if app.clear_composer_attachment_selection() => {
4330 continue;
4331 }
4332 KeyCode::Esc if mention_menu_open => {
4333 app.mention_menu_hidden = true;
4334 app.mention_menu_selected = 0;
4335 }
4336 KeyCode::Esc if app.sidebar_hover_tooltip.is_some() => {
4337 app.sidebar_hover_tooltip = None;
4338 app.needs_redraw = true;
4339 }
4340 KeyCode::Esc => {
4341 match next_escape_action(app, slash_menu_open) {
4342 EscapeAction::CloseSlashMenu => {
4343 // A popup-style action wins over backtrack — clear
4344 // any prime so a stale Primed state can't jump us
4345 // straight into Selecting on the next Esc.
4346 app.backtrack.reset();
4347 app.close_slash_menu();
4348 }
4349 EscapeAction::CancelRequest => {
4350 app.backtrack.reset();
4351 if app.paused || app.paused_quarry.is_some() {
4352 clear_paused_command_state(app, &engine_handle);
4353 if app.is_loading
4354 || matches!(
4355 app.runtime_turn_status.as_deref(),
4356 Some("in_progress")
4357 )
4358 {
4359 engine_handle.cancel();
4360 mark_active_turn_cancelled_locally(app);
4361 current_streaming_text.clear();
4362 stream_display_clock.reset();
4363 }
4364 app.active_allowed_tools = None;
4365 app.hunt.quarry = None;
4366 app.hunt.tokens_used = 0;
4367 app.hunt.time_used_seconds = 0;
4368 app.hunt.continuation_count = 0;
4369 app.status_message =
4370 Some(parent_stop_status(app, "Paused command cancelled"));
4371 } else {
4372 engine_handle.cancel();
4373 mark_active_turn_cancelled_locally(app);
4374 current_streaming_text.clear();
4375 stream_display_clock.reset();
4376 app.status_message =
4377 Some(parent_stop_status(app, "Request cancelled"));
4378 }
4379 }
4380 EscapeAction::PauseCommand => {
4381 app.backtrack.reset();
4382 pause_pausable_command(app, &engine_handle);
4383 }
4384 EscapeAction::DiscardQueuedDraft => {
4385 app.backtrack.reset();
4386 if app.cancel_queued_draft_edit() {
4387 app.status_message =
4388 Some("Queued edit canceled; follow-up restored".to_string());
4389 }
4390 }
4391 EscapeAction::ClearInput => {
4392 app.backtrack.reset();
4393 app.edit_in_progress = false;
4394 app.clear_input_recoverable();
4395 let _ = app.maybe_show_behavioral_tip(
4396 crate::tui::behavioral_tips::BehavioralTip::ClearedInputRestore,
4397 );
4398 }
4399 EscapeAction::Noop => {
4400 // Nothing else cares about this Esc — route it
4401 // through the backtrack state machine. While
4402 // streaming or with the live transcript already
4403 // open, fall through silently (#133 acceptance:
4404 // "during streaming Esc-Esc is a silent no-op").
4405 if app.is_loading
4406 || app.view_stack.top_kind() == Some(ModalKind::LiveTranscript)
4407 {
4408 continue;
4409 }
4410 let total = count_user_history_cells(app);
4411 match app.backtrack.handle_esc(total) {
4412 crate::tui::backtrack::EscEffect::None => {}
4413 crate::tui::backtrack::EscEffect::Prime => {
4414 app.status_message =
4415 Some("Press Esc again to backtrack".to_string());
4416 app.needs_redraw = true;
4417 }
4418 crate::tui::backtrack::EscEffect::Cancel => {
4419 app.status_message = Some("Backtrack canceled".to_string());
4420 app.needs_redraw = true;
4421 }
4422 crate::tui::backtrack::EscEffect::OpenOverlay => {
4423 open_backtrack_overlay(app);
4424 }
4425 }
4426 }
4427 }
4428 }
4429 KeyCode::Up if key.modifiers.contains(KeyModifiers::SUPER) => {
4430 app.scroll_up(app.viewport.last_transcript_visible.max(3));
4431 }
4432 KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
4433 app.scroll_up(3);
4434 }
4435 KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => {
4436 app.scroll_up(3);
4437 }
4438 KeyCode::Up
4439 if key.modifiers.is_empty()
4440 && mention_menu_open
4441 && app.mention_menu_selected > 0 =>
4442 {
4443 app.mention_menu_selected = app.mention_menu_selected.saturating_sub(1);
4444 }
4445 KeyCode::Up if key.modifiers.is_empty() && slash_menu_open => {
4446 select_previous_slash_menu_entry(app, slash_menu_entries.len());
4447 }
4448 KeyCode::Char('p')
4449 if key.modifiers.contains(KeyModifiers::CONTROL) && slash_menu_open =>
4450 {
4451 select_previous_slash_menu_entry(app, slash_menu_entries.len());
4452 }
4453 KeyCode::Up
4454 if key.modifiers.is_empty()
4455 && app.selected_composer_attachment_index().is_some() =>
4456 {
4457 let _ = app.select_previous_composer_attachment();
4458 }
4459 KeyCode::Up
4460 if key.modifiers.is_empty()
4461 && app.cursor_position == 0
4462 && !mention_menu_open
4463 && !slash_menu_open
4464 && app.composer_attachment_count() > 0 =>
4465 {
4466 let _ = app.select_previous_composer_attachment();
4467 continue;
4468 }
4469 // #85: ↑ edits the most-recent queued message when the composer
4470 // is idle and the pending-input preview is showing queued work.
4471 KeyCode::Up
4472 if key.modifiers.is_empty()
4473 && app.input.is_empty()
4474 && app.cursor_position == 0
4475 && app.queued_draft.is_none()
4476 && !app.queued_messages.is_empty()
4477 && !mention_menu_open
4478 && !slash_menu_open
4479 && app.selected_composer_attachment_index().is_none() =>
4480 {
4481 let _ = app.pop_last_queued_into_draft();
4482 }
4483 KeyCode::Down if key.modifiers.contains(KeyModifiers::SUPER) => {
4484 app.scroll_down(app.viewport.last_transcript_visible.max(3));
4485 }
4486 KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => {
4487 app.scroll_down(3);
4488 }
4489 KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
4490 app.scroll_down(3);
4491 }
4492 KeyCode::Down if key.modifiers.is_empty() && mention_menu_open => {
4493 app.mention_menu_selected = (app.mention_menu_selected + 1)
4494 .min(mention_menu_entries.len().saturating_sub(1));
4495 }
4496 KeyCode::Down if key.modifiers.is_empty() && slash_menu_open => {
4497 select_next_slash_menu_entry(app, slash_menu_entries.len());
4498 }
4499 KeyCode::Char('n')
4500 if key.modifiers.contains(KeyModifiers::CONTROL) && slash_menu_open =>
4501 {
4502 select_next_slash_menu_entry(app, slash_menu_entries.len());
4503 }
4504 KeyCode::Down
4505 if key.modifiers.is_empty()
4506 && app.selected_composer_attachment_index().is_some() =>
4507 {
4508 let _ = app.select_next_composer_attachment();
4509 }
4510 KeyCode::PageUp => {
4511 let page = app.viewport.last_transcript_visible.max(1);
4512 app.scroll_up(page);
4513 }
4514 KeyCode::PageDown => {
4515 let page = app.viewport.last_transcript_visible.max(1);
4516 app.scroll_down(page);
4517 }
4518 KeyCode::Tab => {
4519 if mention_menu_open
4520 && crate::tui::file_mention::apply_mention_menu_selection(
4521 app,
4522 &mention_menu_entries,
4523 )
4524 {
4525 continue;
4526 }
4527 if slash_menu_open && apply_slash_menu_selection(app, &slash_menu_entries, true)
4528 {
4529 continue;
4530 }
4531 if try_autocomplete_slash_command(app) {
4532 continue;
4533 }
4534 if crate::tui::file_mention::try_autocomplete_file_mention(app) {
4535 continue;
4536 }
4537 if app.input.is_empty()
4538 && let Some(suggestion) = app.prompt_suggestion.take()
4539 {
4540 app.input = suggestion;
4541 app.cursor_position = app.input.chars().count();
4542 app.needs_redraw = true;
4543 continue;
4544 }
4545 // Tab is completion when the composer has content and a
4546 // mode switch only when it is empty. Sending or queueing
4547 // input is reserved for Enter so Tab never changes roles
4548 // based on whether a turn happens to be running.
4549 if !app.input.is_empty() {
4550 continue;
4551 }
4552 let prior_model = app.model.clone();
4553 let prior_mode = app.mode;
4554 app.cycle_mode();
4555 if app.mode != prior_mode {
4556 sync_mode_update(app, &engine_handle).await;
4557 }
4558 if app.model != prior_model {
4559 let _ = engine_handle
4560 .send(Op::SetModel {
4561 model: app.model.clone(),
4562 mode: app.mode,
4563 route_limits: app.active_route_limits,
4564 })
4565 .await;
4566 }
4567 }
4568 // Transcript-nav shortcuts now require Alt, leaving most bare
4569 // letters free to insert as text. Before v0.8.30, bare `g`,
4570 // `G`, `[`, `]`, `?`, and `l` on an empty composer were
4571 // hijacked for navigation — typing "good" yielded "ood" with
4572 // no whale and no warning. The Alt-prefixed shortcuts mirror
4573 // the Alt+R / Alt+C pattern already in use. Shift is
4574 // permitted for most capital-letter forms.
4575 KeyCode::Char('g')
4576 if key_shortcuts::alt_nav_modifiers(key.modifiers)
4577 && app.input.is_empty()
4578 && !slash_menu_open =>
4579 {
4580 if let Some(anchor) =
4581 TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0)
4582 {
4583 app.viewport.transcript_scroll = anchor;
4584 }
4585 }
4586 KeyCode::Char('G')
4587 if key_shortcuts::alt_nav_modifiers(key.modifiers)
4588 && app.input.is_empty()
4589 && !slash_menu_open =>
4590 {
4591 app.scroll_to_bottom();
4592 }
4593 KeyCode::Char('[')
4594 if key_shortcuts::alt_nav_modifiers(key.modifiers)
4595 && app.input.is_empty()
4596 && !slash_menu_open
4597 && !jump_to_adjacent_tool_cell(app, SearchDirection::Backward) =>
4598 {
4599 app.status_message = Some("No previous tool output".to_string());
4600 }
4601 KeyCode::Char(']')
4602 if key_shortcuts::alt_nav_modifiers(key.modifiers)
4603 && app.input.is_empty()
4604 && !slash_menu_open
4605 && !jump_to_adjacent_tool_cell(app, SearchDirection::Forward) =>
4606 {
4607 app.status_message = Some("No next tool output".to_string());
4608 }
4609 // Help chords (Alt+?, F1, Ctrl+/) are handled above via
4610 // shell_key_routing::is_help_shortcut so printable layout
4611 // characters stay text.
4612 // Input handling
4613 _ if is_composer_newline_key(key) => {
4614 app.insert_char('\n');
4615 }
4616 KeyCode::Enter
4617 if mention_menu_open
4618 && crate::tui::file_mention::apply_mention_menu_selection(
4619 app,
4620 &mention_menu_entries,
4621 ) =>
4622 {
4623 continue;
4624 }
4625 // Accept Ctrl+Enter when the terminal reports it distinctly.
4626 // It is deliberately not advertised because several common
4627 // terminals encode it exactly like bare Enter.
4628 _ if is_forced_submit_key(key) => {
4629 let action = app.decide_composer_submit(ComposerSubmitChord::CtrlEnter);
4630 if let Some(input) = app.submit_input() {
4631 if reject_local_input_while_remote(app, &input) {
4632 continue;
4633 }
4634 if handle_bang_shell_input(app, &engine_handle, &input).await? {
4635 continue;
4636 }
4637 if looks_like_slash_command_input(&input) {
4638 if execute_command_input(
4639 terminal,
4640 app,
4641 &mut engine_handle,
4642 &task_manager,
4643 config,
4644 &mut web_config_session,
4645 &input,
4646 )
4647 .await?
4648 {
4649 return Ok(());
4650 }
4651 } else {
4652 let (queued, recovery) = message_from_submitted_input(app, input);
4653 dispatch_composer_message(
4654 app,
4655 config,
4656 &engine_handle,
4657 queued,
4658 recovery,
4659 action,
4660 )
4661 .await?;
4662 }
4663 }
4664 }
4665 KeyCode::Enter => {
4666 let action = app.decide_composer_submit(ComposerSubmitChord::Enter);
4667 // #573: when the user typed a slash-command prefix that
4668 // the popup is matching (e.g. `/mo` → `/model`), Enter
4669 // should run the *highlighted match* rather than
4670 // sending the literal `/mo` text. Only kick in when the
4671 // popup has at least one entry; otherwise fall through
4672 // to the legacy submit path.
4673 let selecting_inline_skill = slash_menu_open
4674 && partial_inline_skill_mention_at_cursor(&app.input, app.cursor_position)
4675 .is_some();
4676 if slash_menu_open
4677 && !slash_menu_entries.is_empty()
4678 && apply_slash_menu_selection(app, &slash_menu_entries, false)
4679 {
4680 app.close_slash_menu();
4681 if selecting_inline_skill {
4682 continue;
4683 }
4684 }
4685 if let Some(input) = app.handle_composer_enter() {
4686 if reject_local_input_while_remote(app, &input) {
4687 continue;
4688 }
4689 // `# foo` quick-add (#492) — when memory is enabled,
4690 // a single line starting with `#` (but not `##` /
4691 // `#!` shebangs / Markdown headings the user might
4692 // be pasting in) is intercepted: the text is
4693 // appended to the user memory file and the input
4694 // is consumed without firing a turn. Disabled
4695 // behaviour falls through to normal turn submit.
4696 if should_intercept_memory_quick_add(config, &input) {
4697 handle_memory_quick_add(app, &input, config);
4698 continue;
4699 }
4700 if handle_bang_shell_input(app, &engine_handle, &input).await? {
4701 continue;
4702 }
4703 if looks_like_slash_command_input(&input) {
4704 if execute_command_input(
4705 terminal,
4706 app,
4707 &mut engine_handle,
4708 &task_manager,
4709 config,
4710 &mut web_config_session,
4711 &input,
4712 )
4713 .await?
4714 {
4715 return Ok(());
4716 }
4717 } else {
4718 let (queued, recovery) = message_from_submitted_input(app, input);
4719 // #383: /edit — if the user invoked /edit to revise
4720 // the last message, undo the last exchange before
4721 // dispatching the replacement. Sync the engine
4722 // session so it also drops the old exchange.
4723 if app.edit_in_progress {
4724 crate::commands::execute("/undo", app);
4725 app.edit_in_progress = false;
4726 let _ = engine_handle
4727 .send(Op::SyncSession {
4728 session_id: app.current_session_id.clone(),
4729 messages: app.api_messages.clone(),
4730 system_prompt: app.system_prompt.clone(),
4731 system_prompt_override: false,
4732 model: app.model.clone(),
4733 workspace: app.workspace.clone(),
4734 mode: app.mode,
4735 })
4736 .await;
4737 }
4738 dispatch_composer_message(
4739 app,
4740 config,
4741 &engine_handle,
4742 queued,
4743 recovery,
4744 action,
4745 )
4746 .await?;
4747 }
4748 }
4749 }
4750 KeyCode::Backspace
4751 if key.modifiers.contains(KeyModifiers::SUPER)
4752 && !app.remove_selected_composer_attachment() =>
4753 {
4754 app.delete_to_start_of_line();
4755 }
4756 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => {}
4757 KeyCode::Backspace
4758 if key.modifiers.contains(KeyModifiers::ALT)
4759 && !app.remove_selected_composer_attachment() =>
4760 {
4761 app.delete_word_backward();
4762 }
4763 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {}
4764 KeyCode::Backspace
4765 if key.modifiers.contains(KeyModifiers::CONTROL)
4766 && !app.remove_selected_composer_attachment() =>
4767 {
4768 app.delete_word_backward();
4769 }
4770 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::CONTROL) => {}
4771 KeyCode::Delete
4772 if key.modifiers.contains(KeyModifiers::ALT)
4773 && !app.remove_selected_composer_attachment() =>
4774 {
4775 app.delete_word_forward();
4776 }
4777 KeyCode::Delete if key.modifiers.contains(KeyModifiers::ALT) => {}
4778 KeyCode::Delete
4779 if key.modifiers.contains(KeyModifiers::CONTROL)
4780 && !app.remove_selected_composer_attachment() =>
4781 {
4782 app.delete_word_forward();
4783 }
4784 KeyCode::Delete if key.modifiers.contains(KeyModifiers::CONTROL) => {}
4785 KeyCode::Backspace if !app.remove_selected_composer_attachment() => {
4786 app.delete_char();
4787 }
4788 KeyCode::Backspace => {}
4789 KeyCode::Char('h')
4790 if key_shortcuts::is_ctrl_h_backspace(&key)
4791 && !app.remove_selected_composer_attachment() =>
4792 {
4793 app.delete_char();
4794 }
4795 KeyCode::Char('h') if key_shortcuts::is_ctrl_h_backspace(&key) => {}
4796 KeyCode::Delete if !app.remove_selected_composer_attachment() => {
4797 app.delete_char_forward();
4798 }
4799 KeyCode::Delete => {}
4800 _ if key_shortcuts::is_select_all_shortcut(&key) => {
4801 app.select_all();
4802 }
4803 KeyCode::Left
4804 if key.modifiers.contains(KeyModifiers::SHIFT)
4805 && is_word_cursor_modifier(key.modifiers) =>
4806 {
4807 if app.selection_anchor.is_none() {
4808 app.selection_anchor = Some(app.cursor_position);
4809 }
4810 app.move_cursor_word_backward();
4811 }
4812 KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => {
4813 if app.selection_anchor.is_none() {
4814 app.selection_anchor = Some(app.cursor_position);
4815 }
4816 app.move_cursor_left();
4817 }
4818 KeyCode::Left if is_word_cursor_modifier(key.modifiers) => {
4819 app.clear_selection();
4820 app.move_cursor_word_backward();
4821 }
4822 KeyCode::Left => {
4823 app.clear_selection();
4824 app.move_cursor_left();
4825 }
4826 KeyCode::Right
4827 if key.modifiers.contains(KeyModifiers::SHIFT)
4828 && is_word_cursor_modifier(key.modifiers) =>
4829 {
4830 if app.selection_anchor.is_none() {
4831 app.selection_anchor = Some(app.cursor_position);
4832 }
4833 app.move_cursor_word_forward();
4834 }
4835 KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => {
4836 if app.selection_anchor.is_none() {
4837 app.selection_anchor = Some(app.cursor_position);
4838 }
4839 app.move_cursor_right();
4840 }
4841 KeyCode::Right if is_word_cursor_modifier(key.modifiers) => {
4842 app.clear_selection();
4843 app.move_cursor_word_forward();
4844 }
4845 KeyCode::Right => {
4846 app.clear_selection();
4847 app.move_cursor_right();
4848 }
4849 // Selection-extending Home/End. Ctrl+Shift extends to the
4850 // buffer edge, bare Shift to the logical line edge. These sit
4851 // above the Ctrl+Home/Ctrl+End transcript-scroll arms so the
4852 // shifted chords always edit the selection, never the
4853 // viewport.
4854 KeyCode::Home
4855 if key.modifiers.contains(KeyModifiers::SHIFT)
4856 && key.modifiers.contains(KeyModifiers::CONTROL) =>
4857 {
4858 if app.selection_anchor.is_none() {
4859 app.selection_anchor = Some(app.cursor_position);
4860 }
4861 app.move_cursor_start();
4862 }
4863 KeyCode::End
4864 if key.modifiers.contains(KeyModifiers::SHIFT)
4865 && key.modifiers.contains(KeyModifiers::CONTROL) =>
4866 {
4867 if app.selection_anchor.is_none() {
4868 app.selection_anchor = Some(app.cursor_position);
4869 }
4870 app.move_cursor_end();
4871 }
4872 KeyCode::Home if key.modifiers.contains(KeyModifiers::SHIFT) => {
4873 if app.selection_anchor.is_none() {
4874 app.selection_anchor = Some(app.cursor_position);
4875 }
4876 app.move_cursor_line_start();
4877 }
4878 KeyCode::End if key.modifiers.contains(KeyModifiers::SHIFT) => {
4879 if app.selection_anchor.is_none() {
4880 app.selection_anchor = Some(app.cursor_position);
4881 }
4882 app.move_cursor_line_end();
4883 }
4884 KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
4885 if let Some(anchor) =
4886 TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0)
4887 {
4888 app.viewport.transcript_scroll = anchor;
4889 }
4890 }
4891 KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => {
4892 app.scroll_to_bottom();
4893 }
4894 KeyCode::Home | KeyCode::Char('a')
4895 if key.modifiers.contains(KeyModifiers::CONTROL) =>
4896 {
4897 app.clear_selection();
4898 app.move_cursor_start();
4899 }
4900 KeyCode::Home => {
4901 app.clear_selection();
4902 app.move_cursor_line_start();
4903 }
4904 KeyCode::End => {
4905 app.clear_selection();
4906 app.move_cursor_line_end();
4907 }
4908 KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4909 app.clear_selection();
4910 app.move_cursor_end();
4911 }
4912 _ if handle_composer_alt_word_motion_key(app, key) => {}
4913 _ if key_shortcuts::is_external_editor_shortcut(&key) => {
4914 // Ctrl+Shift+O (or F4 on terminals that cannot report the
4915 // shifted chord): spawn $EDITOR on the composer contents
4916 // (#91). Plain Ctrl+O belongs exclusively to the Turn
4917 // Inspector, even while the composer holds a draft (#4482).
4918 // Only fires when no modal is active (the !view_stack
4919 // branch above already returns early in that case) and
4920 // the composer is the focused input target. We accept the
4921 // shortcut whether or not a model turn is streaming —
4922 // editing the buffer never disturbs in-flight work.
4923 let seed = app.input.clone();
4924 let editor_result = terminal_input.pause_for_child_terminal().and_then(|()| {
4925 let result = drain_terminal_input_queue(
4926 &terminal_input,
4927 &mut pending_terminal_events,
4928 )
4929 .and_then(|()| {
4930 crate::tui::external_editor::spawn_editor_for_input(
4931 terminal,
4932 app.use_alt_screen,
4933 app.use_mouse_capture,
4934 app.use_bracketed_paste,
4935 &seed,
4936 )
4937 });
4938 terminal_input.resume_after_child_terminal();
4939 force_terminal_repaint = true;
4940 result
4941 });
4942 match editor_result {
4943 Ok(crate::tui::external_editor::EditorOutcome::Edited(new)) => {
4944 app.input = new;
4945 app.move_cursor_end();
4946 let editor = std::env::var("VISUAL")
4947 .ok()
4948 .filter(|s| !s.trim().is_empty())
4949 .or_else(|| {
4950 std::env::var("EDITOR")
4951 .ok()
4952 .filter(|s| !s.trim().is_empty())
4953 })
4954 .unwrap_or_else(|| "vi".to_string());
4955 app.status_message = Some(format!("Edited in {editor}"));
4956 }
4957 Ok(crate::tui::external_editor::EditorOutcome::Unchanged) => {
4958 app.status_message = Some("Editor closed (no changes)".to_string());
4959 }
4960 Ok(crate::tui::external_editor::EditorOutcome::Cancelled) => {
4961 app.status_message = Some("Editor cancelled".to_string());
4962 }
4963 Err(err) => {
4964 app.status_message = Some(format!("Editor error: {err}"));
4965 }
4966 }
4967 app.needs_redraw = true;
4968 }
4969 KeyCode::Up => {
4970 let _ =
4971 handle_composer_history_arrow(app, key, slash_menu_open, mention_menu_open);
4972 }
4973 KeyCode::Down => {
4974 let _ =
4975 handle_composer_history_arrow(app, key, slash_menu_open, mention_menu_open);
4976 }
4977 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4978 app.clear_input_recoverable();
4979 let _ = app.maybe_show_behavioral_tip(
4980 crate::tui::behavioral_tips::BehavioralTip::ClearedInputRestore,
4981 );
4982 }
4983 KeyCode::Char('z')
4984 if key.modifiers.contains(KeyModifiers::CONTROL)
4985 && app.restore_last_cleared_input_if_empty() =>
4986 {
4987 app.status_message = Some("Restored cleared draft".to_string());
4988 }
4989 KeyCode::Char('w') | KeyCode::Char('W')
4990 if key.modifiers.contains(KeyModifiers::CONTROL) =>
4991 {
4992 app.delete_word_backward();
4993 }
4994 KeyCode::Char('s')
4995 | KeyCode::Char('S')
4996 | KeyCode::Char('g')
4997 | KeyCode::Char('G')
4998 if key.modifiers == KeyModifiers::CONTROL =>
4999 {
5000 // #440: park the current draft to the persistent stash and
5001 // clear the composer. Ctrl+G is the terminal-safe alias for
5002 // hosts such as Cursor/VS Code that reserve Ctrl+S for Save.
5003 // Empty composers are a no-op so a stray shortcut cannot
5004 // pollute the file. Surface a toast so the user sees the
5005 // confirmation (no-op feels broken otherwise).
5006 if !app.input.is_empty() {
5007 crate::composer_stash::push_stash(&app.input);
5008 if app.queued_draft.is_some() {
5009 // Stash the edited text while preserving the
5010 // original queued follow-up in its queue slot.
5011 let _ = app.cancel_queued_draft_edit();
5012 } else {
5013 app.clear_input_recoverable();
5014 }
5015 app.push_status_toast(
5016 "Draft stashed — `/stash pop` to restore",
5017 StatusToastLevel::Info,
5018 Some(3_000),
5019 );
5020 }
5021 }
5022 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5023 // #379: context-sensitive Ctrl+Y.
5024 // When the composer has content → emacs-style yank
5025 // from the kill buffer at the cursor.
5026 // When the composer is empty (transcript focus) →
5027 // copy the focused cell text to the system clipboard.
5028 if app.input.is_empty() && app.view_stack.is_empty() {
5029 if copy_focused_cell(app) {
5030 app.push_status_toast(
5031 "Copied to clipboard",
5032 StatusToastLevel::Info,
5033 Some(2_000),
5034 );
5035 } else {
5036 app.status_message = Some("No transcript cell to copy".to_string());
5037 }
5038 } else {
5039 app.yank();
5040 }
5041 }
5042 KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5043 let sel = app.selected_text();
5044 if !sel.is_empty() {
5045 if app.clipboard.write_text(&sel).is_ok() {
5046 app.push_status_toast("Cut to clipboard", StatusToastLevel::Info, None);
5047 app.delete_selection();
5048 } else {
5049 app.push_status_toast("Cut failed", StatusToastLevel::Error, None);
5050 }
5051 }
5052 }
5053 _ if key_shortcuts::is_paste_shortcut(&key) => {
5054 app.paste_from_clipboard();
5055 }
5056 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => {
5057 apply_mode_update(app, &engine_handle, AppMode::Agent).await;
5058 continue;
5059 }
5060 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => {
5061 apply_mode_update(app, &engine_handle, AppMode::Yolo).await;
5062 continue;
5063 }
5064 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => {
5065 apply_mode_update(app, &engine_handle, AppMode::Plan).await;
5066 continue;
5067 }
5068 KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => {
5069 apply_mode_update(app, &engine_handle, AppMode::Agent).await;
5070 continue;
5071 }
5072 KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => {
5073 apply_mode_update(app, &engine_handle, AppMode::Yolo).await;
5074 continue;
5075 }
5076 KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => {
5077 apply_mode_update(app, &engine_handle, AppMode::Plan).await;
5078 continue;
5079 }
5080 // Vim composer: Normal-mode motion / operator keys.
5081 // Only fires when vim is enabled, the input is focused (no modal
5082 // open on top), and the key has no modifier (pure char).
5083 KeyCode::Char(c)
5084 if app.vim_is_normal_mode()
5085 && key.modifiers.is_empty()
5086 && !slash_menu_open
5087 && !mention_menu_open
5088 && app.view_stack.is_empty() =>
5089 {
5090 vim_mode::handle_vim_normal_key(app, c);
5091 continue;
5092 }
5093 // Vim composer: in Visual mode plain chars are ignored
5094 // (no text insertion until `i` / `a` enters Insert).
5095 KeyCode::Char(_)
5096 if app.vim_is_visual_mode()
5097 && key.modifiers.is_empty()
5098 && app.view_stack.is_empty() =>
5099 {
5100 // absorb — Visual mode not yet fully implemented
5101 }
5102 KeyCode::Char(c) if is_plain_char => {
5103 app.insert_char(c);
5104 }
5105 KeyCode::Char(_) => {}
5106 _ => {}
5107 }
5108
5109 if !is_plain_char && !is_plain_enter {
5110 app.paste_burst.deactivate_keep_window();
5111 }
5112 }
5113 }
5114 }
5115
5116 pub(crate) async fn run_cache_warmup(app: &App, config: &Config) -> Result<CacheWarmupOutcome> {
5117 let route = resolve_cache_replay_route(app, config)?
5118 .validate()
5119 .map_err(anyhow::Error::msg)?;
5120 let base_url = route.client.base_url().to_string();
5121 let reasoning_effort = app
5122 .reasoning_effort_api_value_for_replay(route.identity.provider, &base_url, &route.model)
5123 .map(str::to_string);
5124 let request = MessageRequest {
5125 model: route.model.clone(),
5126 messages: app.api_messages.clone(),
5127 max_tokens: 1024,
5128 system: app.system_prompt.clone(),
5129 tools: app.session.last_tool_catalog.clone(),
5130 tool_choice: None,
5131 metadata: None,
5132 thinking: None,
5133 reasoning_effort,
5134 stream: None,
5135 temperature: None,
5136 top_p: None,
5137 };
5138 let warmup = build_cache_warmup_request(&request);
5139 let inspection = inspect_prompt_for_request(&warmup);
5140 let response =
5141 tokio::time::timeout(Duration::from_secs(45), route.client.create_message(warmup))
5142 .await??;
5143 Ok(CacheWarmupOutcome {
5144 usage: response.usage,
5145 provider_identity: route.identity.key,
5146 model: route.model,
5147 base_url,
5148 inspection,
5149 })
5150 }
5151
5152 pub(crate) async fn run_prepared_dispatch(
5153 app: &mut App,
5154 config: &Config,
5155 engine_handle: &EngineHandle,
5156 prepare: UserDispatchPrepare,
5157 recovery: DispatchRecovery,
5158 ) -> Result<()> {
5159 // Unit tests that intentionally omit the production completion mailbox
5160 // apply the result inline. Run the owned async phase as a task just like
5161 // production does so its large future is polled from a clean executor
5162 // stack instead of nesting under the test helper's call chain.
5163 let apply = tokio::spawn(spawned_dispatch_inner(
5164 prepare,
5165 recovery,
5166 engine_handle.clone(),
5167 ))
5168 .await
5169 .map_err(|err| anyhow::anyhow!("dispatch task was lost: {err}"))?;
5170 apply(app, engine_handle, config)
5171 }
5172
5173 pub(crate) async fn run_xai_device_login_from_tui(
5174 terminal: &mut AppTerminal,
5175 app: &mut App,
5176 engine_handle: &mut EngineHandle,
5177 config: &mut Config,
5178 ) -> Result<bool> {
5179 pause_terminal(
5180 terminal,
5181 app.use_alt_screen,
5182 app.use_mouse_capture,
5183 app.use_bracketed_paste,
5184 )?;
5185 let login_result = crate::xai_oauth::device_code_login().await;
5186 resume_terminal(
5187 terminal,
5188 app.use_alt_screen,
5189 app.use_mouse_capture,
5190 app.use_bracketed_paste,
5191 app.synchronized_output_enabled,
5192 )?;
5193
5194 let switched = match login_result {
5195 Ok(pending) => {
5196 apply_codewhale_owned_xai_login(
5197 app,
5198 engine_handle,
5199 config,
5200 pending,
5201 "xAI device login complete",
5202 )
5203 .await
5204 }
5205 Err(err) => {
5206 let message = format!("xAI device login failed: {err}");
5207 app.add_message(HistoryCell::System {
5208 content: message.clone(),
5209 });
5210 app.status_message = Some(message);
5211 false
5212 }
5213 };
5214 app.needs_redraw = true;
5215 Ok(switched)
5216 }
5217
5217 lines RUST