返回 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::clamp_event_poll_timeout;
8 use super::observer_hooks::{
9 execute_session_error_hook, execute_session_state_transition_hooks,
10 execute_turn_end_observer_hook, surface_observer_hook_submission_failure,
11 };
12 use super::task_projection::{
13 refresh_active_task_panel, refresh_automation_panel, refresh_automation_panel_blocking,
14 refresh_shell_exec_live_output,
15 };
16 use super::*;
17 use crate::tui::shell_key_routing::ShellBindingId;
18 use codewhale_models::Role;
19
20 use crate::tui::control_socket::SessionControl;
21
22 pub(super) fn event_owner_is_active(
23 current_session_id: Option<&str>,
24 owner_session_id: &str,
25 ) -> bool {
26 !owner_session_id.is_empty() && current_session_id == Some(owner_session_id)
27 }
28
29 /// Apply only the projection owned by this host session. A delayed SetModel
30 /// receipt from the previous session cannot replace the current transcript.
31 pub(super) fn apply_engine_session_projection(
32 app: &mut App,
33 config: &Config,
34 event: EngineEvent,
35 ) -> bool {
36 let EngineEvent::SessionUpdated {
37 session_id,
38 messages,
39 system_prompt,
40 model,
41 workspace,
42 } = event
43 else {
44 return false;
45 };
46 // SetModel can emit the old session while a host-owned
47 // SyncSession is still queued. Reject that entire stale
48 // projection before changing transcript or persistence.
49 if !event_owner_is_active(app.current_session_id.as_deref(), &session_id) {
50 tracing::debug!(
51 expected = ?app.current_session_id,
52 received = %session_id,
53 "ignoring stale engine session projection"
54 );
55 return false;
56 }
57 if app.last_known_goal_state.is_some()
58 && let Err(error) = persist_current_session_goal(app)
59 {
60 surface_goal_persistence_failure(app, &error);
61 }
62 app.context_token_cache.borrow_mut().clear();
63 app.set_api_messages(messages);
64 // #6190: the projection is the engine's own record, so it is where a
65 // steer's acceptance becomes observable — and the only place the steer's
66 // real message index is known. Promote before anything else reads the
67 // transcript, so live order equals record order by construction.
68 crate::tui::ui::dispatch::settle_accepted_steers(app);
69 app.system_prompt = system_prompt;
70 if app.auto_model {
71 app.last_effective_model = Some(model);
72 } else {
73 app.set_model_selection(model);
74 }
75 app.update_model_compaction_budget();
76 if app.workspace != workspace {
77 apply_workspace_runtime_state(app, config, workspace);
78 }
79 if (app.is_loading || app.is_compacting || app.is_purging)
80 && let Ok(manager) = SessionManager::default_location()
81 {
82 if let Ok(session) = build_session_snapshot(app, &manager) {
83 app.session_title = Some(session.metadata.title.clone());
84 // The engine's session id was pinned above, so
85 // every checkpoint of this session lands in the
86 // same per-session file.
87 if let Err(err) =
88 persist_with_pending_work_boundary(app, PersistRequest::SaveCheckpoint { session })
89 {
90 app.status_message = Some(format!(
91 "To-do list update pending: checkpoint could not be queued ({err})"
92 ));
93 }
94 }
95 } else if app.session_title.is_none() {
96 // Never synchronously reload the growing session
97 // JSON on the event-loop task just to recover a
98 // title. The in-memory metadata cache is authoritative.
99 let cached = app
100 .current_session_metadata
101 .as_ref()
102 .filter(|metadata| metadata.id == session_id)
103 .map(|metadata| metadata.title.clone());
104 app.session_title = cached.or_else(|| derive_session_title(&app.api_messages));
105 }
106 true
107 }
108
109 fn current_session_fleet_workers_status(
110 locale: codewhale_localization::Locale,
111 count: usize,
112 ) -> String {
113 codewhale_localization::tr(
114 locale,
115 codewhale_localization::MessageId::SubagentsCurrentSessionFleetWorkersStatus,
116 )
117 .replace("{count}", &count.to_string())
118 }
119
120 /// Host state can change without a model turn, including learning the Runtime
121 /// binding of a resumed legacy session. Commit that state before clearing its
122 /// recovery checkpoint; an unfinished turn keeps its checkpoint untouched.
123 pub(super) fn persist_settled_session_on_shutdown(
124 app: &mut App,
125 handle: &persistence_actor::PersistActorHandle,
126 ) -> Result<bool, String> {
127 if app.is_loading || app.dispatch_in_flight || app.current_session_id.is_none() {
128 return Ok(false);
129 }
130 let manager = SessionManager::default_location().map_err(|error| error.to_string())?;
131 let session = build_session_snapshot(app, &manager)?;
132 if !handle.try_send(PersistRequest::CompletedCommit { session }) {
133 return Err("persistence actor is unavailable during shutdown".into());
134 }
135 Ok(true)
136 }
137
138 #[derive(Debug)]
139 struct TranslationAccountingContext {
140 cost_scope: crate::cost_status::CostScopeToken,
141 origin_session_id: Option<String>,
142 origin_turn_id: Option<String>,
143 source_id: String,
144 }
145
146 struct SettledTranslation {
147 translated: anyhow::Result<String>,
148 usage: Option<codewhale_models::Usage>,
149 }
150
151 impl TranslationAccountingContext {
152 fn capture(app: &App, kind: &str, sequence: u64) -> Self {
153 let raw_source = format!(
154 "translation:{}:{}:{kind}:{sequence}",
155 app.current_session_id.as_deref().unwrap_or("no-session"),
156 app.runtime_turn_id.as_deref().unwrap_or("no-turn")
157 );
158 Self {
159 cost_scope: crate::cost_status::scope_token(),
160 origin_session_id: app.current_session_id.clone(),
161 origin_turn_id: app.runtime_turn_id.clone(),
162 source_id: format!(
163 "translation:{}",
164 crate::cost_status::usage_source_fingerprint(&raw_source)
165 ),
166 }
167 }
168
169 fn settle(
170 self,
171 response: anyhow::Result<crate::client::TranslationProviderResponse>,
172 ) -> SettledTranslation {
173 let response = match response {
174 Ok(response) => response,
175 Err(error) => {
176 return SettledTranslation {
177 translated: Err(error),
178 usage: None,
179 };
180 }
181 };
182 if let Some(usage) = response.usage.as_ref() {
183 if let (Some(session_id), Some(turn_id)) = (
184 self.origin_session_id.as_deref(),
185 self.origin_turn_id.as_deref(),
186 ) {
187 crate::cost_status::report_effective_route_for_interactive_origin(
188 self.cost_scope,
189 session_id,
190 turn_id,
191 &self.source_id,
192 &response.route,
193 usage,
194 );
195 } else {
196 crate::cost_status::report_effective_route_for_runtime(
197 self.cost_scope,
198 None,
199 &self.source_id,
200 &response.route,
201 usage,
202 );
203 }
204 } else {
205 if let (Some(session_id), Some(turn_id)) = (
206 self.origin_session_id.as_deref(),
207 self.origin_turn_id.as_deref(),
208 ) {
209 crate::cost_status::report_unreceipted_for_interactive_origin(
210 self.cost_scope,
211 session_id,
212 turn_id,
213 &self.source_id,
214 &response.route,
215 );
216 } else {
217 crate::cost_status::report_unreceipted_provider_success(
218 self.cost_scope,
219 None,
220 &self.source_id,
221 &response.route,
222 );
223 }
224 }
225 SettledTranslation {
226 translated: response.translated,
227 usage: response.usage,
228 }
229 }
230 }
231
232 fn accrue_translation_usage(app: &mut App, usage: &codewhale_models::Usage) {
233 let turn_tokens = usage.input_tokens.saturating_add(usage.output_tokens);
234 app.session.total_tokens = app.session.total_tokens.saturating_add(turn_tokens);
235 app.session.total_conversation_tokens = app
236 .session
237 .total_conversation_tokens
238 .saturating_add(turn_tokens);
239 app.session.total_input_tokens = app
240 .session
241 .total_input_tokens
242 .saturating_add(usage.input_tokens);
243 app.session.total_output_tokens = app
244 .session
245 .total_output_tokens
246 .saturating_add(usage.output_tokens);
247 if usage.prompt_cache_hit_tokens.is_some()
248 || usage.prompt_cache_miss_tokens.is_some()
249 || usage.prompt_cache_write_tokens.is_some()
250 {
251 let classes = crate::pricing::token_usage_for_pricing(usage);
252 app.session.total_cache_hit_tokens = app
253 .session
254 .total_cache_hit_tokens
255 .saturating_add(u32::try_from(classes.cache_read).unwrap_or(u32::MAX));
256 app.session.total_cache_miss_tokens = app
257 .session
258 .total_cache_miss_tokens
259 .saturating_add(u32::try_from(classes.input).unwrap_or(u32::MAX));
260 app.session.total_cache_write_tokens = app
261 .session
262 .total_cache_write_tokens
263 .saturating_add(u32::try_from(classes.cache_write).unwrap_or(u32::MAX));
264 }
265 }
266
267 fn translation_origin(app: &App) -> (Option<String>, Option<String>) {
268 // Fixed-size one-way identities avoid retaining raw imported ids in a
269 // detached completion envelope without introducing truncation aliases.
270 let fingerprint = |value: Option<&str>| value.map(crate::cost_status::usage_source_fingerprint);
271 (
272 fingerprint(app.current_session_id.as_deref()),
273 fingerprint(app.runtime_turn_id.as_deref()),
274 )
275 }
276
277 fn translation_origin_is_current(
278 app: &App,
279 origin_session_fingerprint: Option<&str>,
280 origin_turn_fingerprint: Option<&str>,
281 ) -> bool {
282 let current = translation_origin(app);
283 current.0.as_deref() == origin_session_fingerprint
284 && current.1.as_deref() == origin_turn_fingerprint
285 }
286
287 fn translation_session_is_current(app: &App, origin_session_fingerprint: Option<&str>) -> bool {
288 translation_origin(app).0.as_deref() == origin_session_fingerprint
289 }
290
291 fn exact_translation_client(
292 config: &Config,
293 route: &crate::core::events::TurnRoute,
294 ) -> anyhow::Result<Arc<CodewhaleClient>> {
295 let identity = config
296 .resolve_persisted_provider_identity(
297 Some(route.provider.as_str()),
298 Some(&route.provider_identity),
299 )
300 .map_err(anyhow::Error::msg)?;
301 let validated = crate::route_runtime::resolve_runtime_route_for_identity(
302 config,
303 &identity,
304 Some(&route.model),
305 )
306 .map_err(anyhow::Error::msg)?
307 .validate()
308 .map_err(anyhow::Error::msg)?;
309 if validated.identity.key != route.provider_identity
310 || validated.model != route.model
311 || validated.candidate.endpoint().base_url != route.base_url
312 {
313 anyhow::bail!(
314 "translation route changed after turn dispatch; refusing to reuse a different provider client"
315 );
316 }
317 if let Some(receipt) = route.receipt.as_ref()
318 && &validated
319 .client
320 .turn_route_receipt(&route.provider_identity)
321 != receipt
322 {
323 anyhow::bail!(
324 "translation credential or endpoint changed after turn dispatch; refusing stale completion ownership"
325 );
326 }
327 Ok(Arc::new(validated.client))
328 }
329
330 /// Bind the Runtime thread store to a session before the process-owner lock
331 /// is taken, so a second Codewhale on the same machine does not collide on
332 /// the default root (#5630). This id is only the initial store anchor; saved
333 /// metadata retains the actual store binding when launch creates a new id.
334 pub(crate) fn ensure_runtime_session_id(app: &mut App) -> String {
335 if let Some(existing) = app
336 .current_session_id
337 .as_deref()
338 .map(str::trim)
339 .filter(|id| !id.is_empty())
340 {
341 return existing.to_string();
342 }
343 let session_id = uuid::Uuid::new_v4().to_string();
344 app.current_session_id = Some(session_id.clone());
345 session_id
346 }
347
348 fn persist_current_session_goal(app: &App) -> Result<(), String> {
349 let session_id = app
350 .current_session_id
351 .as_deref()
352 .ok_or_else(|| "session id is not established".to_string())?;
353 let manager = SessionManager::default_location()
354 .map_err(|error| format!("could not open the session store: {error}"))?;
355 manager
356 .save_session_goal(session_id, app.last_known_goal_state.as_ref())
357 .map_err(|error| error.to_string())
358 }
359
360 pub(crate) fn surface_goal_persistence_failure(app: &mut App, error: &str) {
361 app.push_status_toast(
362 format!("Goal progress is not durable yet: {error}"),
363 StatusToastLevel::Warning,
364 None,
365 );
366 }
367
368 /// Apply Space only to the owner stored by the final render pass.
369 pub(super) fn handle_transcript_space(app: &mut App) -> bool {
370 let Some((owner, reasoning_target)) = app.viewport.transcript_cache.take_transcript_action()
371 else {
372 return false;
373 };
374 let idx = owner.cell_index;
375 if owner.identity_epoch != app.transcript_identity_epoch {
376 return false;
377 }
378 let Some(cell) = app.cell_at_virtual_index(idx) else {
379 return false;
380 };
381 let is_thinking = matches!(cell, HistoryCell::Thinking { .. });
382 if let Some(target) = reasoning_target.filter(|_| !app.collapsed_cells.contains(&idx)) {
383 if target.owner != owner {
384 return false;
385 }
386 if !app.show_thinking || !is_thinking {
387 return false;
388 }
389 let options = app.transcript_render_options();
390 let folded = !(options.verbose || options.thinking_default_expanded)
391 ^ (target.action == ReasoningAction::Collapse);
392 app.folded_thinking.remove(&idx);
393 if folded {
394 app.folded_thinking.insert(idx);
395 }
396 } else if app.toggle_tool_run_expansion_at(idx) {
397 return true;
398 } else if !app.collapsed_cells.remove(&idx) {
399 if is_thinking {
400 return false;
401 }
402 app.collapsed_cells.insert(idx);
403 }
404 app.mark_history_updated();
405 true
406 }
407
408 /// Route plain input that must be decided before the composer sees it.
409 ///
410 /// The raw-paste fallback intentionally holds the first ASCII character for
411 /// a few milliseconds. Space must use that same ambiguity window: a second
412 /// rapid character proves it was paste payload, while a lone held Space can
413 /// become the rendered transcript action when the hold expires.
414 pub(super) fn handle_plain_key_before_composer(
415 app: &mut App,
416 key: &KeyEvent,
417 now: Instant,
418 ) -> bool {
419 crate::tui::paste::handle_paste_burst_key(app, key, now)
420 }
421
422 /// Flush a raw-paste ambiguity window without losing a leading Space.
423 ///
424 /// `FlushResult::Paste` is always composer payload. A lone typed Space is a
425 /// transcript action only when the composer is still empty and the last
426 /// rendered owner accepts it; otherwise it remains ordinary input.
427 pub(super) fn flush_paste_burst_before_composer(app: &mut App, now: Instant) -> bool {
428 if !app.view_stack.is_empty() {
429 // One grammar buffer: a modal owns keys. Held burst must not leak
430 // into the composer (leaky `/model` after the picker opens).
431 app.paste_burst.clear_after_explicit_paste();
432 return false;
433 }
434 match app.take_paste_burst_flush_if_enabled(now) {
435 crate::tui::paste_burst::FlushResult::Paste(text) => {
436 app.insert_str(&text);
437 true
438 }
439 crate::tui::paste_burst::FlushResult::Typed(' ')
440 if app.input.is_empty() && handle_transcript_space(app) =>
441 {
442 true
443 }
444 crate::tui::paste_burst::FlushResult::Typed(ch) => {
445 app.insert_char(ch);
446 true
447 }
448 crate::tui::paste_burst::FlushResult::SuppressionExpired => {
449 app.needs_redraw = true;
450 true
451 }
452 crate::tui::paste_burst::FlushResult::None => false,
453 }
454 }
455
456 /// The shell's key admission, asked exactly as the event loop asks it: which
457 /// binding does this key press, for whoever owns the keyboard right now?
458 ///
459 /// Every seam below calls this instead of re-deriving focus from
460 /// `view_stack`, `launch.visible`, or — the bug this replaces — whether the
461 /// composer happens to hold text.
462 pub(crate) fn shell_binding_for_key(app: &App, key: &KeyEvent) -> Option<ShellBindingId> {
463 crate::tui::shell_key_routing::route(app.focus(), key)
464 }
465
466 /// What pressing Tab did — see [`dispatch_tab_key`].
467 #[derive(Debug, PartialEq, Eq)]
468 pub(crate) enum TabDispatch {
469 /// One of the composer's own completions consumed the key.
470 Completion,
471 /// Nobody owns Tab in this focus state.
472 Ignored,
473 /// The session mode cycled. The caller syncs the engine.
474 ModeCycled {
475 prior_mode: AppMode,
476 prior_model: String,
477 },
478 }
479
480 /// Tab dispatch, lifted out of the event loop body so a test can press Tab.
481 ///
482 /// The composer's completions get the key first: a mention menu, a slash
483 /// menu, an in-progress command or file mention, a waiting prompt
484 /// suggestion. Those are genuine composer *editing* questions about the
485 /// text. Once none of them claims the key, Tab is the shell's mode cycle,
486 /// admitted by [`App::focus`] alone — whether the composer holds text is not
487 /// part of that decision. It used to be: `if !app.input.is_empty()
488 /// { continue; }` killed Tab the moment the user typed anything.
489 pub(crate) fn dispatch_tab_key(
490 app: &mut App,
491 key: &KeyEvent,
492 mention_menu_entries: &[String],
493 slash_menu_entries: &[crate::tui::widgets::SlashMenuEntry],
494 ) -> TabDispatch {
495 if !mention_menu_entries.is_empty()
496 && crate::tui::file_mention::apply_mention_menu_selection(app, mention_menu_entries)
497 {
498 return TabDispatch::Completion;
499 }
500 if !slash_menu_entries.is_empty() && apply_slash_menu_selection(app, slash_menu_entries, true) {
501 return TabDispatch::Completion;
502 }
503 if try_autocomplete_slash_command(app) {
504 return TabDispatch::Completion;
505 }
506 if crate::tui::file_mention::try_autocomplete_file_mention(app) {
507 return TabDispatch::Completion;
508 }
509 if app.input.is_empty()
510 && let Some(suggestion) = app.prompt_suggestion.take()
511 {
512 app.input = suggestion;
513 app.cursor_position = app.input.chars().count();
514 app.needs_redraw = true;
515 return TabDispatch::Completion;
516 }
517 if shell_binding_for_key(app, key) != Some(ShellBindingId::ModeCycle) {
518 return TabDispatch::Ignored;
519 }
520 // Sending or queueing input is reserved for Enter, so Tab never changes
521 // roles based on whether a turn happens to be running.
522 let prior_model = app.model.clone();
523 let prior_mode = app.mode;
524 app.cycle_mode();
525 app.note_footer_hint_used(crate::tui::footer_hints::MODE_CYCLE);
526 TabDispatch::ModeCycled {
527 prior_mode,
528 prior_model,
529 }
530 }
531
532 /// Whether a mouse event is a wheel/trackpad scroll in any direction.
533 fn is_scroll_event(mouse: &crossterm::event::MouseEvent) -> bool {
534 matches!(
535 mouse.kind,
536 crossterm::event::MouseEventKind::ScrollUp
537 | crossterm::event::MouseEventKind::ScrollDown
538 | crossterm::event::MouseEventKind::ScrollLeft
539 | crossterm::event::MouseEventKind::ScrollRight
540 )
541 }
542
543 /// Bound on how many scroll events one gesture may fold into a single frame,
544 /// so a stuck wheel cannot starve the draw.
545 const MAX_COALESCED_SCROLLS: usize = 64;
546
547 /// Apply every queued scroll event of the current gesture except the last,
548 /// and return that last one for the caller to handle normally.
549 ///
550 /// A trackpad emits a burst of scroll events. Handling them one per loop
551 /// iteration meant one frame each, and the frame limiter then spaced those
552 /// frames out, so the scroll arrived as a slow crawl long after the fingers
553 /// stopped. Resize events have been coalesced this way since #65; scroll
554 /// never was. The scroll handlers only accumulate into
555 /// `viewport.pending_scroll_delta`, so folding the burst in costs one cheap
556 /// call each and exactly one draw for the whole gesture.
557 ///
558 /// A non-scroll event ends the burst and is pushed back unread.
559 pub(crate) fn coalesce_scroll_burst(
560 app: &mut App,
561 first: crossterm::event::MouseEvent,
562 input: &TerminalInputPump,
563 pending: &mut VecDeque<ObservedTerminalEvent>,
564 ) -> std::io::Result<crossterm::event::MouseEvent> {
565 if !is_scroll_event(&first) {
566 return Ok(first);
567 }
568 let mut latest = first;
569 for _ in 0..MAX_COALESCED_SCROLLS {
570 let Some(next_observed) = try_next_terminal_event(input, pending)? else {
571 break;
572 };
573 match &next_observed.event {
574 Event::Mouse(next) if is_scroll_event(next) => {
575 let _ = handle_mouse_event(app, latest);
576 latest = *next;
577 }
578 _ => {
579 pending.push_back(next_observed);
580 break;
581 }
582 }
583 }
584 Ok(latest)
585 }
586
587 /// Run the interactive TUI event loop.
588 ///
589 /// # Examples
590 ///
591 /// ```ignore
592 /// # use crate::config::Config;
593 /// # use crate::tui::TuiOptions;
594 /// # async fn example(config: &Config, options: TuiOptions) -> anyhow::Result<()> {
595 /// crate::tui::run_tui(config, options).await
596 /// # }
597 /// ```
598 pub async fn run_tui(
599 config: &Config,
600 options: TuiOptions,
601 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
602 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
603 ) -> Result<()> {
604 // Install notification, sound, category, and attention policy before any
605 // producer (including the model-facing notify tool) can emit an event.
606 let _ = crate::tui::notifications::settings(config);
607 let startup_screen_mode = options.screen_mode;
608 let use_alt_screen = startup_screen_mode.uses_alt_screen();
609 let use_mouse_capture = options.use_mouse_capture;
610 let use_bracketed_paste = options.use_bracketed_paste;
611
612 // Apply OSC 8 hyperlink toggle from config.
613 //
614 // #3029: OSC 8 hyperlinks are emitted out-of-band. Markdown wrapping keeps
615 // visible spans and per-line targets in separate structures; each render
616 // seam translates those targets into absolute `LinkRegion`s without ever
617 // placing an escape byte in a ratatui buffer cell. `ColorCompatBackend`
618 // then emits the OSC 8 escapes through its `Write` impl around the matching
619 // cell runs. Hyperlinks are on by default for terminals that handle the OSC
620 // terminator (`ESC \`) cleanly. Windows legacy consoles (conhost) still
621 // mishandle the terminator, so the default stays off there; opt in via
622 // `[tui] osc8_links = true` on any platform.
623 let osc8_default_on = !cfg!(target_os = "windows");
624 crate::tui::osc8::set_enabled(
625 config
626 .tui
627 .as_ref()
628 .and_then(|tui| tui.osc8_links)
629 .unwrap_or(osc8_default_on),
630 );
631
632 // Fail fast with a clear message when the interactive TUI is launched
633 // without a controlling TTY (#4716). Without this, enable_raw_mode fails
634 // with opaque "Device not configured" / "Input/output error" and some
635 // terminal hosts surface only "[Process completed]".
636 require_interactive_terminal(io::stdin().is_terminal(), io::stdout().is_terminal())?;
637 require_foreground_terminal_owner()?;
638
639 // #6169: install the suspend/resume handshake here — after the
640 // foreground-ownership check (the termios snapshot needs the still-cooked
641 // tty) and before raw mode, so every mode enabled below has a handler that
642 // can undo it. Not in `lib.rs`: this must not run for the non-TUI
643 // subcommands.
644 job_control_guard::install_job_control_guard();
645
646 // This sets local terminal attributes; it is not a terminal-response probe.
647 // Do it on the owning thread, as on resume, so blocking-pool scheduling
648 // cannot abort startup or leave a detached worker enabling raw mode later.
649 enable_raw_mode().context("Failed to enable raw mode")?;
650
651 #[cfg(target_os = "windows")]
652 enable_windows_ime_console_mode();
653
654 let mut stdout = io::stdout();
655 // Initialize the file-backed TUI log and redirect raw stderr away from
656 // the alt-screen for the lifetime of this guard. MUST run BEFORE
657 // EnterAlternateScreen; otherwise logging between alt-screen entry and
658 // redirect init leaks raw bytes into the TUI buffer, causing the "scroll
659 // demon" on Windows (#1909) and garbled output on all platforms (#1085).
660 // The guard is held until the function returns; dropping it after
661 // LeaveAlternateScreen restores the original stderr handle/fd so shutdown
662 // messages reach the user's terminal. We accept the init failing (e.g.,
663 // read-only $HOME) and continue without the redirect rather than refusing
664 // to start the TUI.
665 let _tui_log_guard = match crate::runtime_log::init() {
666 Ok(guard) => Some(guard),
667 Err(err) => {
668 tracing::warn!(target: "runtime_log", ?err, "TUI log init failed; stderr leaks may render as scroll-demon");
669 None
670 }
671 };
672 if use_alt_screen {
673 enter_alt_screen(&mut stdout)?;
674 // Windows also suppresses Codewhale's own verbose CLI logger while
675 // the alt-screen is active. The stderr redirect above catches raw
676 // writes; this prevents the known verbose source at the origin.
677 #[cfg(windows)]
678 crate::logging::snapshot_verbose_state();
679 #[cfg(windows)]
680 crate::logging::set_verbose(false);
681 }
682 // Mouse capture, bracketed paste, focus events, and the Kitty
683 // keyboard-protocol escape-disambiguation flag (#442). Single source
684 // of truth shared with the FocusGained recovery path and
685 // resume_terminal — see recover_terminal_modes.
686 //
687 // Focus events are necessary for IME compositor re-activation on
688 // macOS when the user switches away (Cmd+Tab) and returns. The Kitty
689 // keyboard protocol opt-in is best-effort: terminals that don't
690 // support it (iTerm2, Terminal.app, Windows 10 conhost) silently
691 // discard the escape, while supporting terminals (Kitty, Ghostty,
692 // Alacritty 0.13+, WezTerm, recent Konsole, recent xterm) report
693 // unambiguous events for Option/Alt-modified keys and plain Esc.
694 //
695 // Only `DISAMBIGUATE_ESCAPE_CODES` is pushed — the higher tiers
696 // (`REPORT_EVENT_TYPES`, `REPORT_ALL_KEYS_AS_ESCAPE_CODES`) emit
697 // release events that the existing key handlers would mis-route
698 // as duplicate presses.
699 //
700 // On Windows, crossterm's `PushKeyboardEnhancementFlags` command always
701 // reports the terminal as unsupported (`is_ansi_code_supported` returns
702 // false), so the escape is written directly instead. VSCode's integrated
703 // terminal and Windows Terminal ≥1.17 honour the kitty keyboard protocol
704 // and will correctly disambiguate Shift+Enter from plain Enter once this
705 // sequence is received. Terminals that do not understand it silently
706 // ignore it.
707 recover_terminal_modes(&mut stdout, use_mouse_capture, use_bracketed_paste);
708 // The guard reads the *live* screen and disables capture unconditionally,
709 // so a runtime `/inline` or `/fullscreen` switch cannot leave it emitting
710 // the wrong teardown escape.
711 let mut cleanup_guard = TerminalCleanupGuard {
712 use_bracketed_paste,
713 defused: false,
714 };
715 let color_depth = palette::ColorDepth::detect();
716 // Raw mode is on and the event loop has not started, which is the only
717 // window where the OSC 11 background query is safe to issue — see
718 // `palette::probe_terminal_background`. The result is cached process-wide,
719 // so every later `PaletteMode::detect()` sees the same answer.
720 let background = palette::probe_terminal_background();
721 // Same window, same reason: the kitty graphics capability query answers
722 // on stdin, so it is asked before the input pump exists.
723 let kitty_graphics = crate::tui::mark::probe_kitty_graphics();
724 // Same window again: the sixel probe is a primary-DA query whose reply
725 // also arrives on stdin. Keep both capability receipts before input starts.
726 let sixel_graphics = crate::tui::mark::probe_sixel_graphics();
727 let palette_mode = background.mode();
728 tracing::debug!(
729 ?color_depth,
730 ?palette_mode,
731 background_source = ?background.source(),
732 background_color = ?background.color(),
733 kitty_graphics,
734 sixel_graphics,
735 "terminal color profile detected"
736 );
737 let mut backend = ColorCompatBackend::new(stdout, color_depth, palette_mode);
738 backend.set_detected_background(background.color());
739 let mut terminal = build_app_terminal(backend, startup_screen_mode)?;
740 // At this point Settings hasn't loaded yet, so we can't read the
741 // user's `synchronized_output` knob. Use the same env-based terminal
742 // quirk detection that `Settings::apply_env_overrides` uses, so the
743 // startup viewport reset matches what every later draw will do on
744 // flicker-sensitive hosts. A user who has explicitly set
745 // `synchronized_output = "on"` to override detection will get sync wrap
746 // from the main draw loop onward; the one-time startup viewport reset
747 // stays opt-out for them, which is the safe default because the cost is
748 // at most brief tearing on the first frame.
749 let sync_output_at_init = !crate::settings::detected_ptyxis_terminal()
750 && !crate::settings::detected_legacy_windows_console_host();
751 reset_terminal_viewport(&mut terminal, sync_output_at_init)?;
752 let event_broker = EventBroker::new();
753
754 // Local mutable copy so runtime config flips (e.g. `/provider` switch)
755 // can rebuild the API client without restarting the process.
756 let mut config = config.clone();
757 let config = &mut config;
758 let mut app = App::new_with_plugin_registry(options.clone(), config, plugin_registry);
759 let _cursor_accent_guard = crate::tui::cursor_accent::CursorAccentGuard::install(
760 app.low_motion || !app.fancy_animations,
761 app.ui_theme.accent_primary,
762 );
763 crate::startup_trace::mark("app_constructed");
764 sync_config_provider_from_app(config, &app);
765 surface_prompt_override_notices(&mut app);
766
767 if options.resume_session_id.is_none() && !app.launch.visible {
768 let opened_setup = open_setup_checkpoint_if_due(&mut app, config, options.skip_onboarding);
769 // One-time Fleet + Hotbar intro for returning (non-resuming) users.
770 // First-time users see it when they finish onboarding. Gated by a
771 // persisted flag, so it shows exactly once and never inside a resumed
772 // session transcript or behind the constitution checkpoint.
773 if !opened_setup {
774 app.maybe_show_feature_intro();
775 }
776 }
777
778 // Load existing session if resuming.
779 if let Some(ref session_id) = options.resume_session_id
780 && let Ok(manager) = SessionManager::default_location()
781 {
782 // Try to load by prefix or full ID
783 let load_result: std::io::Result<Option<crate::session_manager::SavedSession>> =
784 if session_id == "latest" {
785 // Special case: resume the most recent session in this workspace.
786 match manager.get_latest_session_for_workspace(&options.workspace) {
787 Ok(Some(meta)) => manager
788 .resume_session(&meta.id)
789 .map(|recovery| Some(recovery.session)),
790 Ok(None) => Ok(None),
791 Err(e) => Err(e),
792 }
793 } else {
794 manager
795 .resume_session_by_prefix(session_id)
796 .map(|recovery| Some(recovery.session))
797 };
798
799 match load_result {
800 Ok(Some(saved)) => match manager.load_session_goal(&saved.metadata.id) {
801 Ok(goal) => {
802 match apply_loaded_session_with_goal(&mut app, config, &saved, goal.as_ref()) {
803 Ok(()) => {
804 app.status_message = Some(format!(
805 "Resumed session: {}",
806 crate::session_manager::truncate_id(&saved.metadata.id)
807 ));
808 }
809 Err(err) => {
810 crate::tui::ui::session_state::surface_session_load_failure(
811 &mut app,
812 format!("Failed to restore session: {err}"),
813 );
814 }
815 }
816 }
817 Err(err) => {
818 crate::tui::ui::session_state::surface_session_load_failure(
819 &mut app,
820 format!("Failed to restore session goal: {err}"),
821 );
822 }
823 },
824 Ok(None) => {
825 crate::tui::ui::session_state::surface_session_load_failure(
826 &mut app,
827 "No sessions found to resume".to_string(),
828 );
829 }
830 Err(e) => {
831 crate::tui::ui::session_state::surface_session_load_failure(
832 &mut app,
833 format!("Failed to load session: {e}"),
834 );
835 }
836 }
837 }
838
839 // Auto-resume's receipt (#2934). It overrides the generic resume message
840 // because it is the more specific truth: it names what was reattached, or
841 // why nothing was. It never overwrites a *failure* message from the load
842 // path above — a real error outranks a decision receipt.
843 if let Some(notice) = options.startup_notice.clone()
844 && app
845 .status_message
846 .as_deref()
847 .is_none_or(|current| !current.starts_with("Failed to"))
848 {
849 app.status_message = Some(notice);
850 }
851
852 let session_id = ensure_runtime_session_id(&mut app);
853 let transition =
854 prepare_offline_queue_transition(&app, &session_id).map_err(anyhow::Error::msg)?;
855 let restored_offline_queue = install_offline_queue_transition(&mut app, transition)
856 || !app.queued_messages.is_empty()
857 || app.queued_draft.is_some();
858 if restored_offline_queue && app.status_message.is_none() && app.queued_message_count() > 0 {
859 app.status_message = Some(format!(
860 "Restored {} queued message(s) from previous session — ↑ to edit, Ctrl+X to discard",
861 app.queued_message_count()
862 ));
863 }
864
865 let task_manager = TaskManager::start(
866 TaskManagerConfig::from_runtime(
867 config,
868 app.workspace.clone(),
869 Some(app.model.clone()),
870 Some(app.max_subagents.clamp(1, 4)),
871 ),
872 config.clone(),
873 std::sync::Arc::clone(&app.plugin_registry),
874 &session_id,
875 app.current_session_metadata
876 .as_ref()
877 .and_then(|metadata| metadata.runtime_store.as_ref()),
878 )
879 .await?;
880 if let Some(saved) = app
881 .current_session_metadata
882 .as_ref()
883 .and_then(|meta| meta.runtime_store.as_ref())
884 && task_manager
885 .session_store_binding()
886 .as_ref()
887 .is_some_and(|current| current != saved)
888 {
889 app.push_status_toast(
890 app.tr(MessageId::RuntimeStoreRecovered).into_owned(),
891 StatusToastLevel::Warning,
892 None,
893 );
894 }
895 let _task_shutdown = task_manager.shutdown_guard();
896 let mut automation_service = AutomationManager::default_location()?;
897 automation_service.bind_task_manager(&task_manager)?;
898 let automations = std::sync::Arc::new(tokio::sync::Mutex::new(automation_service));
899 let automation_cancel = tokio_util::sync::CancellationToken::new();
900 let automation_scheduler = spawn_scheduler(
901 automations.clone(),
902 task_manager.clone(),
903 automation_cancel.clone(),
904 AutomationSchedulerConfig::default(),
905 );
906 let shell_manager = app
907 .runtime_services
908 .shell_manager
909 .clone()
910 .unwrap_or_else(|| crate::tools::shell::new_shared_shell_manager(app.workspace.clone()));
911 // #2511: ensure hook_executor is initialized for fresh sessions — it is
912 // only set by apply_workspace_runtime_state (session resume / workspace
913 // switch), so a brand-new session would otherwise leave it None and both
914 // exec_shell shell_env hooks and ToolCallBefore gate would silently no-op.
915 if app.runtime_services.hook_executor.is_none() {
916 app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
917 }
918 app.runtime_services = RuntimeToolServices {
919 shell_manager: Some(shell_manager),
920 persist_services_enabled: false,
921 task_manager: Some(task_manager.clone()),
922 automations: Some(automations),
923 task_data_dir: Some(task_manager.data_dir()),
924 active_task_id: None,
925 active_thread_id: None,
926 dynamic_tool_executor: None,
927 work: app.runtime_services.work.clone(),
928 // #456: plumb the App's HookExecutor so `exec_shell` can surface
929 // the configured `shell_env` hooks. Clone the shared Arc.
930 hook_executor: app.runtime_services.hook_executor.clone(),
931 handle_store: app.runtime_services.handle_store.clone(),
932 rlm_sessions: app.runtime_services.rlm_sessions.clone(),
933 media_originals_dir: crate::media_originals::default_store_dir(),
934 };
935 crate::startup_trace::mark("task_manager_ready");
936 refresh_active_task_panel(&mut app, &task_manager).await;
937 refresh_automation_panel_blocking(&mut app).await;
938
939 // A `[redaction] model_bound = "disabled"` request lowers the model-bound
940 // masking boundary only after an explicit one-time confirmation on this
941 // startup gate. Arm the gate before the engine spawns so it owns the first
942 // screen; answering it rebuilds the engine with the confirmed mode.
943 app.redaction_gate = crate::tui::redaction_gate::confirmation_required(config);
944
945 // Restore before admitting initial input, including resumed conversations.
946 let engine_handle = spawn_tui_engine_with_session(&mut app, config).await?;
947 crate::startup_trace::mark("engine_spawned");
948 // The translation client is optional: it never crashes the TUI on
949 // startup, even when the API key is missing, the base URL is malformed,
950 // or the network is unavailable.
951 // Translations are skipped with a logged warning until a key is saved.
952 let translation_client = match CodewhaleClient::new(config) {
953 Ok(client) => Some(Arc::new(client)),
954 Err(err) => {
955 if app.onboarding == OnboardingState::None {
956 tracing::warn!("Translation client initialization failed: {err}");
957 }
958 None
959 }
960 };
961
962 // Fire session start hook
963 {
964 let context = app.base_hook_context();
965 // Captured before the hook executor moves `context` into its blocking
966 // task; the outbox emit below needs the same session identity.
967 let outbox_thread_id = context.session_id.clone().unwrap_or_default();
968 let outbox_mode = context.mode.clone();
969 let outbox_model = context.model.clone();
970 let outbox_workspace = context.workspace.clone();
971 let hooks = app.hooks.clone();
972 if let Err(error) =
973 tokio::task::spawn_blocking(move || hooks.execute(HookEvent::SessionStart, &context))
974 .await
975 {
976 tracing::error!(target: "hooks", %error, "session_start executor task was lost");
977 app.status_message = Some("session_start hook executor did not run".to_string());
978 }
979 // Lifecycle outbox (`[lifecycle_outbox]`): fires alongside the
980 // session_start hook, with the same session identity. No-op when
981 // the feature is disabled.
982 app.lifecycle_outbox.emit(codewhale_hooks::LifecycleEvent {
983 event: "session_start".to_string(),
984 kind: "session.started".to_string(),
985 thread_id: outbox_thread_id,
986 turn_id: None,
987 item_id: None,
988 payload: serde_json::json!({
989 "mode": outbox_mode,
990 "model": outbox_model,
991 "workspace": outbox_workspace
992 .as_ref()
993 .map(|path| path.display().to_string()),
994 }),
995 });
996 }
997
998 // Spawn the persistence actor so checkpoint/session-save I/O stays off
999 // the UI thread. The actor serialises + writes to disk in a dedicated
1000 // task; the UI just `try_send`s a request and returns immediately.
1001 let persistence_runtime = SessionManager::default_location()
1002 .ok()
1003 .map(|persist_manager| {
1004 let (handle, task) = persistence_actor::spawn_persistence_actor(persist_manager);
1005 persistence_actor::init_actor(handle.clone());
1006 (handle, task)
1007 });
1008
1009 // Re-park the queue restored above, now that the actor exists. Its clear
1010 // request carries no session id, so the actor learns which session owns
1011 // the parked file from a save — without this, draining a restored queue
1012 // to empty would leave the file behind and resend it on the next boot.
1013 if restored_offline_queue {
1014 persist_offline_queue_state(&app);
1015 }
1016
1017 // Returning users recovering a missing key open the picker immediately so
1018 // recovery cannot silently replace a persisted route. First-run users
1019 // start on Welcome; Enter shows the provider explanation, and a second
1020 // Enter opens the picker.
1021 if app.onboarding == OnboardingState::Provider && app.onboarding_missing_key_recovery {
1022 open_onboarding_provider_picker(&mut app, config, &engine_handle, true).await;
1023 }
1024
1025 // #4605: create the dispatch completion channel before any submit path so
1026 // initial input and queued follow-ups can dispatch without blocking the
1027 // startup sequence.
1028 // At most one user dispatch is allowed in flight. A two-slot completion
1029 // mailbox covers the hook stage plus the send stage without turning a
1030 // stalled UI into an unbounded queue of captured App mutations.
1031 let (dispatch_completion_tx, dispatch_completion_rx) =
1032 tokio::sync::mpsc::channel::<crate::tui::app::DispatchApplyFn>(2);
1033 app.dispatch_completion_tx = Some(dispatch_completion_tx);
1034
1035 if std::mem::take(&mut app.start_remote_control_on_launch) {
1036 start_remote_control_session(&mut app, config);
1037 }
1038 submit_initial_input_if_ready(&mut app, config, &engine_handle).await?;
1039
1040 crate::startup_trace::log_summary();
1041 // Pin the cold-start measurement at the same moment the summary is logged.
1042 // `log_summary` computes the same number into a local, emits it, clears its
1043 // buffer, and returns `()`, so this reads `PROCESS_START` directly rather
1044 // than through it. Only this path calls it, which is what keeps the
1045 // cold-start bucket absent on surfaces with no event loop.
1046 crate::startup_trace::mark_cold_start();
1047 let result = run_event_loop(
1048 &mut terminal,
1049 &mut app,
1050 config,
1051 engine_handle,
1052 task_manager.clone(),
1053 &event_broker,
1054 translation_client,
1055 pending_telemetry_notice,
1056 dispatch_completion_rx,
1057 )
1058 .await;
1059 automation_cancel.cancel();
1060 automation_scheduler.abort();
1061 if let Err(error) = task_manager.shutdown_and_wait().await {
1062 tracing::error!(%error, "Task manager shutdown remains incomplete");
1063 }
1064
1065 // Join the startup-default writer before anything else tears down.
1066 //
1067 // The last thing a user does before quitting is very often the selection
1068 // they most want to survive — Tab into Operate, then Ctrl+C. Those writes
1069 // are queued off the event loop on purpose, so at this point one may still
1070 // be in flight or not yet started. Draining here is what makes "the last
1071 // immediate selection lands" true rather than a race against process exit.
1072 //
1073 // Failures are collected, not toasted: the event loop has already drawn its
1074 // final frame, so a toast would never be painted. They are printed below,
1075 // after the alternate screen is gone and stderr is back on the user's real
1076 // terminal.
1077 let startup_default_failures = app.startup_defaults.shutdown();
1078 for failure in &startup_default_failures {
1079 tracing::warn!(
1080 target: "settings",
1081 subjects = ?failure.subjects,
1082 detail = %failure.detail,
1083 "startup default was not persisted before shutdown",
1084 );
1085 }
1086 let startup_default_failures: Vec<String> = startup_default_failures
1087 .iter()
1088 .map(|failure| app.startup_default_failure_message(failure))
1089 .collect();
1090
1091 // Fire session end hook
1092 {
1093 let context = app.base_hook_context();
1094 let _ = app.execute_hooks(HookEvent::SessionEnd, &context);
1095 // Lifecycle outbox (`[lifecycle_outbox]`): fires alongside the
1096 // session_end hook, with the same session identity. No-op when
1097 // the feature is disabled.
1098 app.lifecycle_outbox.emit(codewhale_hooks::LifecycleEvent {
1099 event: "session_end".to_string(),
1100 kind: "session.ended".to_string(),
1101 thread_id: context.session_id.clone().unwrap_or_default(),
1102 turn_id: None,
1103 item_id: None,
1104 payload: serde_json::json!({
1105 "workspace": context.workspace
1106 .as_ref()
1107 .map(|path| path.display().to_string()),
1108 "total_tokens": context.total_tokens,
1109 }),
1110 });
1111 }
1112
1113 // Keep the final session/turn receipts ahead of runtime teardown. A failed
1114 // observability sink must not prevent the user's session from shutting down.
1115 if let Err(error) = app.lifecycle_outbox.flush(Duration::from_secs(2)).await {
1116 tracing::warn!(target: "lifecycle_outbox", %error, "TUI lifecycle outbox did not drain before exit");
1117 }
1118
1119 // Flush the persistence actor, collect the durability report (write
1120 // failures are surfaced, not discarded), then shut down gracefully.
1121 //
1122 // The session's crash-recovery checkpoint is cleared only for a settled
1123 // session. While a turn is in flight (or a spawned dispatch has not yet
1124 // applied), the checkpoint is the only durable record of that work:
1125 // clearing it here unconditionally could erase in-flight progress that
1126 // never reached a snapshot, so it survives for startup recovery review.
1127 if let Some((handle, task)) = persistence_runtime {
1128 // A quit key can leave the frame before its usual queue comparison.
1129 // Capture the final edited draft before the shutdown durability barrier.
1130 persist_offline_queue_state(&app);
1131 let turn_in_flight = app.is_loading || app.dispatch_in_flight;
1132 if turn_in_flight {
1133 tracing::info!(
1134 target: "persistence",
1135 "shutdown preserves the in-flight checkpoint for recovery review"
1136 );
1137 } else if let Err(error) = persist_settled_session_on_shutdown(&mut app, &handle) {
1138 tracing::warn!(
1139 target: "persistence",
1140 %error,
1141 "session snapshot could not be queued during shutdown; checkpoint retained"
1142 );
1143 }
1144 let (report_tx, report_rx) = tokio::sync::oneshot::channel();
1145 handle.try_send(PersistRequest::FlushAndReport { reply: report_tx });
1146 if let Ok(report) = report_rx.await
1147 && !report.failures.is_empty()
1148 {
1149 tracing::warn!(
1150 target: "persistence",
1151 failures = ?report.failures,
1152 "session persistence reported write failures during shutdown",
1153 );
1154 }
1155 handle.try_send(PersistRequest::Shutdown);
1156 let _ = task.await;
1157 }
1158
1159 cleanup_guard.defused = true;
1160 crate::tui::cursor_accent::restore_cursor_accent();
1161 pop_keyboard_enhancement_flags(terminal.backend_mut());
1162 disable_alternate_scroll_mode(terminal.backend_mut());
1163 execute!(terminal.backend_mut(), DisableFocusChange)?;
1164 disable_raw_mode()?;
1165 // `/inline` and `/fullscreen` can have moved the screen since startup; the
1166 // teardown must match the screen the terminal is actually on.
1167 if app.use_alt_screen() {
1168 leave_alt_screen(terminal.backend_mut())?;
1169 #[cfg(windows)]
1170 crate::logging::restore_verbose_state();
1171 }
1172 if app.use_mouse_capture {
1173 execute!(terminal.backend_mut(), DisableMouseCapture)?;
1174 }
1175 if use_bracketed_paste {
1176 disable_bracketed_paste_mode(terminal.backend_mut());
1177 }
1178 terminal.show_cursor()?;
1179 drop(terminal);
1180
1181 // Back on the primary screen, so this is somewhere the user can actually
1182 // read. A settings write that did not land would otherwise be invisible
1183 // until the next launch quietly came up in the old mode.
1184 for failure in &startup_default_failures {
1185 tracing::error!(target: "settings", "{failure}");
1186 // Printed AFTER `LeaveAlternateScreen` / `drop(terminal)`, so this is on
1187 // the restored primary screen. The module-level
1188 // `#![deny(clippy::print_stderr)]` would otherwise refuse it.
1189 #[allow(clippy::print_stderr)]
1190 {
1191 eprintln!("codewhale: {failure}");
1192 }
1193 }
1194
1195 if result.is_ok()
1196 && let Some(hint) = resume_hint_text(
1197 app.ui_locale,
1198 app.current_session_id.as_deref(),
1199 io::stdout().is_terminal(),
1200 )
1201 {
1202 // Printed AFTER `LeaveAlternateScreen` / `drop(terminal)` above,
1203 // so we're back on the primary screen — this is the one
1204 // legitimate stdout write in the TUI module tree. The
1205 // module-level `#![deny(clippy::print_stdout)]` would otherwise
1206 // refuse it.
1207 #[allow(clippy::print_stdout)]
1208 {
1209 println!("{hint}");
1210 }
1211 }
1212
1213 result
1214 }
1215
1216 /// Submit the pre-session composer's message as the first message of a new
1217 /// session.
1218 ///
1219 /// The startup screen owns the keyboard until a real session exists, so a
1220 /// send from its composer first begins the launch session through the same
1221 /// `begin_launch_session` path the startup rows use, then hands the
1222 /// submitted text to the ordinary composer dispatch branches (memory quick-
1223 /// add, `!` shell, `/` command, message). There is still exactly one turn
1224 /// loop: this only routes input into `Engine::run_turn` like any other
1225 /// composer submit.
1226 ///
1227 /// Ordering is draft-loss-proof: the composer draft is consumed only after
1228 /// the launch transition has been applied. A paste-burst absorption never
1229 /// begins a session, and if applying the transition fails after it began,
1230 /// the draft is still sitting in the composer for the user to resubmit —
1231 /// the failure can never erase it.
1232 #[allow(clippy::too_many_arguments)]
1233 async fn dispatch_launch_composer_submit(
1234 terminal: &mut AppTerminal,
1235 app: &mut App,
1236 engine_handle: &mut EngineHandle,
1237 task_manager: &SharedTaskManager,
1238 config: &mut Config,
1239 chord: ComposerSubmitChord,
1240 ) -> Result<bool> {
1241 if app.launch.return_to_session {
1242 app.launch.dismiss();
1243 return dispatch_session_composer_submit(
1244 terminal,
1245 app,
1246 engine_handle,
1247 task_manager,
1248 config,
1249 chord,
1250 )
1251 .await;
1252 }
1253 let action = app.decide_composer_submit(chord);
1254 if app.startup_input_unproven || !app.composer_enter_would_submit() {
1255 // A paste burst, empty composer or startup integrity hold owns this
1256 // Enter. Apply that guard without creating an empty session.
1257 app.handle_composer_enter();
1258 return Ok(false);
1259 }
1260 let result = begin_launch_session(app, None);
1261 if apply_command_result(terminal, app, engine_handle, task_manager, config, result).await? {
1262 return Ok(true);
1263 }
1264 // The transition is applied; only now consume the draft it carries.
1265 let Some(input) = app.handle_composer_enter() else {
1266 return Ok(false);
1267 };
1268 if should_intercept_memory_quick_add(config, &input) {
1269 handle_memory_quick_add(app, &input, config);
1270 return Ok(false);
1271 }
1272 if handle_bang_shell_input(app, engine_handle, &input).await? {
1273 return Ok(false);
1274 }
1275 if looks_like_slash_command_input(&input) {
1276 // Commands own their output; only model-bound prompts become user turns.
1277 if execute_command_input(terminal, app, engine_handle, task_manager, config, &input).await?
1278 {
1279 return Ok(true);
1280 }
1281 } else {
1282 let (queued, recovery) = message_from_submitted_input(app, input);
1283 dispatch_composer_message(app, config, engine_handle, queued, recovery, action).await?;
1284 }
1285 Ok(false)
1286 }
1287
1288 /// Submit the live-session composer through the same branches Enter uses.
1289 ///
1290 /// Mouse `[↵]` sets `pending_composer_submit`; this consumes that chord without
1291 /// duplicating draft consumption or opening transcript-only Enter shortcuts.
1292 /// Its own gates (`SendQueuedNow`, the paste-burst probe) run here; everything
1293 /// from slash-menu selection onward is the shared `submit_decided_composer_input`
1294 /// tail the keyboard Enter arm also uses, so the two surfaces cannot drift.
1295 #[allow(clippy::too_many_arguments)]
1296 async fn dispatch_session_composer_submit(
1297 terminal: &mut AppTerminal,
1298 app: &mut App,
1299 engine_handle: &mut EngineHandle,
1300 task_manager: &SharedTaskManager,
1301 config: &mut Config,
1302 chord: ComposerSubmitChord,
1303 ) -> Result<bool> {
1304 if app.launch.return_to_session {
1305 app.launch.dismiss();
1306 }
1307 let action = app.decide_composer_submit(chord);
1308 if matches!(action, ComposerSubmitAction::SendQueuedNow) {
1309 let _ = send_next_queued_message_now(app, config, engine_handle).await?;
1310 return Ok(false);
1311 }
1312 if !app.composer_enter_would_submit() {
1313 return Ok(false);
1314 }
1315 submit_decided_composer_input(terminal, app, engine_handle, task_manager, config, action).await
1316 }
1317
1318 /// Shared tail of a decided composer submit: slash-menu selection, draft
1319 /// consumption, and the memory/`!`/`/`/message branches.
1320 ///
1321 /// Keyboard Enter and the mouse `[↵]` dispatcher both end here. Each caller
1322 /// keeps its own gates — transcript-only shortcuts and forced-submit chords
1323 /// stay keyboard-only, `SendQueuedNow` and the paste-burst probe stay in the
1324 /// dispatcher — so this tail is the one place either surface can change.
1325 /// Returns `true` only when a command asked the event loop to exit.
1326 #[allow(clippy::too_many_arguments)]
1327 async fn submit_decided_composer_input(
1328 terminal: &mut AppTerminal,
1329 app: &mut App,
1330 engine_handle: &mut EngineHandle,
1331 task_manager: &SharedTaskManager,
1332 config: &mut Config,
1333 action: ComposerSubmitAction,
1334 ) -> Result<bool> {
1335 // #573: when the user typed a slash-command prefix that the popup is
1336 // matching (e.g. `/mo` → `/model`), submit runs the *highlighted match*
1337 // rather than sending the literal `/mo` text. Only kick in when the
1338 // popup has at least one entry; otherwise fall through to the legacy
1339 // submit path.
1340 let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
1341 let slash_menu_open = !slash_menu_entries.is_empty();
1342 let selecting_inline_skill = slash_menu_open
1343 && partial_inline_skill_mention_at_cursor(&app.input, app.cursor_position).is_some();
1344 if slash_menu_open && apply_slash_menu_selection(app, &slash_menu_entries, false) {
1345 app.close_slash_menu();
1346 if selecting_inline_skill {
1347 return Ok(false);
1348 }
1349 }
1350
1351 let Some(input) = app.handle_composer_enter() else {
1352 return Ok(false);
1353 };
1354 // `# foo` quick-add (#492) — when memory is enabled, a single line
1355 // starting with `#` (but not `##` / `#!` shebangs / Markdown headings
1356 // the user might be pasting in) is intercepted: the text is appended to
1357 // the user memory file and the input is consumed without firing a turn.
1358 // Disabled behaviour falls through to normal turn submit.
1359 if should_intercept_memory_quick_add(config, &input) {
1360 handle_memory_quick_add(app, &input, config);
1361 return Ok(false);
1362 }
1363 if handle_bang_shell_input(app, engine_handle, &input).await? {
1364 return Ok(false);
1365 }
1366 if looks_like_slash_command_input(&input) {
1367 // Opening a view is not a conversation turn. SendMessage actions
1368 // record their real prompt through dispatch_composer_message instead.
1369 if execute_command_input(terminal, app, engine_handle, task_manager, config, &input).await?
1370 {
1371 return Ok(true);
1372 }
1373 } else {
1374 // #383: /edit — if the user invoked /edit to revise the last
1375 // message, undo the last exchange before dispatching the
1376 // replacement. Sync the engine session so it also drops the old
1377 // exchange.
1378 if app.edit_in_progress {
1379 crate::commands::execute("/undo", app);
1380 app.edit_in_progress = false;
1381 let _ = engine_handle
1382 .send(Op::SyncSession {
1383 session_id: app.current_session_id.clone(),
1384 messages: app.api_messages.as_ref().clone(),
1385 system_prompt: app.system_prompt.clone(),
1386 system_prompt_override: false,
1387 model: app.model.clone(),
1388 workspace: app.workspace.clone(),
1389 mode: app.mode,
1390 })
1391 .await;
1392 }
1393 let (queued, recovery) = message_from_submitted_input(app, input);
1394 dispatch_composer_message(app, config, engine_handle, queued, recovery, action).await?;
1395 }
1396 Ok(false)
1397 }
1398
1399 #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
1400 pub(crate) async fn run_event_loop(
1401 terminal: &mut AppTerminal,
1402 app: &mut App,
1403 config: &mut Config,
1404 mut engine_handle: EngineHandle,
1405 task_manager: SharedTaskManager,
1406 event_broker: &EventBroker,
1407 translation_client: Option<Arc<CodewhaleClient>>,
1408 mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
1409 mut dispatch_completion_rx: tokio::sync::mpsc::Receiver<crate::tui::app::DispatchApplyFn>,
1410 ) -> Result<()> {
1411 // Track streaming state
1412 let mut current_streaming_text = String::new();
1413 let mut stream_display_clock = StreamDisplayClock::default();
1414 let (translation_tx, mut translation_rx) =
1415 tokio::sync::mpsc::unbounded_channel::<TranslationEvent>();
1416 let fallback_translation_client = translation_client;
1417 let mut active_translation_client = fallback_translation_client.clone();
1418 let mut active_translation_route: Option<crate::core::events::TurnRoute> = None;
1419 let mut translation_sequence = 0_u64;
1420 let mut pending_translations = 0usize;
1421 // #5931: the background runtime's own store faults arrive on its event
1422 // channel, which nothing else in this loop reads.
1423 let mut runtime_event_rx = task_manager.subscribe_runtime_events();
1424 let mut pending_thinking_translations = 0usize;
1425 let mut last_queue_state = offline_queue_projection(app);
1426 let mut last_queue_was_empty = app.queued_messages.is_empty() && app.queued_draft.is_none();
1427 let mut last_task_refresh = Instant::now()
1428 .checked_sub(Duration::from_secs(2))
1429 .unwrap_or_else(Instant::now);
1430 let mut last_status_frame = Instant::now()
1431 .checked_sub(Duration::from_millis(UI_STATUS_ANIMATION_MS))
1432 .unwrap_or_else(Instant::now);
1433 // 120 FPS draw cap. Without this we redraw on every SSE chunk during a
1434 // long stream — wasted work the user can't perceive. See
1435 // `tui::frame_rate_limiter` for the rationale; ports the small piece of
1436 // codex's frame coalescing that maps cleanly onto our poll-based loop.
1437 // Measured display Hz may raise the floor toward the panel refresh rate
1438 // (still never faster than MIN_FRAME_INTERVAL); low_motion always wins.
1439 let mut frame_rate_limiter = crate::tui::frame_rate_limiter::FrameRateLimiter::default();
1440 {
1441 let probe = crate::tui::display_refresh::probe_display_refresh();
1442 frame_rate_limiter.set_adaptive_interval(Some(
1443 crate::tui::display_refresh::draw_min_interval_for_hz(probe.hz, false),
1444 ));
1445 }
1446 // Widgets request future animation frames here; the poll loop remains the
1447 // sole `terminal.draw` emitter (no competing animation loop).
1448 let mut frame_requester = FrameRequester::new();
1449 // Per-session control socket (`[control_socket]`): disabled unless the
1450 // config enables it; even then, nothing binds until the owned session id
1451 // appears (see the per-iteration reconcile below).
1452 let mut session_control = SessionControl::new(
1453 config
1454 .control_socket
1455 .as_ref()
1456 .is_some_and(|socket| socket.enabled),
1457 );
1458 let mut prev_input_snapshot = String::new();
1459 let mut terminal_paused_at: Option<Instant> = None;
1460 // Last observed coarse turn state for the session-state hook transitions
1461 // (#6004); `None` until the first publish records it without firing.
1462 let mut previous_turn_state = None;
1463 let mut force_terminal_repaint = false;
1464 // #6311: while the terminal reports unfocused, frames are pure backlog
1465 // (GTK3 defers all VTE damage on occlusion and replays it on return).
1466 // Event ingestion continues; only `terminal.draw` emission is gated.
1467 let mut terminal_unfocused = false;
1468 // FocusGained debounce: some terminal emulators (e.g. Tabby) re-trigger
1469 // FocusGained when we re-arm focus-change reporting inside
1470 // recover_terminal_modes, creating a tight repaint loop. Skip
1471 // mode recovery (but still mark a repaint) within the debounce window.
1472 const FOCUS_RECOVERY_DEBOUNCE: Duration = Duration::from_millis(200);
1473 let mut last_focus_recovery = Instant::now()
1474 .checked_sub(Duration::from_secs(60))
1475 .unwrap_or_else(Instant::now);
1476 // #5925: the startup terminal probes (OSC 11 background, kitty graphics,
1477 // sixel primary-DA) were the only readers of the tty until now, and they
1478 // consumed whatever the user had already typed. Replay it into the same
1479 // queue the pump feeds — and do it *before* the pump is spawned, so those
1480 // keys are delivered ahead of anything still sitting in the tty rather
1481 // than behind it.
1482 let mut replayed_startup_events = VecDeque::new();
1483 let startup_input_receipt =
1484 crate::tui::startup_input::replay_into(&mut replayed_startup_events);
1485 let startup_input_observed_at = Instant::now();
1486 let mut pending_terminal_events: VecDeque<ObservedTerminalEvent> = replayed_startup_events
1487 .into_iter()
1488 .map(|event| ObservedTerminalEvent::new(event, startup_input_observed_at))
1489 .collect();
1490 // When startup could not account for every byte it consumed, the shell
1491 // cannot prove it saw the whole line. The composer holds the next submit
1492 // instead of sending text it cannot vouch for.
1493 app.startup_input_unproven = !startup_input_receipt.whole_line_proven();
1494 let mut terminal_input = TerminalInputPump::spawn()?;
1495 let mut last_terminal_input_recovery = Instant::now()
1496 .checked_sub(TERMINAL_INPUT_RECOVERY_COOLDOWN)
1497 .unwrap_or_else(Instant::now);
1498 let mut last_recovery_snapshot_at: Option<Instant> = None;
1499 // Fire-and-forget version check — runs once per session in the
1500 // background. On success, a short status toast advertises the update
1501 // without replacing the user's configured footer/status-line chips.
1502 let mut version_check: Option<tokio::task::JoinHandle<Option<UpdateNotice>>> =
1503 spawn_startup_version_check(config.update_config());
1504 // First-run / missing-key: if a live local Ollama catalog answers, adopt a
1505 // real /api/tags model into chrome instead of leaving the DeepSeek costume.
1506 let mut local_ollama_probe: Option<
1507 tokio::task::JoinHandle<Option<crate::local_ollama::LiveLocalOllamaCatalog>>,
1508 > = crate::local_ollama::spawn_local_ollama_adoption_probe(
1509 config,
1510 crate::local_ollama::should_adopt_live_local_ollama(app),
1511 );
1512
1513 // Startup version-change hint: once per version, never on first run.
1514 // `record_launch` owns the semantics (strict semver forward move, corrupt
1515 // record = silent rewrite, downgrade records without hinting); this only
1516 // renders the outcome. Local bookkeeping — independent of the network
1517 // update check, and skipped entirely when home cannot be resolved.
1518 if let Ok(home) = codewhale_config::codewhale_home() {
1519 let outcome = codewhale_release::record_launch(&home, env!("CARGO_PKG_VERSION"));
1520 if let Some(record_error) = outcome.record_error {
1521 tracing::debug!(error = %record_error, "could not persist the last-launch record");
1522 }
1523 if let Some(change) = outcome.change {
1524 let content = app
1525 .tr(MessageId::UpdateChangedHint)
1526 .replace("{previous}", &change.previous)
1527 .replace("{current}", &change.current);
1528 app.add_message(HistoryCell::System { content });
1529 app.needs_redraw = true;
1530 }
1531 }
1532
1533 // Fire a one-shot initial remaining-credit fetch for prepaid
1534 // providers so the footer chip can show on the first frame without
1535 // waiting for a turn to complete.
1536 if !app.balance_initiated {
1537 let api_key = config.active_route_api_key().unwrap_or_default();
1538 let base_url = config.active_route_base_url();
1539 schedule_balance_fetch(app, &api_key, &base_url, false);
1540 app.balance_initiated = true;
1541 }
1542
1543 let mut pending_subagent_list_refresh = false;
1544
1545 loop {
1546 // #6169: first statement of every iteration. The job-control handler can
1547 // stop this process mid-turn (SIGTSTP, or SIGTTIN once the group is
1548 // backgrounded) after restoring the terminal from inside the handler.
1549 // SIGCONT only records that the stop happened; the rebuild happens here,
1550 // in normal context, where crossterm is safe to call.
1551 //
1552 // Two deferrals, both deliberate: a child owning the tty is handled by
1553 // the pause/resume block further down (it rebuilds the modes itself), and
1554 // a group that is still background (a plain `bg`) must not touch the
1555 // terminal at all — re-entering raw mode and the alternate screen would
1556 // steal the shell's tty. The state is left pending either way, so the
1557 // rebuild still runs on the iteration after `fg`.
1558 if job_control_guard::take_resume()
1559 && !event_broker.is_paused()
1560 && require_foreground_terminal_owner().is_ok()
1561 {
1562 job_control_guard::mark_resumed();
1563 resume_terminal(
1564 terminal,
1565 app.use_alt_screen(),
1566 app.use_mouse_capture,
1567 app.use_bracketed_paste,
1568 app.synchronized_output_enabled,
1569 )?;
1570 event_broker.resume_events();
1571 // The input pump is deliberately not told about this: it is only
1572 // ever gated by `pause_terminal_input_for_child` /
1573 // `resume_after_child_terminal`, and calling the latter here would
1574 // falsely clear a child's gate.
1575 app.status_message = Some("Resumed after suspend".to_string());
1576 app.needs_redraw = true;
1577 force_terminal_repaint = true;
1578 }
1579
1580 if app.onboarding == OnboardingState::None && pending_telemetry_notice.take().is_some() {
1581 let receipt = app.tr(MessageId::TelemetryNoticeDefaultOn);
1582 app.push_status_toast(receipt.into_owned(), StatusToastLevel::Info, Some(12_000));
1583 app.needs_redraw = true;
1584 crate::telemetry_notice::record_presented();
1585 }
1586
1587 // A manual compaction deferred by a full engine mailbox retries here
1588 // each iteration until a slot frees or a live pass supersedes it.
1589 flush_deferred_manual_compaction(app, config, &engine_handle);
1590 // Any fleet mutation since the last iteration (`/fleet add|remove`,
1591 // ⇧F, auto-enroll) reaches the engine here, through the one roster
1592 // path the saved-fleet views already use.
1593 flush_stale_fleet_roster(app, config, &engine_handle);
1594 // Goal controls are accepted only after their bounded sidecar is
1595 // durable. Mailbox backpressure must therefore defer delivery, never
1596 // block keyboard input or silently drop the accepted control.
1597 flush_pending_goal_controls(app, &engine_handle);
1598
1599 // Per-session control socket: rebind when the owned session id
1600 // changes, republish the `status` snapshot, and execute queued
1601 // verbs on the UI thread. A verb that asks for quit (the `relaunch`
1602 // seam) exits the loop through the ordinary `/exit` teardown.
1603 session_control.reconcile(app.current_session_id.as_deref());
1604 session_control.update_status(app);
1605 execute_session_state_transition_hooks(app, &mut previous_turn_state);
1606 if session_control
1607 .drain(
1608 app,
1609 config,
1610 &engine_handle,
1611 &mut current_streaming_text,
1612 &mut stream_display_clock,
1613 )
1614 .await
1615 {
1616 return Ok(());
1617 }
1618
1619 while let Some(completion) = app.clipboard.poll_write_completion() {
1620 if let Err(err) = completion {
1621 tracing::warn!(error = %err, "background terminal clipboard write failed");
1622 app.push_status_toast(
1623 format!("Clipboard copy failed: {err}"),
1624 StatusToastLevel::Error,
1625 None,
1626 );
1627 app.needs_redraw = true;
1628 }
1629 }
1630
1631 // Drain dispatch completions from spawned send tasks (#4605). The
1632 // closure receives `&mut App` and applies success state or rollback.
1633 while let Ok(apply) = dispatch_completion_rx.try_recv() {
1634 let _ = apply(app, &engine_handle, &*config);
1635 }
1636
1637 // Drain the version-check handle once; re-assign None so we
1638 // don't poll it again.
1639 let mut done = false;
1640 if let Some(ref handle) = version_check {
1641 done = handle.is_finished();
1642 }
1643 if done && let Ok(Some(notice)) = version_check.take().unwrap().await {
1644 // Transient toast for immediate visibility, plus a durable
1645 // in-transcript notice so the prompt survives the toast TTL and
1646 // stays actionable during a busy session (#3961). The persistent
1647 // header chip keeps a quiet affordance after both (#14).
1648 // Which command to advertise depends on who owns this binary on
1649 // disk, so resolve that here rather than hardcoding our own
1650 // updater into the wording.
1651 let install = codewhale_release::current_install_method();
1652 app.update_available = Some(notice.chip_label());
1653 app.push_status_toast(
1654 notice.toast_line(install),
1655 StatusToastLevel::Info,
1656 Some(VERSION_HINT_TOAST_TTL_MS),
1657 );
1658 app.add_message(HistoryCell::System {
1659 content: notice.notice_block(install),
1660 });
1661 }
1662
1663 // Adopt a live local Ollama tag into first-run / missing-key chrome.
1664 let mut local_done = false;
1665 if let Some(ref handle) = local_ollama_probe {
1666 local_done = handle.is_finished();
1667 }
1668 if local_done
1669 && let Ok(Some(catalog)) = local_ollama_probe.take().unwrap().await
1670 && crate::local_ollama::should_adopt_live_local_ollama(app)
1671 {
1672 adopt_live_local_ollama_catalog(app, &mut engine_handle, config, catalog).await;
1673 }
1674
1675 // Non-blocking startup-default writes (mode / thinking) report their
1676 // failures here rather than at the keystroke, so a settings file we
1677 // could not write is visible instead of silently reverting next launch.
1678 app.drain_startup_default_failures();
1679
1680 while let Ok(event) = translation_rx.try_recv() {
1681 match event {
1682 TranslationEvent::AssistantMessage {
1683 origin_session_fingerprint,
1684 origin_turn_fingerprint,
1685 history_index,
1686 original_text,
1687 translated,
1688 usage,
1689 thinking,
1690 tool_uses,
1691 } => {
1692 pending_translations = pending_translations.saturating_sub(1);
1693 if translation_session_is_current(app, origin_session_fingerprint.as_deref())
1694 && let Some(usage) = usage.as_ref()
1695 {
1696 accrue_translation_usage(app, usage);
1697 }
1698 if !translation_origin_is_current(
1699 app,
1700 origin_session_fingerprint.as_deref(),
1701 origin_turn_fingerprint.as_deref(),
1702 ) {
1703 tracing::debug!(
1704 "discarded assistant translation completed for a stale session/turn"
1705 );
1706 continue;
1707 }
1708 let text = match translated {
1709 Ok(text) => {
1710 app.status_message = Some(
1711 codewhale_localization::tr(
1712 app.ui_locale,
1713 codewhale_localization::MessageId::TranslationComplete,
1714 )
1715 .to_string(),
1716 );
1717 text
1718 }
1719 Err(err) => {
1720 tracing::warn!("assistant translation failed: {err}");
1721 app.status_message = Some(format!(
1722 "{}: {err}",
1723 codewhale_localization::tr(
1724 app.ui_locale,
1725 codewhale_localization::MessageId::TranslationFailed,
1726 )
1727 ));
1728 codewhale_localization::hidden_translation_failed(app.ui_locale)
1729 .to_string()
1730 }
1731 };
1732
1733 if let Some(index) = history_index
1734 && let Some(HistoryCell::Assistant { content, .. }) =
1735 app.history.get_mut(index)
1736 {
1737 *content = text.clone();
1738 app.record_completed_assistant_output(index, &text);
1739 app.bump_history_cell(index);
1740 }
1741 if !replace_matching_assistant_text(app, &original_text, text.clone()) {
1742 push_assistant_message(app, text, thinking, tool_uses);
1743 }
1744 if pending_translations == 0
1745 && !matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
1746 {
1747 app.is_loading = pending_translations > 0;
1748 }
1749 app.needs_redraw = true;
1750 }
1751 TranslationEvent::Thinking {
1752 origin_session_fingerprint,
1753 origin_turn_fingerprint,
1754 placeholder,
1755 translated,
1756 usage,
1757 } => {
1758 pending_translations = pending_translations.saturating_sub(1);
1759 pending_thinking_translations = pending_thinking_translations.saturating_sub(1);
1760 if translation_session_is_current(app, origin_session_fingerprint.as_deref())
1761 && let Some(usage) = usage.as_ref()
1762 {
1763 accrue_translation_usage(app, usage);
1764 }
1765 if !translation_origin_is_current(
1766 app,
1767 origin_session_fingerprint.as_deref(),
1768 origin_turn_fingerprint.as_deref(),
1769 ) {
1770 tracing::debug!(
1771 "discarded thinking translation completed for a stale session/turn"
1772 );
1773 continue;
1774 }
1775 let text = match translated {
1776 Ok(text) => {
1777 app.status_message = Some(
1778 codewhale_localization::thinking_translation_complete(
1779 app.ui_locale,
1780 )
1781 .to_string(),
1782 );
1783 text
1784 }
1785 Err(err) => {
1786 tracing::warn!("thinking translation failed: {err}");
1787 app.status_message = Some(format!(
1788 "{}: {err}",
1789 codewhale_localization::thinking_translation_failed(app.ui_locale)
1790 ));
1791 codewhale_localization::hidden_translation_failed(app.ui_locale)
1792 .to_string()
1793 }
1794 };
1795 streaming_thinking::replace_pending_translation(app, &placeholder, text);
1796 if pending_translations == 0
1797 && !matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
1798 {
1799 app.is_loading = false;
1800 }
1801 app.needs_redraw = true;
1802 }
1803 }
1804 }
1805
1806 if last_task_refresh.elapsed() >= Duration::from_millis(2500) {
1807 if refresh_active_task_panel(app, &task_manager).await {
1808 app.needs_redraw = true;
1809 }
1810 if refresh_automation_panel(app).await {
1811 app.needs_redraw = true;
1812 }
1813 if refresh_shell_exec_live_output(app) {
1814 app.needs_redraw = true;
1815 }
1816 if app
1817 .runtime_services
1818 .work
1819 .as_ref()
1820 .is_some_and(|work| work.has_pending_publish())
1821 && let Err(err) = persist_pending_work_checkpoint(app).await
1822 {
1823 tracing::warn!(error = %err, "background Work lifecycle checkpoint remains pending");
1824 }
1825 last_task_refresh = Instant::now();
1826 }
1827
1828 // Clear suggestion when the user modifies the input.
1829 if app.input != prev_input_snapshot {
1830 app.prompt_suggestion = None;
1831 prev_input_snapshot = app.input.clone();
1832 }
1833
1834 // Poll prompt suggestion cell from background generation task.
1835 // Discard stale results whose generation token no longer matches.
1836 if let Ok(mut guard) = app.prompt_suggestion_cell.try_lock()
1837 && let Some((gen_token, suggestion)) = guard.take()
1838 && gen_token
1839 == app
1840 .prompt_suggestion_gen
1841 .load(std::sync::atomic::Ordering::Relaxed)
1842 {
1843 app.prompt_suggestion = Some(suggestion);
1844 }
1845
1846 // Poll the fleet-profile model-draft cell filled by the background
1847 // drafting task (#3757 review: the draft must not park the loop).
1848 let fleet_draft_delivery = app
1849 .fleet_draft_cell
1850 .try_lock()
1851 .ok()
1852 .and_then(|mut guard| guard.take());
1853 if let Some((draft_gen, model_label, picked_route, reasoning_effort, outcome)) =
1854 fleet_draft_delivery
1855 && draft_gen == app.current_draft_gen()
1856 {
1857 deliver_fleet_draft_result(
1858 app,
1859 model_label,
1860 picked_route,
1861 reasoning_effort,
1862 outcome,
1863 app.ui_locale,
1864 );
1865 }
1866
1867 // Poll the constitution model-draft cell (same background pattern).
1868 let constitution_draft_delivery = app
1869 .constitution_draft_cell
1870 .try_lock()
1871 .ok()
1872 .and_then(|mut guard| guard.take());
1873 if let Some((draft_gen, model_label, draft_locale, outcome)) = constitution_draft_delivery
1874 && draft_gen == app.current_draft_gen()
1875 {
1876 deliver_constitution_draft_result(app, model_label, draft_locale, outcome);
1877 }
1878
1879 // Discovery and callback delivery never park terminal input.
1880 poll_mcp_login(app);
1881
1882 // #1830/#2317: service any already-arrived terminal keys before a
1883 // potentially long engine batch so composer/modal input stays live.
1884 collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
1885 app.maybe_poll_plugin_catalog_idle();
1886 app.maybe_poll_plugin_cta();
1887
1888 if drain_remote_control_events(app, config, &engine_handle).await? {
1889 app.needs_redraw = true;
1890 }
1891
1892 // First, poll for engine events (non-blocking)
1893 let mut received_engine_event = false;
1894 let mut transcript_batch_updated = false;
1895 // #freeze: coalesce per-event `Op::ListSubAgents` sends into a single
1896 // trailing-edge refresh per drain. At high fanout, many spawn/complete/
1897 // mailbox events in one drain otherwise each take the manager write
1898 // lock and trigger a full O(N) list reconcile.
1899 let mut subagent_list_refresh_requested = false;
1900 let mut queued_to_send: Option<QueuedMessage> = None;
1901 let mut respawn_after_provider_rollback: Option<String> = None;
1902 let mut fallback_after_engine_error: Option<ProviderFallbackRollback> = None;
1903 {
1904 let mut rx = engine_handle.rx_event.write().await;
1905 let mut progress_redraw_agents: HashSet<String> = HashSet::new();
1906 let drain_started = Instant::now();
1907 let mut events_drained = 0usize;
1908 loop {
1909 if events_drained > 0
1910 && engine_drain_budget_exhausted(events_drained, drain_started, Instant::now())
1911 {
1912 break;
1913 }
1914 let event = match rx.try_recv() {
1915 Ok(event) => event,
1916 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
1917 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1918 if recover_engine_event_disconnect(app) {
1919 received_engine_event = true;
1920 transcript_batch_updated = true;
1921 }
1922 break;
1923 }
1924 };
1925 // #3033: remember whether an EARLIER event in this drain batch
1926 // already requested a redraw. The AgentProgress throttle below
1927 // may opt the current event out of repainting, but it must not
1928 // cancel redraws owed to other events in the same batch.
1929 let redraw_requested_before_event = received_engine_event;
1930 received_engine_event = true;
1931 capture_turn_started_metadata(app, &event);
1932 if app.suppress_stream_events_until_turn_complete {
1933 if matches!(event, EngineEvent::TurnStarted { .. }) {
1934 // Ctrl+C can race with the engine's per-turn token
1935 // reset: the first cancel may hit the previous token
1936 // if SendMessage is queued but TurnStarted has not
1937 // arrived yet. Reassert cancellation once the real
1938 // turn starts, then keep hiding its queued deltas.
1939 engine_handle.cancel();
1940 continue;
1941 }
1942 if suppress_engine_event_after_local_cancel(&event) {
1943 continue;
1944 }
1945 } else if !app.is_loading && ignore_stale_stream_event_while_idle(&event) {
1946 continue;
1947 }
1948 if !matches!(event, EngineEvent::ApprovalRequired { .. }) {
1949 app.remote_control.observe_engine_event(&event);
1950 // A terminal boundary reached after deltas were shed under
1951 // pressure repairs account truth with a bounded snapshot.
1952 while let Some(resync_run) = app.remote_control.take_pending_resync() {
1953 app.remote_control
1954 .upload_resync_snapshot(&resync_run, &app.api_messages);
1955 }
1956 }
1957 let pet_event_applies = match &event {
1958 EngineEvent::AgentSpawned {
1959 owner_session_id, ..
1960 }
1961 | EngineEvent::AgentProgress {
1962 owner_session_id, ..
1963 }
1964 | EngineEvent::AgentComplete {
1965 owner_session_id, ..
1966 } => event_owner_is_active(app.current_session_id.as_deref(), owner_session_id),
1967 EngineEvent::UserInputRequired { .. } => {
1968 !should_suppress_user_input_prompt(app)
1969 }
1970 EngineEvent::ApprovalRequired {
1971 tool_name,
1972 approval_grouping_key,
1973 approval_key,
1974 approval_force_prompt,
1975 ..
1976 } => {
1977 matches!(
1978 resolve_ui_approval_disposition(
1979 app,
1980 tool_name,
1981 approval_grouping_key,
1982 approval_key,
1983 *approval_force_prompt
1984 ),
1985 crate::core::authority::ApprovalRequestDisposition::Prompt
1986 )
1987 }
1988 _ => true,
1989 };
1990 if pet_event_applies {
1991 crate::tui::pet_watch::observe(app, &event, Instant::now());
1992 }
1993 record_turn_activity(app, &event, Instant::now());
1994 match event {
1995 EngineEvent::MessageStarted { .. } => {
1996 // Assistant text starting after parallel tool work
1997 // means the tool group is done. Flush the active
1998 // cell first so the message lands BELOW the
1999 // committed tool group (Codex pattern: streamed
2000 // assistant content always flows after work).
2001 app.flush_active_cell();
2002 current_streaming_text.clear();
2003 app.streaming_output_token_estimate = 0;
2004 app.streaming_state.reset();
2005 app.streaming_state.start_text(0);
2006 app.streaming_message_index = None;
2007 stream_display_clock.reset();
2008 }
2009 EngineEvent::MessageDelta { content, .. } => {
2010 let sanitized = sanitize_stream_chunk(&content);
2011 if sanitized.is_empty() {
2012 continue;
2013 }
2014 // First delta of a fresh stream has no streaming
2015 // cell yet; flush active so the tool group settles
2016 // before the assistant prose appears below it.
2017 if app.streaming_message_index.is_none() {
2018 app.flush_active_cell();
2019 }
2020 current_streaming_text.push_str(&sanitized);
2021 ensure_streaming_assistant_history_cell(app);
2022 app.streaming_state.push_content(0, &sanitized);
2023 stream_display_clock.note_delta(Instant::now());
2024 received_engine_event = redraw_requested_before_event;
2025 }
2026 EngineEvent::MessageComplete { .. } => {
2027 // #861 RC3: defensive drain of a still-active thinking
2028 // entry. Normally `ThinkingComplete` arrives first and
2029 // populates `last_reasoning` before we get here, but
2030 // when the engine bursts events the channel can
2031 // deliver `MessageComplete` first, in which case
2032 // `last_reasoning.take()` below would be `None` and
2033 // the thinking block would be dropped from
2034 // `api_messages` — causing a DeepSeek HTTP 400 on the
2035 // next turn (V4 thinking-mode requires
2036 // `reasoning_content` replay). Inline-finalize the
2037 // thinking entry here so this branch is order-
2038 // independent.
2039 if app.streaming_thinking_active_entry.is_some() {
2040 if streaming_thinking::finalize_current(app) {
2041 transcript_batch_updated = true;
2042 }
2043 streaming_thinking::stash_reasoning_buffer_into_last_reasoning(app);
2044 }
2045 let mut completed_message_index = None;
2046 if let Some(index) = app.streaming_message_index.take() {
2047 completed_message_index = Some(index);
2048 stream_display_clock.flush_now(Instant::now());
2049 let remaining = app.streaming_state.finalize_block_text(0);
2050 if !remaining.is_empty() {
2051 append_streaming_text(app, index, &remaining);
2052 accrue_streaming_token_estimate(app, &remaining);
2053 }
2054 if let Some(HistoryCell::Assistant { streaming, .. }) =
2055 app.history.get_mut(index)
2056 {
2057 *streaming = false;
2058 }
2059 // Streaming flag flipped — the cell's compact /
2060 // transcript variants render slightly
2061 // differently, so bump its revision so the cache
2062 // refreshes this row only.
2063 app.bump_history_cell(index);
2064 transcript_batch_updated = true;
2065 stream_display_clock.reset();
2066 }
2067
2068 let thinking = app.last_reasoning.take();
2069 let tool_uses = std::mem::take(&mut app.pending_tool_uses);
2070 let history_index = completed_message_index;
2071 if let Some(index) = history_index {
2072 app.record_completed_assistant_output(index, &current_streaming_text);
2073 }
2074
2075 if app.translation_enabled
2076 && !current_streaming_text.is_empty()
2077 && crate::tui::translation::needs_translation(&current_streaming_text)
2078 && let Some(translation_client) = active_translation_client.as_ref()
2079 {
2080 app.status_message = Some(
2081 codewhale_localization::tr(
2082 app.ui_locale,
2083 codewhale_localization::MessageId::TranslationInProgress,
2084 )
2085 .to_string(),
2086 );
2087 app.is_loading = true;
2088 pending_translations = pending_translations.saturating_add(1);
2089 let tx = translation_tx.clone();
2090 let client = translation_client.clone();
2091 let original_text = current_streaming_text.clone();
2092 let translation_model = active_translation_route
2093 .as_ref()
2094 .map(|route| route.model.clone())
2095 .or_else(|| app.last_effective_model.clone())
2096 .unwrap_or_else(|| app.model.clone());
2097 translation_sequence = translation_sequence.saturating_add(1);
2098 let accounting = TranslationAccountingContext::capture(
2099 app,
2100 "assistant",
2101 translation_sequence,
2102 );
2103 let (origin_session_fingerprint, origin_turn_fingerprint) =
2104 translation_origin(app);
2105 let target_language =
2106 app.ui_locale.translation_target_name().to_string();
2107 tokio::spawn(async move {
2108 let settled = accounting.settle(
2109 client
2110 .translate_with_usage(
2111 &original_text,
2112 &translation_model,
2113 &target_language,
2114 )
2115 .await,
2116 );
2117 let _ = tx.send(TranslationEvent::AssistantMessage {
2118 origin_session_fingerprint,
2119 origin_turn_fingerprint,
2120 history_index,
2121 original_text,
2122 translated: settled.translated,
2123 usage: settled.usage,
2124 thinking,
2125 tool_uses,
2126 });
2127 });
2128 } else {
2129 push_assistant_message(
2130 app,
2131 current_streaming_text.clone(),
2132 thinking,
2133 tool_uses,
2134 );
2135 }
2136 }
2137 EngineEvent::ThinkingStarted { .. } => {
2138 stream_display_clock.reset();
2139 // P2.3: thinking lives in the active cell so it groups
2140 // visually with the tool calls that follow until the
2141 // next assistant prose chunk flushes the group.
2142 if streaming_thinking::start_block(app) {
2143 transcript_batch_updated = true;
2144 }
2145 if app.translation_enabled {
2146 let entry_idx = streaming_thinking::ensure_active_entry(app);
2147 streaming_thinking::set_placeholder(app, entry_idx);
2148 transcript_batch_updated = true;
2149 }
2150 }
2151 EngineEvent::ThinkingDelta { content, .. } => {
2152 let sanitized = sanitize_stream_chunk(&content);
2153 if sanitized.is_empty() {
2154 continue;
2155 }
2156 app.reasoning_buffer.push_str(&sanitized);
2157 if app.reasoning_header.is_none() {
2158 app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer);
2159 }
2160
2161 streaming_thinking::ensure_active_entry(app);
2162 app.streaming_state.push_content(0, &sanitized);
2163 stream_display_clock.note_delta(Instant::now());
2164 received_engine_event = redraw_requested_before_event;
2165 }
2166 EngineEvent::ThinkingComplete { .. } => {
2167 stream_display_clock.flush_now(Instant::now());
2168 if app.translation_enabled {
2169 let original_thinking = app.reasoning_buffer.clone();
2170 let _ = app.streaming_state.finalize_block_text(0);
2171 let duration = app
2172 .thinking_started_at
2173 .take()
2174 .map(|t| t.elapsed().as_secs_f32());
2175 if streaming_thinking::finalize_active_entry(app, duration, "") {
2176 transcript_batch_updated = true;
2177 }
2178 if !original_thinking.is_empty()
2179 && crate::tui::translation::needs_translation(&original_thinking)
2180 && let Some(translation_client) = active_translation_client.as_ref()
2181 {
2182 app.status_message = Some(
2183 codewhale_localization::thinking_translation_in_progress(
2184 app.ui_locale,
2185 )
2186 .to_string(),
2187 );
2188 app.is_loading = true;
2189 pending_translations = pending_translations.saturating_add(1);
2190 pending_thinking_translations =
2191 pending_thinking_translations.saturating_add(1);
2192 let tx = translation_tx.clone();
2193 let client = translation_client.clone();
2194 let translation_model = active_translation_route
2195 .as_ref()
2196 .map(|route| route.model.clone())
2197 .or_else(|| app.last_effective_model.clone())
2198 .unwrap_or_else(|| app.model.clone());
2199 translation_sequence = translation_sequence.saturating_add(1);
2200 let accounting = TranslationAccountingContext::capture(
2201 app,
2202 "thinking",
2203 translation_sequence,
2204 );
2205 let (origin_session_fingerprint, origin_turn_fingerprint) =
2206 translation_origin(app);
2207 let placeholder =
2208 codewhale_localization::thinking_translation_placeholder(
2209 app.ui_locale,
2210 )
2211 .to_string();
2212 let target_language =
2213 app.ui_locale.translation_target_name().to_string();
2214 tokio::spawn(async move {
2215 let settled = accounting.settle(
2216 client
2217 .translate_with_usage(
2218 &original_thinking,
2219 &translation_model,
2220 &target_language,
2221 )
2222 .await,
2223 );
2224 let _ = tx.send(TranslationEvent::Thinking {
2225 origin_session_fingerprint,
2226 origin_turn_fingerprint,
2227 placeholder,
2228 translated: settled.translated,
2229 usage: settled.usage,
2230 });
2231 });
2232 } else {
2233 let placeholder =
2234 codewhale_localization::thinking_translation_placeholder(
2235 app.ui_locale,
2236 );
2237 streaming_thinking::replace_pending_translation(
2238 app,
2239 placeholder,
2240 original_thinking,
2241 );
2242 }
2243 } else if streaming_thinking::finalize_current(app) {
2244 transcript_batch_updated = true;
2245 }
2246 streaming_thinking::stash_reasoning_buffer_into_last_reasoning(app);
2247 stream_display_clock.reset();
2248 }
2249 EngineEvent::ToolCallStarted { id, name, input } => {
2250 app.session_metrics.record_tool_started(&id);
2251 app.pending_tool_uses
2252 .push((id.clone(), name.clone(), input.clone()));
2253 // Note this dispatch so the next sub-agent `Started`
2254 // mailbox envelope routes into the right card kind
2255 // (delegate vs fanout).
2256 if matches!(
2257 name.as_str(),
2258 "agent" | "rlm_open" | "rlm_eval" | "rlm" | "delegate"
2259 ) {
2260 app.pending_subagent_dispatch = Some(name.clone());
2261 if matches!(name.as_str(), "rlm_open" | "rlm_eval" | "rlm") {
2262 // New fanout invocation — children should
2263 // group under a fresh card, not the
2264 // previous fanout's leftover.
2265 app.last_fanout_card_index = None;
2266 }
2267 }
2268 handle_tool_call_started(app, &id, &name, &input);
2269 }
2270 // Liveness only. `record_turn_activity` above consumes the
2271 // pulse; it must not alter transcript or status copy.
2272 EngineEvent::ToolCallHeartbeat => {}
2273 EngineEvent::ToolCallComplete { id, name, result } => {
2274 if crate::tui::tool_routing::evidence_completion_should_be_ignored(
2275 app, &id, &result,
2276 ) {
2277 tracing::debug!(tool_id = %id, tool_name = %name, "ignored foreign or replayed evidence completion");
2278 continue;
2279 }
2280 app.session_metrics.record_tool_completed(&id);
2281 if is_model_visible_tool_call(&id) {
2282 let tool_content = match &result {
2283 Ok(output) => sanitize_stream_chunk(
2284 &tool_result_content_for_api_message(app, &id, &name, output)
2285 .await,
2286 ),
2287 Err(err) => sanitize_stream_chunk(&format!("Error: {err}")),
2288 };
2289 app.push_api_message(Message {
2290 role: Role::User,
2291 content: vec![ContentBlock::ToolResult {
2292 tool_use_id: id.clone(),
2293 content: tool_content,
2294 is_error: None,
2295 content_blocks: None,
2296 }],
2297 });
2298 } else {
2299 app.pending_tool_uses
2300 .retain(|(tool_id, _, _)| tool_id != &id);
2301 }
2302 handle_tool_call_complete(app, &id, &name, &result);
2303 if name
2304 == crate::tools::request_plugin_install::REQUEST_PLUGIN_INSTALL_TOOL_NAME
2305 && let Ok(output) = &result
2306 && output.success
2307 && let Some(meta) = output.metadata.as_ref()
2308 {
2309 let plugin = meta
2310 .get("plugin")
2311 .and_then(serde_json::Value::as_str)
2312 .unwrap_or("");
2313 let command = meta
2314 .get("command")
2315 .and_then(serde_json::Value::as_str)
2316 .unwrap_or("");
2317 app.surface_plugin_review_request(plugin, command);
2318 }
2319 if flush_gate_receipts_for(app, Some(&id)) {
2320 transcript_batch_updated = true;
2321 }
2322 if crate::mcp::McpPool::is_mcp_tool(&name)
2323 && match &result {
2324 Ok(output) => !output.success,
2325 Err(_) => true,
2326 }
2327 {
2328 let _ = app.maybe_show_behavioral_tip(
2329 crate::tui::behavioral_tips::BehavioralTip::McpValidation,
2330 );
2331 }
2332
2333 // Every `remember` action mutates durable memory, so a
2334 // successful call is the moment the first-run tip
2335 // points at /memory (one-shot per session, lifetime-capped).
2336 if name == "remember" && matches!(&result, Ok(output) if output.success) {
2337 let _ = app.maybe_show_behavioral_tip(
2338 crate::tui::behavioral_tips::BehavioralTip::DurableStateWritten,
2339 );
2340 }
2341
2342 if result.is_ok()
2343 && is_work_graph_mutation_tool(&name)
2344 && let Err(err) = persist_pending_work_checkpoint(app).await
2345 {
2346 tracing::warn!(
2347 tool = %name,
2348 error = %err,
2349 "Work Graph checkpoint was not enqueued; projections remain unpublished"
2350 );
2351 app.status_message = Some(format!(
2352 "To-do list update pending: checkpoint could not be queued ({err})"
2353 ));
2354 }
2355
2356 // Immediately refresh the task panel sidebar when a
2357 // tool that changes task state completes, so the
2358 // Tasks panel stays in sync with tool execution
2359 // rather than waiting up to 2.5 s for the periodic
2360 // poll. Also merge shell jobs (#373).
2361 // Only tools that actually change durable tasks or
2362 // background shell jobs force a jobs-panel refresh.
2363 // Checklist/todo/plan tools drive the To-do panel,
2364 // which reads `app.todos` directly and repaints on the
2365 // normal redraw — no forced refresh needed (avoids the
2366 // old per-checklist Tasks-panel churn).
2367 if matches!(
2368 name.as_str(),
2369 "agent"
2370 | "task_shell_start"
2371 | "exec_shell"
2372 | "exec_shell_cancel"
2373 | "exec_shell_wait"
2374 | "task_cancel"
2375 // Unified durable-task tool (piagent phase B):
2376 // create/cancel actions mutate task state, so
2377 // any `tasks` completion refreshes the panel.
2378 | "tasks"
2379 ) {
2380 refresh_active_task_panel(app, &task_manager).await;
2381 last_task_refresh = Instant::now();
2382 }
2383 if matches!(name.as_str(), "agent") {
2384 subagent_list_refresh_requested = true;
2385 }
2386 }
2387 EngineEvent::TurnStarted { turn_id, route, .. } => {
2388 // A prior turn that died without its `TurnComplete`
2389 // must not leak its provisional estimate into this one.
2390 app.clear_pending_turn_cost();
2391 app.goal_continuation_waiting = false;
2392 app.session.last_tool_request_snapshot = None;
2393 app.ocean_completion_started_at = None;
2394 app.ocean_receipt_settle_start = None;
2395 app.ocean_turn_history_start = app.history.len();
2396 app.suppress_stream_events_until_turn_complete = false;
2397 app.is_loading = true;
2398 app.offline_mode = false;
2399 app.turn_error_posted = false;
2400 app.lsp_repair = crate::tui::app::LspRepairState::default();
2401 app.prompt_suggestion = None;
2402 app.prompt_suggestion_gen
2403 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2404 app.dispatch_started_at = None;
2405 current_streaming_text.clear();
2406 app.streaming_output_token_estimate = 0;
2407 app.streaming_state.reset();
2408 app.streaming_message_index = None;
2409 app.streaming_thinking_active_entry = None;
2410 stream_display_clock.reset();
2411 let now = Instant::now();
2412 app.turn_started_at = Some(now);
2413 app.turn_last_activity_at = Some(now);
2414 app.session.clear_pending_turn_usage();
2415 app.streaming_output_token_estimate = 0;
2416 app.provider_wait_incident_logged = false;
2417 // Discoverability hint for users who don't know how
2418 // to interrupt a long-running turn (#1367). Only
2419 // surface when the status_message slot is empty so
2420 // we don't trample over a real transient message
2421 // (e.g. "/queue saved", "Selection copied"); the
2422 // hint then auto-clears as soon as anything else
2423 // updates the slot.
2424 if app.status_message.is_none() {
2425 app.status_message = Some("Press Esc or Ctrl+C to cancel".to_string());
2426 }
2427 active_translation_client = match route.as_ref() {
2428 Some(route) => match exact_translation_client(config, route) {
2429 Ok(client) => Some(client),
2430 Err(error) => {
2431 tracing::warn!(
2432 "translation client rejected the frozen turn route: {error}"
2433 );
2434 None
2435 }
2436 },
2437 None => fallback_translation_client.clone(),
2438 };
2439 active_translation_route = route;
2440 app.runtime_turn_id = Some(turn_id);
2441 app.runtime_turn_status = Some("in_progress".to_string());
2442 app.turn_counter = app.turn_counter.saturating_add(1);
2443 app.reasoning_buffer.clear();
2444 app.reasoning_header = None;
2445 app.last_reasoning = None;
2446 app.pending_tool_uses.clear();
2447 last_status_frame = Instant::now();
2448 // Lifecycle outbox (`[lifecycle_outbox]`): the turn
2449 // boundary the shell-hook system deliberately lacks.
2450 // No-op when the feature is disabled.
2451 app.lifecycle_outbox.emit(codewhale_hooks::LifecycleEvent {
2452 event: "turn_start".to_string(),
2453 kind: "turn.started".to_string(),
2454 thread_id: app.hooks.session_id().to_string(),
2455 turn_id: app.runtime_turn_id.clone(),
2456 item_id: None,
2457 payload: serde_json::json!({
2458 "model": codewhale_hooks::bounded_text(
2459 &app.model,
2460 codewhale_hooks::OUTBOX_DETAIL_MAX_CHARS,
2461 ),
2462 "workspace": app.workspace.display().to_string(),
2463 }),
2464 });
2465 }
2466 EngineEvent::ToolRequestSnapshot { snapshot } => {
2467 app.session.last_tool_request_snapshot = Some(snapshot);
2468 }
2469 EngineEvent::RouteDispatched { turn_id, route } => {
2470 if app.runtime_turn_id.as_deref() == Some(turn_id.as_str()) {
2471 active_translation_client = match exact_translation_client(
2472 config, &route,
2473 ) {
2474 Ok(client) => Some(client),
2475 Err(error) => {
2476 tracing::warn!(
2477 "translation client rejected the dispatched turn route: {error}"
2478 );
2479 None
2480 }
2481 };
2482 active_translation_route = Some(route);
2483 }
2484 }
2485 EngineEvent::TurnComplete {
2486 usage,
2487 parent_route_usage,
2488 routed_usage_dropped_records,
2489 status,
2490 error,
2491 tool_catalog,
2492 base_url,
2493 } => {
2494 // A decision whose tool never reported completion
2495 // still gets its receipt before the turn closes.
2496 if flush_gate_receipts_for(app, None) {
2497 transcript_batch_updated = true;
2498 }
2499 // A steer the turn never accepted was dropped by the
2500 // engine. Report it instead of leaving it "sending"
2501 // (#6190).
2502 crate::tui::ui::dispatch::settle_unaccepted_steers_at_turn_end(app);
2503 let completed_turn = app.active_turn.take();
2504 // The in-flight provisional estimate hands off to the
2505 // authoritative cumulative price accrued below; the
2506 // high-water mark keeps the displayed total monotonic
2507 // through the swap (#244).
2508 app.clear_pending_turn_cost();
2509 app.session.clear_pending_turn_usage();
2510 app.session.last_tool_catalog = tool_catalog;
2511 // The endpoint this turn's client actually used. Kept
2512 // separately from the mutable session/config surfaces
2513 // so the prompt-suggestion gate below can require it.
2514 let turn_actual_base_url = base_url.clone();
2515 app.session.last_base_url = base_url;
2516 let was_locally_cancelled = app.suppress_stream_events_until_turn_complete;
2517 app.suppress_stream_events_until_turn_complete = false;
2518 app.active_allowed_tools = None;
2519 if app.paused_goal_objective.is_none() {
2520 app.pausable = false;
2521 app.paused = false;
2522 }
2523 // Turn completion is an ordinary state transition.
2524 // Clearing all 7,900 cells after a long stream was the
2525 // visible end-of-turn flash in the rejected build.
2526 // Ratatui's diff is sufficient here; full repaints stay
2527 // reserved for real terminal boundary changes (resize,
2528 // focus recovery, theme, child-terminal return).
2529 // Finalize any in-flight tool group. Cancellation
2530 // marks still-running entries as Failed so the user
2531 // sees they were interrupted rather than the spinner
2532 // hanging forever.
2533 if matches!(
2534 status,
2535 crate::core::events::TurnOutcomeStatus::Interrupted
2536 | crate::core::events::TurnOutcomeStatus::Failed
2537 ) {
2538 app.finalize_active_cell_as_interrupted();
2539 // Also mark the streaming Assistant cell (if any)
2540 // so partial reasoning/text isn't left with a
2541 // permanent spinner. Idempotent with the
2542 // optimistic call in the Esc handler.
2543 app.finalize_streaming_assistant_as_interrupted();
2544 } else {
2545 app.flush_active_cell();
2546 }
2547 app.is_loading = false;
2548 app.dispatch_started_at = None;
2549 app.pending_provider_switch = None;
2550 app.offline_mode = false;
2551 app.streaming_state.reset();
2552 stream_display_clock.reset();
2553 if was_locally_cancelled {
2554 current_streaming_text.clear();
2555 }
2556 // Capture elapsed before clearing turn_started_at so
2557 // notifications can use the real wall-clock duration.
2558 let turn_elapsed =
2559 app.turn_started_at.map(|t| t.elapsed()).unwrap_or_default();
2560 app.turn_started_at = None;
2561 app.turn_last_activity_at = None;
2562 app.streaming_output_token_estimate = 0;
2563 // Roll the just-finished turn's elapsed time into the
2564 // cumulative session work-time (#448 follow-up). The
2565 // footer's `worked Nh Mm` chip reads this so the
2566 // label reflects actual model work, not idle
2567 // uptime since launch.
2568 app.cumulative_turn_duration =
2569 app.cumulative_turn_duration.saturating_add(turn_elapsed);
2570 // A turn that ended with tools still open (interrupt,
2571 // failure) must not carry their timers forward.
2572 app.session_metrics.clear_in_flight();
2573 // Stream lock applies per-turn; clear it so the next
2574 // turn's chunks pull the view down again until the
2575 // user opts out by scrolling up.
2576 app.user_scrolled_during_stream = false;
2577 app.runtime_turn_status = Some(match status {
2578 crate::core::events::TurnOutcomeStatus::Completed => {
2579 app.ocean_completion_started_at = Some(Instant::now());
2580 app.ocean_receipt_settle_start =
2581 Some(app.ocean_turn_history_start.min(app.history.len()));
2582 "completed".to_string()
2583 }
2584 crate::core::events::TurnOutcomeStatus::Interrupted => {
2585 app.ocean_completion_started_at = None;
2586 app.ocean_receipt_settle_start = None;
2587 "interrupted".to_string()
2588 }
2589 crate::core::events::TurnOutcomeStatus::Failed => {
2590 app.ocean_completion_started_at = None;
2591 app.ocean_receipt_settle_start = None;
2592 "failed".to_string()
2593 }
2594 });
2595 if matches!(
2596 status,
2597 crate::core::events::TurnOutcomeStatus::Interrupted
2598 | crate::core::events::TurnOutcomeStatus::Failed
2599 ) {
2600 subagent_list_refresh_requested = true;
2601 }
2602 // #6004: only a turn that *ended* failed is a session
2603 // error; transient tool failures the agent absorbed
2604 // never fire it.
2605 if matches!(status, crate::core::events::TurnOutcomeStatus::Failed) {
2606 execute_session_error_hook(app, error.as_deref());
2607 }
2608 crate::tui::notifications::clear_taskbar_progress();
2609 if status != crate::core::events::TurnOutcomeStatus::Completed {
2610 crate::retry_status::clear();
2611 crate::tui::notifications::stop_title_animation_quietly();
2612 }
2613 let turn_tokens = usage.input_tokens.saturating_add(usage.output_tokens);
2614 app.session.total_tokens =
2615 app.session.total_tokens.saturating_add(turn_tokens);
2616 app.session.total_conversation_tokens = app
2617 .session
2618 .total_conversation_tokens
2619 .saturating_add(turn_tokens);
2620 app.session.total_input_tokens = app
2621 .session
2622 .total_input_tokens
2623 .saturating_add(usage.input_tokens);
2624 app.session.total_output_tokens = app
2625 .session
2626 .total_output_tokens
2627 .saturating_add(usage.output_tokens);
2628 // Only accumulate cache telemetry when the provider
2629 // reported at least one cache class. Use pricing's
2630 // canonical mutually-exclusive hit/miss/write split so
2631 // cache writes are never counted again as misses.
2632 if usage.prompt_cache_hit_tokens.is_some()
2633 || usage.prompt_cache_miss_tokens.is_some()
2634 || usage.prompt_cache_write_tokens.is_some()
2635 {
2636 let classes = crate::pricing::token_usage_for_pricing(&usage);
2637 let hit_tokens = u32::try_from(classes.cache_read).unwrap_or(u32::MAX);
2638 let miss_tokens = u32::try_from(classes.input).unwrap_or(u32::MAX);
2639 let write_tokens =
2640 u32::try_from(classes.cache_write).unwrap_or(u32::MAX);
2641 app.session.total_cache_hit_tokens = app
2642 .session
2643 .total_cache_hit_tokens
2644 .saturating_add(hit_tokens);
2645 app.session.total_cache_miss_tokens = app
2646 .session
2647 .total_cache_miss_tokens
2648 .saturating_add(miss_tokens);
2649 app.session.total_cache_write_tokens = app
2650 .session
2651 .total_cache_write_tokens
2652 .saturating_add(write_tokens);
2653 }
2654 app.session.last_prompt_tokens = Some(usage.input_tokens);
2655 app.session.last_completion_tokens = Some(usage.output_tokens);
2656 app.session.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens;
2657 app.session.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens;
2658 app.session.last_reasoning_replay_tokens = usage.reasoning_replay_tokens;
2659 let (provider, provider_identity, model, auto_model) = completed_turn
2660 .as_ref()
2661 .and_then(|turn| turn.route.as_ref())
2662 .map(|route| {
2663 (
2664 Some(route.provider),
2665 Some(route.provider_identity.clone()),
2666 Some(route.model.clone()),
2667 route.auto_model,
2668 )
2669 })
2670 .unwrap_or((None, None, None, false));
2671 let effective_turn_provider = provider.unwrap_or(app.api_provider);
2672 let effective_turn_model = model
2673 .as_deref()
2674 .filter(|model| !model.trim().is_empty())
2675 .unwrap_or_else(|| {
2676 app.last_effective_model.as_deref().unwrap_or(&app.model)
2677 })
2678 .to_string();
2679 app.last_effective_provider = Some(effective_turn_provider);
2680 app.last_effective_provider_identity = provider_identity.clone();
2681 if completed_turn
2682 .as_ref()
2683 .and_then(|turn| turn.route.as_ref())
2684 .is_some_and(|route| route.auto_model)
2685 {
2686 app.last_auto_route_receipt = completed_turn
2687 .as_ref()
2688 .and_then(|turn| turn.auto_route_receipt.clone());
2689 } else if completed_turn
2690 .as_ref()
2691 .is_some_and(|turn| turn.route.is_some())
2692 {
2693 app.last_auto_route_receipt = None;
2694 }
2695 if status == crate::core::events::TurnOutcomeStatus::Completed {
2696 app.provider_health.record_success(
2697 config,
2698 effective_turn_provider,
2699 &effective_turn_model,
2700 );
2701 }
2702 if auto_model {
2703 app.last_effective_model = Some(effective_turn_model.clone());
2704 }
2705 // Price the turn exactly once. The same audit feeds the
2706 // session total, the `/cache` row, and the `/cost`
2707 // completeness counters, so those three surfaces can
2708 // never disagree about what was counted (#4318).
2709 let cost_audit = completed_turn
2710 .as_ref()
2711 .and_then(|turn| turn.route.as_ref())
2712 .and_then(crate::core::events::TurnRoute::cost_envelope)
2713 .map(|route| route.audit(&parent_route_usage));
2714 app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {
2715 provider,
2716 provider_identity,
2717 model,
2718 auto_model,
2719 input_tokens: parent_route_usage.input_tokens,
2720 output_tokens: parent_route_usage.output_tokens,
2721 cache_hit_tokens: parent_route_usage.prompt_cache_hit_tokens,
2722 cache_miss_tokens: parent_route_usage.prompt_cache_miss_tokens,
2723 reasoning_replay_tokens: parent_route_usage.reasoning_replay_tokens,
2724 cache_write_tokens: parent_route_usage.prompt_cache_write_tokens,
2725 reasoning_tokens: parent_route_usage.reasoning_tokens,
2726 cost_audit: cost_audit.clone(),
2727 recorded_at: Instant::now(),
2728 });
2729 app.retire_action_notices(None);
2730 if let Some(error) = error.as_deref() {
2731 // Only show "Turn failed:" in the composer status
2732 // area when an EngineEvent::Error has NOT already
2733 // posted the same message into the transcript.
2734 // Otherwise the error appears twice: once in a
2735 // HistoryCell and again as a redundant status line.
2736 if !app.turn_error_posted {
2737 app.set_sticky_status(
2738 format!(
2739 "{}: {error}",
2740 app.tr(MessageId::NotificationTurnFailed)
2741 ),
2742 StatusToastLevel::Error,
2743 None,
2744 );
2745 }
2746 }
2747
2748 // Update session cost, and record what the total does
2749 // *not* cover so `/cost` can stay honest about it.
2750 //
2751 // `cost_audit` above came from `cost_envelope()`, i.e.
2752 // the billing envelope frozen at CodeWhale's
2753 // pre-permit application-dispatch boundary and
2754 // classified from this turn's frozen receipt. It
2755 // is `None` for a route that was never dispatched, and
2756 // a route whose receipt named no product classified as
2757 // Unknown — either way nothing accrues. A `/provider`
2758 // or custom-table switch since dispatch cannot
2759 // retro-bill this turn onto another route, because no
2760 // ambient `Config` is read here at all.
2761 let turn_cost = cost_audit.as_ref().and_then(|audit| audit.estimate);
2762 if let Some(audit) = cost_audit.as_ref() {
2763 app.record_turn_cost_audit(audit);
2764 // Redacted receipt for the route this money came
2765 // from: provider identity, wire model, billing
2766 // surface, and the endpoint *fingerprint* — never the
2767 // URL or any credential.
2768 if let Some(receipt) =
2769 completed_turn_cost_route_receipt(completed_turn.as_ref(), audit)
2770 {
2771 app.record_turn_cost_route_receipt(receipt);
2772 }
2773 }
2774 if let Some(cost) = turn_cost {
2775 app.accrue_session_cost_estimate(cost);
2776 }
2777 if routed_usage_dropped_records > 0 {
2778 let dropped =
2779 u32::try_from(routed_usage_dropped_records).unwrap_or(u32::MAX);
2780 app.session.cost_unpriced_turns =
2781 app.session.cost_unpriced_turns.saturating_add(dropped);
2782 app.session.cost_cny_unpriced_turns =
2783 app.session.cost_cny_unpriced_turns.saturating_add(dropped);
2784 app.session
2785 .cost_unpriced_reasons
2786 .insert("routed_usage_receipt_missing".to_string());
2787 app.session
2788 .cost_cny_unpriced_reasons
2789 .insert("routed_usage_receipt_missing".to_string());
2790 }
2791
2792 // Emit OSC 9 / BEL desktop notification for long turns, and
2793 // always stop the title animation that began on TurnStarted.
2794 if status == crate::core::events::TurnOutcomeStatus::Completed {
2795 if let Some((method, threshold, include_summary)) =
2796 notifications::settings(config)
2797 {
2798 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
2799 let payload = notifications::completed_turn_payload(
2800 app,
2801 &current_streaming_text,
2802 include_summary,
2803 turn_elapsed,
2804 turn_cost,
2805 );
2806 crate::tui::notifications::notify_done(
2807 method,
2808 in_tmux,
2809 &payload,
2810 threshold,
2811 turn_elapsed,
2812 );
2813 crate::tui::notifications::stop_title_animation();
2814 } else {
2815 crate::tui::notifications::stop_title_animation_quietly();
2816 }
2817 }
2818
2819 // Generate ghost-text follow-up suggestion asynchronously.
2820 //
2821 // Privacy (#4404/#4411): the request is anchored to the
2822 // completed turn's route snapshot and to the receipt the
2823 // engine minted from the client it installed for that
2824 // turn — never to live UI selection, and never to
2825 // authority re-derived from mutable config.
2826 // Conversation context is only ever sent to that exact
2827 // endpoint with that exact credential. Providers whose
2828 // wire shape this helper does not speak produce no
2829 // background request at all — and never reach another
2830 // provider's credentials while deciding that.
2831 let suggestion_launch = completed_turn
2832 .as_ref()
2833 .and_then(|turn| {
2834 let route = turn.route.as_ref()?;
2835 let authority = turn.suggestion_authority.as_ref()?;
2836 Some(crate::tui::prompt_suggestion::SuggestionRouteSnapshot {
2837 provider: route.provider,
2838 provider_identity: route.provider_identity.as_str(),
2839 model: route.model.as_str(),
2840 authority,
2841 actual_base_url: turn_actual_base_url.as_deref(),
2842 })
2843 })
2844 .and_then(|snapshot| {
2845 crate::tui::prompt_suggestion::plan_suggestion_launch_with_config(
2846 config,
2847 status == crate::core::events::TurnOutcomeStatus::Completed,
2848 config.prompt_suggestion_enabled(),
2849 app.api_messages.len(),
2850 Some(snapshot),
2851 )
2852 });
2853 if let Some(launch) = suggestion_launch {
2854 let suggestion_cell = app.prompt_suggestion_cell.clone();
2855 let messages: std::sync::Arc<Vec<codewhale_models::Message>> =
2856 app.api_messages.clone();
2857 let gen_token = app
2858 .prompt_suggestion_gen
2859 .load(std::sync::atomic::Ordering::Relaxed);
2860 tokio::spawn(async move {
2861 let summary =
2862 crate::tui::prompt_suggestion::summarize_recent_messages(
2863 &messages, 8,
2864 );
2865 if let Some(suggestion) =
2866 crate::tui::prompt_suggestion::generate_suggestion(
2867 &launch.api_key,
2868 &launch.base_url,
2869 &launch.model,
2870 &summary,
2871 launch.openrouter_vendor.as_deref(),
2872 )
2873 .await
2874 && let Ok(mut guard) = suggestion_cell.lock()
2875 {
2876 *guard = Some((gen_token, suggestion));
2877 }
2878 });
2879 }
2880
2881 // Generate post-turn receipt for completed turns.
2882 // Also push a persistent status toast so users always
2883 // see the outcome in the footer (not just the 8-second
2884 // composer receipt), regardless of notification method
2885 // or platform.
2886 if status == crate::core::events::TurnOutcomeStatus::Completed {
2887 let tool_count = app.tool_evidence.len();
2888 let mut receipt = "✓ turn completed".to_string();
2889 if tool_count > 0 {
2890 let _ = write!(receipt, " · {tool_count} tool(s) used");
2891 for evidence in &app.tool_evidence {
2892 let summary = crate::utils::truncate_with_ellipsis(
2893 &evidence.summary,
2894 60,
2895 "…",
2896 );
2897 let _ = write!(receipt, " · {}: {summary}", evidence.tool_name);
2898 }
2899 }
2900 app.set_receipt_text(receipt.clone());
2901 // Mirror as a persistent status toast (10s TTL).
2902 // The footer bar visibly shows status toasts,
2903 // which is more glanceable than the composer
2904 // border receipt alone.
2905 app.push_status_toast(
2906 receipt,
2907 crate::tui::app::StatusToastLevel::Info,
2908 Some(10_000),
2909 );
2910 }
2911
2912 // Auto-save completed turn and clear crash checkpoint.
2913 // Offloaded to the persistence actor so the UI
2914 // stays responsive.
2915 if let Ok(manager) = SessionManager::default_location()
2916 && let Ok(session) = build_session_snapshot(app, &manager)
2917 {
2918 app.current_session_id = Some(session.metadata.id.clone());
2919 // Compound completion commit: the actor writes the
2920 // completed snapshot and clears this session's
2921 // crash checkpoint only after that write succeeds.
2922 // A failed save now retains the checkpoint as the
2923 // sole recovery record instead of erasing it.
2924 let queued =
2925 persistence_actor::try_persist(PersistRequest::CompletedCommit {
2926 session,
2927 });
2928 if queued {
2929 if let Err(err) = publish_pending_work_projection(app).await {
2930 tracing::warn!(
2931 error = %err,
2932 "completed-turn Work projections remain unpublished"
2933 );
2934 app.status_message = Some(format!(
2935 "Session queued, but Work views could not publish ({err})"
2936 ));
2937 }
2938 } else if app
2939 .runtime_services
2940 .work
2941 .as_ref()
2942 .is_some_and(|work| work.has_pending_publish())
2943 {
2944 app.status_message = Some(
2945 "To-do list update pending: session snapshot could not be queued"
2946 .to_string(),
2947 );
2948 }
2949 }
2950 // The checkpoint clear is owned by the compound
2951 // `CompletedCommit` above: it applies only after this
2952 // session's snapshot safely landed. When the snapshot
2953 // could not be built or queued, the in-flight
2954 // checkpoint survives for startup recovery review.
2955
2956 // Refresh prepaid remaining credit after each completed
2957 // turn so the footer balance chip stays current without
2958 // adding latency to any request path.
2959 let api_key = config.active_route_api_key().unwrap_or_default();
2960 let base_url = config.active_route_base_url();
2961 schedule_balance_fetch(app, &api_key, &base_url, false);
2962
2963 // Legacy pending-steer recovery. Current keyboard
2964 // handling keeps Esc as cancel-only, but older saved
2965 // state may still carry pending steers.
2966 if status == crate::core::events::TurnOutcomeStatus::Interrupted
2967 && app.submit_pending_steers_after_interrupt
2968 {
2969 if let Some(merged) = merge_pending_steers(&mut *app) {
2970 queued_to_send = Some(merged);
2971 }
2972 } else if status == crate::core::events::TurnOutcomeStatus::Failed
2973 && !app.pending_steers.is_empty()
2974 {
2975 // Hard-fail recovery: if the engine failed before
2976 // a clean Interrupted landed, demote pending
2977 // steers to the visible queue so they're not
2978 // silently lost. User can /queue to inspect.
2979 for msg in app.drain_pending_steers() {
2980 app.queue_message(msg);
2981 }
2982 }
2983
2984 // Counted here, at the caller, never inside
2985 // `execute_turn_end_observer_hook`: that function's
2986 // first statement returns early for anyone with no
2987 // TurnEnd hooks, and the natural future optimization
2988 // hoists that check up to this call site — which would
2989 // silently zero the counter for every user who does
2990 // not use hooks.
2991 {
2992 let telemetry = codewhale_telemetry::session_counters();
2993 telemetry.bump(codewhale_telemetry::Counter::Turns);
2994 telemetry.observe_turn_secs(turn_elapsed.as_secs());
2995 }
2996
2997 if let Err(error) = execute_turn_end_observer_hook(
2998 app,
2999 completed_turn.as_ref(),
3000 &usage,
3001 completed_turn
3002 .as_ref()
3003 .and_then(|turn| turn.route.as_ref())
3004 .and_then(|route| route.billing.as_ref())
3005 .and_then(|billing| billing.billing_surface.as_deref()),
3006 turn_elapsed,
3007 error.as_deref(),
3008 ) {
3009 surface_observer_hook_submission_failure(app, error);
3010 }
3011
3012 // Lifecycle outbox (`[lifecycle_outbox]`): one
3013 // `turn_end` event per completed turn, with the kind
3014 // projected from the turn status — `turn.failed` for
3015 // failed turns, `turn.completed` for completed ones,
3016 // `turn.interrupted` for locally cancelled ones.
3017 // No-op when the feature is disabled.
3018 {
3019 let outbox_status =
3020 app.runtime_turn_status.as_deref().unwrap_or("unknown");
3021 let kind = match outbox_status {
3022 "completed" => "turn.completed",
3023 "failed" => "turn.failed",
3024 "interrupted" => "turn.interrupted",
3025 _ => "turn.ended",
3026 };
3027 app.lifecycle_outbox.emit(codewhale_hooks::LifecycleEvent {
3028 event: "turn_end".to_string(),
3029 kind: kind.to_string(),
3030 thread_id: app.hooks.session_id().to_string(),
3031 turn_id: app.runtime_turn_id.clone(),
3032 item_id: None,
3033 payload: serde_json::json!({
3034 "status": outbox_status,
3035 "duration_ms": turn_elapsed.as_millis() as u64,
3036 "workspace": app.workspace.display().to_string(),
3037 "error": error
3038 .as_deref()
3039 .map(|message| codewhale_hooks::bounded_text(
3040 message,
3041 codewhale_hooks::OUTBOX_DETAIL_MAX_CHARS,
3042 )),
3043 }),
3044 });
3045 }
3046
3047 if queued_to_send.is_none() {
3048 queued_to_send = app.pop_queued_message();
3049 }
3050 }
3051 EngineEvent::Error {
3052 envelope,
3053 recoverable: _,
3054 } => {
3055 let provider_before_error = app.api_provider;
3056 let identity_before_error = config
3057 .resolve_persisted_provider_identity(
3058 Some(provider_before_error.as_str()),
3059 app.provider_id_for_persistence(),
3060 )
3061 .unwrap_or_else(|_| ProviderIdentity {
3062 provider: provider_before_error,
3063 key: app.provider_identity_for_persistence().to_string(),
3064 exact_id: app.provider_id_for_persistence().map(str::to_string),
3065 migrated_legacy_ollama_cloud_route: false,
3066 });
3067 let fallback_chain_before_error = app.provider_chain.clone();
3068 let (health_provider, health_model) =
3069 error_health_route(app, provider_before_error);
3070 app.provider_health.record_failure(
3071 config,
3072 health_provider,
3073 &health_model,
3074 &envelope,
3075 );
3076 let rollback_after_auth_failure =
3077 matches!(
3078 envelope.category,
3079 crate::error_taxonomy::ErrorCategory::Authentication
3080 ) && app.pending_provider_switch.is_some();
3081 apply_engine_error_to_app(app, envelope);
3082 if app.api_provider != provider_before_error && app.is_fallback_active() {
3083 // Several queued errors can be drained together.
3084 // The first route remains the rollback authority;
3085 // later chain advances must not overwrite it with
3086 // an enum/key pair from the half-applied fallback.
3087 fallback_after_engine_error.get_or_insert(ProviderFallbackRollback {
3088 identity: identity_before_error,
3089 chain: fallback_chain_before_error,
3090 });
3091 }
3092 if rollback_after_auth_failure
3093 && let Some(rollback_warning) =
3094 rollback_provider_after_auth_failure(app, config)
3095 {
3096 respawn_after_provider_rollback = Some(rollback_warning);
3097 }
3098 }
3099 EngineEvent::Status { message } => {
3100 app.status_message = Some(message);
3101 }
3102 EngineEvent::ToolProjectionWarning {
3103 provider,
3104 omitted_tool_names,
3105 omitted_tool_count,
3106 } => {
3107 let tools = crate::core::events::tool_projection_warning_tool_list(
3108 &omitted_tool_names,
3109 omitted_tool_count,
3110 );
3111 let message = app
3112 .tr(MessageId::ToolProjectionWarning)
3113 .replace("{provider}", &provider)
3114 .replace("{tools}", &tools);
3115 app.push_status_toast(message, StatusToastLevel::Warning, Some(12_000));
3116 }
3117 EngineEvent::SnapshotsDisabled { reason, .. } => {
3118 // Undo is silently off otherwise: the engine's stderr
3119 // notice never reaches the alternate screen (#5930).
3120 // The engine already rendered the one localized line;
3121 // show it once as a toast and leave the durable copy
3122 // to `/status` rather than pinning it in the
3123 // transcript too (#6042).
3124 app.push_status_toast(reason, StatusToastLevel::Warning, Some(12_000));
3125 }
3126 EngineEvent::McpSessionBoot {
3127 generation,
3128 snapshot,
3129 connecting,
3130 finished,
3131 } => {
3132 apply_mcp_session_boot_event(
3133 app, generation, snapshot, connecting, finished,
3134 );
3135 }
3136 EngineEvent::RequestManifestReady { rendered } => {
3137 // Typed manifest text, or the explicitly requested
3138 // base-prompt-only disclosure. Rendered as a system cell.
3139 app.add_message(HistoryCell::System { content: rendered });
3140 transcript_batch_updated = true;
3141 }
3142 EngineEvent::GoalUpdated { snapshot } => {
3143 if apply_goal_snapshot_to_app(app, &snapshot) {
3144 transcript_batch_updated = true;
3145 if let Err(error) = persist_current_session_goal(app) {
3146 surface_goal_persistence_failure(app, &error);
3147 }
3148 }
3149 }
3150 EngineEvent::GoalContinuationWaiting { delay_seconds } => {
3151 app.goal_continuation_waiting = true;
3152 let delay = crate::elapsed::format_elapsed_secs(delay_seconds);
3153 app.status_message = Some(
3154 app.tr(MessageId::GoalContinuationWaiting)
3155 .replace("{delay}", &delay),
3156 );
3157 }
3158 EngineEvent::GoalContinuationWaitEnded { interrupted } => {
3159 app.goal_continuation_waiting = false;
3160 let message_id = if interrupted {
3161 MessageId::GoalContinuationStopped
3162 } else {
3163 MessageId::GoalContinuationReady
3164 };
3165 app.status_message = Some(app.tr(message_id).to_string());
3166 }
3167 event @ EngineEvent::SessionUpdated { .. } => {
3168 apply_engine_session_projection(app, config, event);
3169 }
3170 EngineEvent::CompactionStarted { id, auto, .. } => {
3171 apply_compaction_started(app, id, auto);
3172 }
3173 EngineEvent::CompactionCompleted {
3174 id,
3175 auto,
3176 message,
3177 messages_before,
3178 messages_after,
3179 summary_prompt,
3180 ..
3181 } => {
3182 apply_compaction_completed(
3183 app,
3184 &id,
3185 auto,
3186 message,
3187 messages_before,
3188 messages_after,
3189 summary_prompt,
3190 );
3191 }
3192 EngineEvent::CompactionCancelled { id, auto, message } => {
3193 apply_compaction_cancelled(app, &id, auto, message);
3194 }
3195 EngineEvent::CompactionFailed { id, auto, message } => {
3196 apply_compaction_failed(app, &id, auto, message);
3197 }
3198 EngineEvent::PurgeStarted { message } => {
3199 app.is_purging = true;
3200 app.status_message = Some(message);
3201 }
3202 EngineEvent::PurgeCompleted { message, .. } => {
3203 app.is_purging = false;
3204 app.status_message = Some(message);
3205 }
3206 EngineEvent::PurgeFailed { message } => {
3207 app.is_purging = false;
3208 app.status_message = Some(message);
3209 }
3210 EngineEvent::PrefixCacheChange {
3211 description,
3212 stability_pct,
3213 changed,
3214 pinned_combined_hash,
3215 pin_reason,
3216 last_miss_reason,
3217 context_updates,
3218 ..
3219 } => {
3220 app.prefix_context_updates = context_updates;
3221 app.prefix_checks_total = app.prefix_checks_total.saturating_add(1);
3222 app.prefix_stability_pct = Some(stability_pct);
3223 app.last_pinned_prefix_hash =
3224 (!pinned_combined_hash.is_empty()).then_some(pinned_combined_hash);
3225 app.prefix_pin_reason = (!pin_reason.is_empty()).then_some(pin_reason);
3226 // A declared re-pin or reset is an expected miss, not a
3227 // silent-cache-death drift; only an undeclared drift is
3228 // a real problem.
3229 let is_drift = description.starts_with("drift");
3230 app.prefix_last_miss_reason =
3231 (!last_miss_reason.is_empty()).then_some(last_miss_reason);
3232 if changed {
3233 app.prefix_change_count = app.prefix_change_count.saturating_add(1);
3234 if is_drift {
3235 app.prefix_drift_count = app.prefix_drift_count.saturating_add(1);
3236 }
3237 if !description.is_empty() {
3238 app.last_prefix_change_desc = Some(description);
3239 }
3240 }
3241 }
3242 EngineEvent::LspRepairUpdate {
3243 diagnostics_found,
3244 files,
3245 injected,
3246 } => {
3247 let repair = &mut app.lsp_repair;
3248 repair.diagnostics_found =
3249 repair.diagnostics_found.saturating_add(diagnostics_found);
3250 repair.files_touched = repair.files_touched.saturating_add(files);
3251 if injected {
3252 // Injection itself is not a repair attempt — the model
3253 // has only been shown the diagnostics so far (#4107).
3254 repair.injected = true;
3255 if repair.latest == "unavailable" || repair.latest.is_empty() {
3256 repair.latest = "unknown";
3257 }
3258 } else if repair.injected {
3259 // Diagnostics after a prior injection imply the model
3260 // edited again (a repair attempt). Zero findings = resolved.
3261 repair.repair_attempted = true;
3262 repair.latest = if diagnostics_found == 0 {
3263 "resolved"
3264 } else {
3265 "still_failing"
3266 };
3267 } else {
3268 repair.latest = "unknown";
3269 }
3270 }
3271 EngineEvent::PauseEvents { ack } => {
3272 if !event_broker.is_paused() {
3273 let input_handoff =
3274 match terminal_input.pause_for_child_terminal().await {
3275 Ok(()) => prepare_terminal_input_handoff(
3276 &terminal_input,
3277 &mut pending_terminal_events,
3278 ),
3279 Err(err) => Err(err),
3280 };
3281 match input_handoff {
3282 Ok(true) => {}
3283 Ok(false) => {
3284 terminal_input.resume_after_child_terminal();
3285 tracing::debug!(
3286 "refusing interactive child because cancellation input is pending"
3287 );
3288 // Preserve Esc/Ctrl+C for the ordinary
3289 // key path and withhold the ack so the
3290 // child cannot race ahead of cancellation.
3291 continue;
3292 }
3293 Err(err) => {
3294 terminal_input.resume_after_child_terminal();
3295 tracing::warn!(
3296 error = %err,
3297 "refusing interactive child after terminal input handoff failed"
3298 );
3299 let recovery = match terminal_input.restart_detached() {
3300 Ok(()) => "Terminal input recovered.".to_string(),
3301 Err(restart_err) => {
3302 tracing::warn!(
3303 error = %restart_err,
3304 "failed to restart terminal input after handoff refusal"
3305 );
3306 format!(
3307 "Terminal input recovery also failed ({restart_err}); restart Codewhale if keys stop responding."
3308 )
3309 }
3310 };
3311 app.push_status_toast(
3312 format!(
3313 "Interactive terminal handoff refused ({err}). {recovery}"
3314 ),
3315 StatusToastLevel::Error,
3316 None,
3317 );
3318 app.needs_redraw = true;
3319 last_terminal_input_recovery = Instant::now();
3320 // Do not acknowledge the pause. The
3321 // engine guard times out, refuses the
3322 // child, and queues a harmless resume.
3323 continue;
3324 }
3325 }
3326 if let Err(err) = pause_terminal(
3327 terminal,
3328 app.use_alt_screen(),
3329 app.use_mouse_capture,
3330 app.use_bracketed_paste,
3331 ) {
3332 terminal_input.resume_after_child_terminal();
3333 tracing::warn!(
3334 error = %err,
3335 "refusing interactive child after terminal mode handoff failed"
3336 );
3337 resume_terminal(
3338 terminal,
3339 app.use_alt_screen(),
3340 app.use_mouse_capture,
3341 app.use_bracketed_paste,
3342 app.synchronized_output_enabled,
3343 )
3344 .with_context(|| {
3345 format!(
3346 "terminal handoff failed ({err}) and Codewhale could not restore terminal controls"
3347 )
3348 })?;
3349 app.push_status_toast(
3350 format!("Interactive terminal handoff refused ({err})."),
3351 StatusToastLevel::Error,
3352 None,
3353 );
3354 app.needs_redraw = true;
3355 force_terminal_repaint = true;
3356 // As above, withholding the acknowledgement
3357 // keeps the child from launching.
3358 continue;
3359 }
3360 event_broker.pause_events();
3361 terminal_paused_at = Some(Instant::now());
3362 }
3363 if let Some(ack) = ack {
3364 ack.notify_one();
3365 }
3366 }
3367 EngineEvent::ResumeEvents => {
3368 if event_broker.is_paused() {
3369 resume_terminal(
3370 terminal,
3371 app.use_alt_screen(),
3372 app.use_mouse_capture,
3373 app.use_bracketed_paste,
3374 app.synchronized_output_enabled,
3375 )?;
3376 event_broker.resume_events();
3377 terminal_input.resume_after_child_terminal();
3378 terminal_paused_at = None;
3379 }
3380 }
3381 EngineEvent::AgentSpawned {
3382 owner_session_id,
3383 id,
3384 prompt,
3385 worker_status,
3386 parent_run_id,
3387 spawn_depth,
3388 model,
3389 route_source: _,
3390 } if event_owner_is_active(
3391 app.current_session_id.as_deref(),
3392 &owner_session_id,
3393 ) =>
3394 {
3395 let prompt_summary = bound_agent_activity_text(&prompt);
3396 app.agent_progress
3397 .insert(id.clone(), format!("starting: {prompt_summary}"));
3398 let meta = app.agent_progress_meta.entry(id.clone()).or_default();
3399 meta.parent_run_id = parent_run_id;
3400 meta.spawn_depth = spawn_depth;
3401 meta.current_activity = worker_status.map(|status| {
3402 AgentCurrentActivity::bounded(
3403 status.into(),
3404 Some(prompt_summary.clone()),
3405 None,
3406 None,
3407 )
3408 });
3409 meta.current_tool = None;
3410 record_agent_spawned_route(app, &id, &model);
3411 if app.agent_activity_started_at.is_none() {
3412 app.agent_activity_started_at = Some(Instant::now());
3413 }
3414 // #3030: Assign a stable user-facing label for this
3415 // agent and keep the raw id out of the status bar.
3416 apply_agent_spawned_status_and_observer(app, &id, &prompt, &prompt_summary);
3417 subagent_list_refresh_requested = true;
3418 }
3419 EngineEvent::AgentProgress {
3420 owner_session_id,
3421 id,
3422 status,
3423 activity,
3424 parent_run_id,
3425 spawn_depth,
3426 } if event_owner_is_active(
3427 app.current_session_id.as_deref(),
3428 &owner_session_id,
3429 ) =>
3430 {
3431 let display = bound_agent_activity_text(&friendly_subagent_progress(
3432 app,
3433 &id,
3434 &status,
3435 activity.routine_wait,
3436 ));
3437 if activity.routine_wait {
3438 app.agent_progress
3439 .entry(id.clone())
3440 .or_insert_with(|| display.clone());
3441 } else {
3442 app.agent_progress.insert(id.clone(), display.clone());
3443 }
3444 let meta = app.agent_progress_meta.entry(id.clone()).or_default();
3445 meta.parent_run_id = parent_run_id;
3446 meta.spawn_depth = spawn_depth;
3447 let current_tool = activity
3448 .tool_name
3449 .as_deref()
3450 .map(subagent_progress_tool_display_name)
3451 .map(str::to_string);
3452 meta.current_activity = Some(AgentCurrentActivity::bounded(
3453 activity.worker_status.into(),
3454 Some(display.clone()),
3455 current_tool.clone(),
3456 activity.step,
3457 ));
3458 meta.current_tool = current_tool;
3459 if app.agent_activity_started_at.is_none() {
3460 app.agent_activity_started_at = Some(Instant::now());
3461 }
3462 // #3030: progress can arrive before AgentSpawned is
3463 // observed — assign the stable label on first sight.
3464 let label = app.ensure_agent_label(&id);
3465 app.status_message = Some(format!("{label}: {display}"));
3466 // A progress-first agent (its AgentSpawned was dropped
3467 // under channel pressure) exists only in agent_progress
3468 // until a ListSubAgents refresh promotes it into
3469 // subagent_cache. Request that refresh like the
3470 // AgentSpawned arm does, so the sidebar row survives
3471 // reconciliation instead of flickering out.
3472 if !app.subagent_cache.iter().any(|agent| agent.agent_id == id) {
3473 subagent_list_refresh_requested = true;
3474 }
3475 // #3033: Throttle redraws from rapid AgentProgress events.
3476 // When 4+ sub-agents are running concurrently, each firing
3477 // progress events, the per-event `needs_redraw = true` saturates
3478 // the render loop and starves terminal input. Limit
3479 // progress-driven repaints to at most one per 100ms; the
3480 // status-animation timer (80ms cadence) provides a guaranteed
3481 // floor for sidebar updates. Data is still recorded immediately;
3482 // the sidebar picks it up on the next permitted redraw.
3483 if !agent_progress_redraw_permitted_for_drain(
3484 &mut app.last_agent_progress_redraw,
3485 &mut progress_redraw_agents,
3486 &id,
3487 Instant::now(),
3488 ) {
3489 // Restore the pre-event accumulator value: a
3490 // throttled progress event contributes no redraw of
3491 // its own, but earlier events' redraws survive.
3492 received_engine_event = redraw_requested_before_event;
3493 }
3494 }
3495 EngineEvent::AgentComplete {
3496 owner_session_id,
3497 id,
3498 result,
3499 outcome,
3500 ..
3501 } if event_owner_is_active(
3502 app.current_session_id.as_deref(),
3503 &owner_session_id,
3504 ) =>
3505 {
3506 let subagent_elapsed = app
3507 .agent_activity_started_at
3508 .or(app.turn_started_at)
3509 .map(|started| started.elapsed())
3510 .unwrap_or_default();
3511 let has_other_running_subagents =
3512 app.agent_progress.keys().any(|agent_id| agent_id != &id)
3513 || app.subagent_cache.iter().any(|agent| {
3514 agent.agent_id != id
3515 && matches!(agent.status, SubAgentStatus::Running)
3516 });
3517 app.agent_progress.remove(&id);
3518 let terminal_status = outcome;
3519 if let Some(terminal_status) = terminal_status.as_ref() {
3520 apply_subagent_terminal_projection(
3521 app,
3522 &id,
3523 terminal_status.clone(),
3524 Some(bound_agent_activity_text(&result)),
3525 );
3526 apply_agent_complete_status_and_observer(
3527 app,
3528 &id,
3529 &result,
3530 terminal_status,
3531 );
3532 } else {
3533 let label = app.ensure_agent_label(&id);
3534 app.status_message = Some(format!(
3535 "{label} settled; outcome unconfirmed. Refreshing worker state."
3536 ));
3537 }
3538 let should_recapture_terminal =
3539 !has_other_running_subagents && app.use_alt_screen();
3540 let subagent_notification_mode =
3541 config.notifications_config().subagent_completion;
3542 let workflow_tool_running = workflow_tool_is_running(app);
3543 if let Some(terminal_status) = terminal_status.as_ref()
3544 && should_notify_subagent_completion(
3545 subagent_notification_mode,
3546 has_other_running_subagents,
3547 workflow_tool_running,
3548 )
3549 && let Some((method, threshold, include_summary)) =
3550 notifications::settings(config)
3551 {
3552 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
3553 let payload = notifications::subagent_terminal_payload(
3554 app.ui_locale,
3555 &id,
3556 &result,
3557 terminal_status,
3558 include_summary,
3559 subagent_elapsed,
3560 );
3561 crate::tui::notifications::notify_done(
3562 method,
3563 in_tmux,
3564 &payload,
3565 threshold,
3566 subagent_elapsed,
3567 );
3568 }
3569 if should_recapture_terminal && event_broker.is_paused() {
3570 resume_terminal(
3571 terminal,
3572 app.use_alt_screen(),
3573 app.use_mouse_capture,
3574 app.use_bracketed_paste,
3575 app.synchronized_output_enabled,
3576 )?;
3577 event_broker.resume_events();
3578 terminal_input.resume_after_child_terminal();
3579 terminal_paused_at = None;
3580 app.needs_redraw = true;
3581 }
3582 subagent_list_refresh_requested = true;
3583 }
3584 EngineEvent::SubAgentFollowUp {
3585 owner_session_id,
3586 agent_id,
3587 outcome,
3588 } if event_owner_is_active(
3589 app.current_session_id.as_deref(),
3590 &owner_session_id,
3591 ) =>
3592 {
3593 crate::tui::agent_focus::apply_follow_up_receipt(app, &agent_id, &outcome);
3594 }
3595 EngineEvent::AgentList {
3596 owner_session_id,
3597 agents,
3598 coordination,
3599 queued_follow_ups,
3600 roster,
3601 } if event_owner_is_active(
3602 app.current_session_id.as_deref(),
3603 &owner_session_id,
3604 ) =>
3605 {
3606 app.agent_queued_follow_ups = queued_follow_ups;
3607 app.agent_roster = roster;
3608 app.agent_roster_session_id = Some(owner_session_id);
3609 if std::mem::take(&mut app.agent_roster_print_requested) {
3610 let content = crate::tui::agent_roster::render_agent_roster(
3611 app.current_agent_roster(),
3612 "main",
3613 );
3614 app.add_message(crate::tui::history::HistoryCell::System { content });
3615 }
3616 let mut sorted = agents.clone();
3617 sort_subagents_in_place(&mut sorted);
3618 sorted.retain(|a| !a.from_prior_session);
3619 app.subagent_cache = sorted.clone();
3620 apply_coordination_detail_projection(app, coordination);
3621 reconcile_subagent_activity_state(app);
3622 let view_agents = subagent_view_agents(app, &app.subagent_cache);
3623 if app.view_stack.update_subagents(&view_agents) {
3624 app.status_message = Some(current_session_fleet_workers_status(
3625 app.ui_locale,
3626 view_agents.len(),
3627 ));
3628 }
3629 // Individual spawn/complete events already log to history;
3630 // full list available via /agents command.
3631 }
3632 EngineEvent::AgentSpawned { .. }
3633 | EngineEvent::AgentProgress { .. }
3634 | EngineEvent::AgentComplete { .. }
3635 | EngineEvent::SubAgentFollowUp { .. }
3636 | EngineEvent::AgentList { .. } => {
3637 // Process-local senders can outlive a session switch.
3638 // A foreign event must not mutate the active transcript,
3639 // sidebar, status, observer, or notification surface.
3640 received_engine_event = redraw_requested_before_event;
3641 }
3642 EngineEvent::SubAgentMailbox {
3643 owner_session_id,
3644 turn_id,
3645 seq,
3646 message,
3647 } if event_owner_is_active(
3648 app.current_session_id.as_deref(),
3649 &owner_session_id,
3650 ) =>
3651 {
3652 let should_refresh_subagents =
3653 subagent_message_refreshes_workspace_context(&message);
3654 let updated_transcript =
3655 handle_subagent_mailbox_for_turn(app, &turn_id, seq, &message);
3656 if let Some((agent_id, status, result)) =
3657 subagent_terminal_projection_from_mailbox(&message)
3658 {
3659 apply_subagent_terminal_projection(app, agent_id, status, result);
3660 subagent_list_refresh_requested = true;
3661 }
3662 if should_refresh_subagents {
3663 subagent_list_refresh_requested = true;
3664 }
3665 if updated_transcript {
3666 transcript_batch_updated = true;
3667 } else if !should_refresh_subagents
3668 && matches!(
3669 message,
3670 crate::tools::subagent::MailboxMessage::Progress { .. }
3671 )
3672 {
3673 // Progress mailbox envelopes mirror AgentProgress.
3674 // When the card state did not visibly change, do
3675 // not let the duplicate envelope bypass the
3676 // AgentProgress redraw throttle.
3677 received_engine_event = redraw_requested_before_event;
3678 }
3679 }
3680 EngineEvent::SubAgentMailbox { .. } => {
3681 received_engine_event = redraw_requested_before_event;
3682 }
3683 EngineEvent::WorkflowUi {
3684 owner_session_id,
3685 run_id,
3686 event,
3687 } => {
3688 if !apply_owned_workflow_ui_event(app, &owner_session_id, &run_id, &event) {
3689 tracing::debug!("discarding workflow UI event for an inactive session");
3690 received_engine_event = redraw_requested_before_event;
3691 continue;
3692 }
3693 // #4095 residual: budget_updated is high-frequency under
3694 // multi-agent fan-out. Data is already applied; pace the
3695 // repaint like AgentProgress so the panel does not churn.
3696 let is_budget = event
3697 .get("type")
3698 .and_then(|v| v.as_str())
3699 .is_some_and(|t| t == "budget_updated");
3700 if is_budget {
3701 if workflow_budget_redraw_permitted(
3702 &mut app.last_workflow_budget_redraw,
3703 Instant::now(),
3704 ) {
3705 app.needs_redraw = true;
3706 } else {
3707 received_engine_event = redraw_requested_before_event;
3708 }
3709 }
3710 transcript_batch_updated = true;
3711 }
3712 EngineEvent::ApprovalRequired {
3713 id,
3714 tool_name,
3715 description,
3716 input,
3717 approval_key,
3718 approval_grouping_key,
3719 intent_summary,
3720 approval_force_prompt,
3721 } => {
3722 // A count and nothing else. The tool name, the
3723 // description, the input, and the matched rule are all
3724 // user- or model-authored strings.
3725 codewhale_telemetry::session_counters()
3726 .bump(codewhale_telemetry::Counter::ApprovalModalShown);
3727 // Mirror semantics: the approval is always shown
3728 // locally. When the web mirror is attached to this
3729 // turn, ALSO record it so the web can answer; the
3730 // first decision wins (`resolve_pending_approval`
3731 // vs `take_pending_approval`).
3732 let shared_with_web = if app.remote_control.can_share_approval_with_web() {
3733 app.remote_control.record_remote_approval(
3734 &id,
3735 &tool_name,
3736 &description,
3737 &input,
3738 &approval_key,
3739 intent_summary.as_deref(),
3740 );
3741 true
3742 } else {
3743 false
3744 };
3745 use crate::core::authority::ApprovalRequestDisposition;
3746 // One disposition path for every ApprovalRequired (#4412):
3747 // session denial, Full Access policy hold, session/FA
3748 // auto-approve, Never posture, or modal prompt.
3749 match resolve_ui_approval_disposition(
3750 app,
3751 &tool_name,
3752 &approval_grouping_key,
3753 &approval_key,
3754 approval_force_prompt,
3755 ) {
3756 ApprovalRequestDisposition::AutoDenySessionDenied => {
3757 // The user already denied a matching approval key
3758 // during this process; auto-deny so the
3759 // model's retry loop doesn't keep re-prompting
3760 // (#360).
3761 auto_deny_session_approval(
3762 app,
3763 &engine_handle,
3764 &id,
3765 &tool_name,
3766 &approval_key,
3767 )
3768 .await;
3769 }
3770 ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold => {
3771 log_sensitive_event(
3772 "tool.approval.auto_deny_full_access_policy",
3773 serde_json::json!({
3774 "tool_name": tool_name,
3775 "session_id": app.current_session_id,
3776 "mode": app.mode.label(),
3777 }),
3778 );
3779 let _ = engine_handle.deny_tool_call(id.clone()).await;
3780 let notice = app
3781 .tr(MessageId::ApprovalFullAccessPolicyBlocked)
3782 .replace("{tool}", &tool_name);
3783 app.push_status_toast(
3784 notice,
3785 StatusToastLevel::Warning,
3786 Some(12_000),
3787 );
3788 }
3789 ApprovalRequestDisposition::AutoApprove => {
3790 log_sensitive_event(
3791 "tool.approval.auto_approve_session",
3792 serde_json::json!({
3793 "tool_name": tool_name,
3794 "approval_key": approval_key,
3795 "session_id": app.current_session_id,
3796 "mode": app.mode.label(),
3797 }),
3798 );
3799 let _ = engine_handle.approve_tool_call(id.clone()).await;
3800 }
3801 ApprovalRequestDisposition::AutoDenyAutoReview => {
3802 log_sensitive_event(
3803 "tool.approval.auto_deny_auto_review",
3804 serde_json::json!({
3805 "tool_name": tool_name,
3806 "session_id": app.current_session_id,
3807 "mode": app.mode.label(),
3808 }),
3809 );
3810 let _ = engine_handle.deny_tool_call(id.clone()).await;
3811 let held = crate::tui::gate_receipts::auto_review_held_receipt(
3812 app.ui_locale,
3813 &tool_name,
3814 );
3815 app.add_message(HistoryCell::System {
3816 content: held.clone(),
3817 });
3818 app.push_status_toast_record(
3819 StatusToast::new(held, StatusToastLevel::Warning, Some(12_000))
3820 .for_event(format!("approval-held:{id}")),
3821 );
3822 }
3823 ApprovalRequestDisposition::AutoDenyNeverPosture => {
3824 log_sensitive_event(
3825 "tool.approval.auto_deny",
3826 serde_json::json!({
3827 "tool_name": tool_name,
3828 "session_id": app.current_session_id,
3829 "mode": app.mode.label(),
3830 }),
3831 );
3832 let _ = engine_handle.deny_tool_call(id.clone()).await;
3833 app.push_status_toast_record(
3834 StatusToast::new(
3835 app.tr(MessageId::ApprovalNeverPostureBlocked)
3836 .replace("{tool}", &tool_name),
3837 StatusToastLevel::Warning,
3838 Some(12_000),
3839 )
3840 .for_event(format!("approval-blocked:{id}")),
3841 );
3842 }
3843 ApprovalRequestDisposition::Prompt => {
3844 let tool_input = input;
3845
3846 push_approval_request_view(
3847 app,
3848 &id,
3849 &tool_name,
3850 &description,
3851 &tool_input,
3852 &approval_key,
3853 intent_summary.as_deref(),
3854 config.approval_default_selection(),
3855 config.approval_timeout(),
3856 );
3857 log_sensitive_event(
3858 "tool.approval.prompted",
3859 serde_json::json!({
3860 "tool_name": tool_name,
3861 "description": description,
3862 "session_id": app.current_session_id,
3863 "mode": app.mode.label(),
3864 }),
3865 );
3866 let payload = notifications::approval_needed_payload(
3867 app.ui_locale,
3868 &tool_name,
3869 );
3870 if let Some((method, _, _)) =
3871 crate::tui::notifications::settings(config)
3872 {
3873 let in_tmux =
3874 std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
3875 // #4834: the tool *description* is the
3876 // pending command. It stays in the
3877 // terminal, where the user can read it
3878 // in context; the banner names only the
3879 // tool. Copy is centralized (#5041) so
3880 // the action-first phrasing is tested.
3881 crate::tui::notifications::notify_done(
3882 method,
3883 in_tmux,
3884 &payload,
3885 Duration::ZERO,
3886 Duration::ZERO,
3887 );
3888 }
3889 let mut notice = payload.headline().to_string();
3890 if shared_with_web {
3891 notice.push_str(" · ");
3892 notice
3893 .push_str(&app.tr(MessageId::NotificationDecisionWebHint));
3894 }
3895 app.push_status_toast_record(
3896 StatusToast::new(
3897 notice,
3898 StatusToastLevel::Warning,
3899 Some(12_000),
3900 )
3901 .for_action(id.clone()),
3902 );
3903 }
3904 }
3905 }
3906 EngineEvent::UserInputRequired { id, request } => {
3907 if should_suppress_user_input_prompt(app) {
3908 // A question may have been planned just before the
3909 // user switched to Auto-Review. Cancel the stale
3910 // request instead of opening a modal under an Auto
3911 // header; the tool result tells the model to keep
3912 // moving without inventing a user choice.
3913 log_sensitive_event(
3914 "tool.user_input.auto_cancelled_auto_review",
3915 serde_json::json!({
3916 "tool_id": id.clone(),
3917 "session_id": app.current_session_id,
3918 }),
3919 );
3920 let _ = engine_handle.cancel_user_input(id).await;
3921 app.pending_user_input_prompt = None;
3922 let notice = app.tr(MessageId::AutoReviewQuestionSkipped).into_owned();
3923 app.push_status_toast(notice, StatusToastLevel::Info, Some(6_000));
3924 } else {
3925 app.pending_user_input_prompt = Some((id.clone(), request.clone()));
3926 app.view_stack.push(UserInputView::new(id.clone(), request));
3927 let payload = notifications::input_needed_payload(app.ui_locale);
3928 if let Some((method, _, _)) =
3929 crate::tui::notifications::settings(config)
3930 {
3931 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
3932 crate::tui::notifications::notify_done(
3933 method,
3934 in_tmux,
3935 &payload,
3936 Duration::ZERO,
3937 Duration::ZERO,
3938 );
3939 }
3940 app.push_status_toast_record(
3941 StatusToast::new(
3942 payload.headline(),
3943 StatusToastLevel::Warning,
3944 Some(12_000),
3945 )
3946 .for_action(id.clone()),
3947 );
3948 }
3949 }
3950 EngineEvent::ElevationRequired {
3951 tool_id,
3952 tool_name,
3953 command,
3954 denial_reason,
3955 blocked_network,
3956 blocked_write,
3957 } => {
3958 // Auto-approved modes may retry denied tools without another prompt.
3959 if app_auto_approve_enabled(app) {
3960 log_sensitive_event(
3961 "tool.sandbox.auto_elevate",
3962 serde_json::json!({
3963 "tool_name": tool_name,
3964 "tool_id": tool_id,
3965 "reason": denial_reason,
3966 "session_id": app.current_session_id,
3967 }),
3968 );
3969 app.add_message(HistoryCell::System {
3970 content: format!(
3971 "Sandbox denied {tool_name}: {denial_reason} - auto-elevating to full access"
3972 ),
3973 });
3974 // Auto-elevate to full access (no sandbox)
3975 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
3976 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
3977 } else {
3978 log_sensitive_event(
3979 "tool.sandbox.prompt_elevation",
3980 serde_json::json!({
3981 "tool_name": tool_name,
3982 "tool_id": tool_id,
3983 "reason": denial_reason,
3984 "session_id": app.current_session_id,
3985 }),
3986 );
3987 // Show elevation dialog
3988 let request = ElevationRequest::for_shell(
3989 &tool_id,
3990 command.as_deref().unwrap_or(&tool_name),
3991 &denial_reason,
3992 blocked_network,
3993 blocked_write,
3994 );
3995 app.view_stack
3996 .push(ElevationView::new(request, app.ui_locale));
3997 let payload = notifications::elevation_needed_payload(
3998 app.ui_locale,
3999 &tool_name,
4000 &denial_reason,
4001 );
4002 if let Some((method, _, _)) =
4003 crate::tui::notifications::settings(config)
4004 {
4005 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
4006 crate::tui::notifications::notify_done(
4007 method,
4008 in_tmux,
4009 &payload,
4010 Duration::ZERO,
4011 Duration::ZERO,
4012 );
4013 }
4014 app.push_status_toast_record(
4015 StatusToast::new(
4016 payload.headline(),
4017 StatusToastLevel::Warning,
4018 Some(12_000),
4019 )
4020 .for_action(tool_id.clone()),
4021 );
4022 }
4023 }
4024 EngineEvent::TurnUsage {
4025 max_output_tokens: _,
4026 usage,
4027 duration_ms,
4028 first_token_ms,
4029 request_ms,
4030 } => {
4031 // Per-step usage receipt. The session metrics strip
4032 // folds each model call's timing (stream time, TTFT,
4033 // whole-call time) here.
4034 app.session_metrics.record_model_call(
4035 usage.output_tokens,
4036 duration_ms,
4037 first_token_ms,
4038 request_ms,
4039 );
4040 // Billed prompt receipt for the context meter: what
4041 // the provider says the model actually processed
4042 // (#5577). Reviewer/REPL child receipts also arrive
4043 // here, but their prompt is never larger than the
4044 // parent context, and the meter takes a max.
4045 if usage.input_tokens > 0 {
4046 app.last_billed_input_tokens = Some(
4047 app.last_billed_input_tokens
4048 .map_or(usage.input_tokens, |prior| {
4049 prior.max(usage.input_tokens)
4050 }),
4051 );
4052 }
4053 // Live cost: price this call against the route that
4054 // was actually dispatched so the cost surfaces move
4055 // during a long agentic turn instead of only at its
4056 // end. Provisional by design — `TurnComplete` clears
4057 // this and lands the authoritative cumulative price
4058 // through the same audit path, so nothing counts
4059 // twice and the two can never disagree on route.
4060 let step_cost = app
4061 .active_turn
4062 .as_ref()
4063 .and_then(|turn| turn.route.as_ref())
4064 .and_then(crate::core::events::TurnRoute::cost_envelope)
4065 .and_then(|route| route.audit(&usage).estimate);
4066 if let Some(cost) = step_cost {
4067 app.accrue_pending_turn_cost_estimate(cost);
4068 }
4069 app.session.accrue_pending_turn_usage(&usage);
4070 }
4071 EngineEvent::RoutedTurnUsage {
4072 usage,
4073 duration_ms,
4074 first_token_ms,
4075 request_ms,
4076 } => {
4077 // Routed calls own separate immutable cost receipts.
4078 // Preserve model-call telemetry without pricing them
4079 // provisionally under the active parent route or
4080 // incrementally adding tokens that TurnComplete will
4081 // reconcile authoritatively.
4082 app.session_metrics.record_model_call(
4083 usage.output_tokens,
4084 duration_ms,
4085 first_token_ms,
4086 request_ms,
4087 );
4088 }
4089 EngineEvent::AdvisoryNote { note, .. } => {
4090 // Advisor background watcher note. Display as a
4091 // concise system message in the transcript so the
4092 // user can see it without it blocking the parent turn.
4093 if note.trim() != "ok" {
4094 app.add_message(HistoryCell::System {
4095 content: format!("⚑ Advisor: {note}"),
4096 });
4097 }
4098 }
4099 EngineEvent::ToolGateDecision {
4100 agent_id,
4101 tool_id,
4102 tool_name,
4103 gate,
4104 decision,
4105 risk,
4106 reason,
4107 } => {
4108 // A permission decision nobody was prompted for. The
4109 // audit log already has the full record; the
4110 // transcript gets a one-line receipt so the person
4111 // can see who decided and why, without a modal. It is
4112 // held until the tool card completes so it lands
4113 // under that card rather than inside a running run.
4114 let receipt = crate::tui::gate_receipts::tool_gate_receipt(
4115 app.ui_locale,
4116 &tool_name,
4117 gate,
4118 decision,
4119 risk.as_deref(),
4120 &reason,
4121 );
4122 if let Some(agent_id) = agent_id {
4123 // A child's decision belongs to the child's
4124 // conversation: it renders under that tool card
4125 // in focus mode, not in the main transcript.
4126 app.child_gate_receipts
4127 .entry(agent_id.clone())
4128 .or_default()
4129 .push((tool_id, receipt));
4130 if app
4131 .agent_focus
4132 .as_ref()
4133 .is_some_and(|focus| focus.is(&agent_id))
4134 {
4135 crate::tui::agent_focus::refresh_focus(app);
4136 }
4137 } else {
4138 app.pending_gate_receipts.push((tool_id, receipt));
4139 }
4140 }
4141 }
4142 events_drained = events_drained.saturating_add(1);
4143 }
4144 }
4145 if let Some(rollback) = fallback_after_engine_error {
4146 apply_provider_fallback_switch(app, &mut engine_handle, config, rollback).await;
4147 }
4148 if let Some(rollback_warning) = respawn_after_provider_rollback {
4149 let _ = engine_handle.send(Op::Shutdown).await;
4150 let engine_config = build_engine_config(app, config);
4151 engine_handle = spawn_tui_engine(engine_config, config);
4152 if !app.api_messages.is_empty() {
4153 let _ = engine_handle
4154 .send(Op::SyncSession {
4155 session_id: app.current_session_id.clone(),
4156 messages: app.api_messages.as_ref().clone(),
4157 system_prompt: app.system_prompt.clone(),
4158 system_prompt_override: false,
4159 model: app.model.clone(),
4160 workspace: app.workspace.clone(),
4161 mode: app.mode,
4162 })
4163 .await;
4164 }
4165 let _ = engine_handle
4166 .send(Op::SetCompaction {
4167 config: app.compaction_config(),
4168 })
4169 .await;
4170 app.status_message = Some(rollback_warning);
4171 }
4172 if commit_streaming_display_tick(app, &mut stream_display_clock, Instant::now()) {
4173 transcript_batch_updated = true;
4174 }
4175 // #4022: `/lane interrupt` answers immediately with a queued receipt,
4176 // which is not an outcome. The terminal receipt lands here, under the
4177 // ticket the composer printed, so a queued write is never left looking
4178 // like it succeeded. Drain is non-blocking: it only takes the queue
4179 // mutex, and a poisoned one yields nothing rather than panicking the
4180 // event loop.
4181 for receipt in app.lane_control.drain_completed() {
4182 app.add_message(HistoryCell::System {
4183 content: receipt.render(),
4184 });
4185 transcript_batch_updated = true;
4186 }
4187 if drain_runtime_store_failures(app, &mut runtime_event_rx) {
4188 transcript_batch_updated = true;
4189 }
4190 if transcript_batch_updated {
4191 app.mark_history_updated();
4192 }
4193 if received_engine_event {
4194 // ListSubAgents can wait behind the parent's active turn. The
4195 // open register must also reflect the already-received, session-
4196 // scoped lifecycle events, using the same projection as opening it.
4197 if app.view_stack.contains_kind(ModalKind::SubAgents) {
4198 let agents = subagent_view_agents(app, &app.subagent_cache);
4199 app.view_stack.update_subagents(&agents);
4200 }
4201 app.needs_redraw = true;
4202 }
4203 if subagent_list_refresh_requested {
4204 pending_subagent_list_refresh = true;
4205 }
4206 // #freeze: one trailing-edge sub-agent list refresh per drain, no
4207 // matter how many spawn/complete/mailbox events arrived this batch.
4208 // #3837: keep a sticky pending bit when the op channel is full so a
4209 // terminal lifecycle event cannot permanently lose the authoritative
4210 // ListSubAgents refresh.
4211 if pending_subagent_list_refresh {
4212 match engine_handle.try_send(Op::ListSubAgents) {
4213 Ok(()) => pending_subagent_list_refresh = false,
4214 Err(err) => {
4215 if err
4216 .downcast_ref::<tokio::sync::mpsc::error::TrySendError<Op>>()
4217 .is_some_and(|send_err| {
4218 matches!(send_err, tokio::sync::mpsc::error::TrySendError::Closed(_))
4219 })
4220 {
4221 pending_subagent_list_refresh = false;
4222 }
4223 }
4224 }
4225 }
4226
4227 if let Some(next) = queued_to_send {
4228 let _ = dispatch_user_message_with_recovery(
4229 app,
4230 config,
4231 &engine_handle,
4232 next,
4233 DispatchRecovery::Queued {
4234 restore_index: None,
4235 },
4236 )
4237 .await;
4238
4239 app.needs_redraw = true;
4240 }
4241
4242 // Avoid cloning the queued messages/draft every loop iteration
4243 // (~20-40 Hz) purely for change detection. When the queue is empty and
4244 // was empty last time — the overwhelmingly common case — there is
4245 // nothing to compare, so skip the clone entirely. A multi-KB queued
4246 // draft is only cloned while one is actually pending.
4247 let queue_now_empty = app.queued_messages.is_empty() && app.queued_draft.is_none();
4248 if !(queue_now_empty && last_queue_was_empty) {
4249 let queue_state = offline_queue_projection(app);
4250 if queue_state != last_queue_state {
4251 persist_offline_queue_state(app);
4252 last_queue_state = queue_state;
4253 app.needs_redraw = true;
4254 }
4255 last_queue_was_empty = queue_now_empty;
4256 }
4257
4258 if !app.view_stack.is_empty() {
4259 let events = app.view_stack.tick();
4260 if !events.is_empty() {
4261 app.needs_redraw = true;
4262 if handle_view_events_boxed(
4263 terminal,
4264 app,
4265 config,
4266 &task_manager,
4267 &mut engine_handle,
4268 events,
4269 )
4270 .await?
4271 {
4272 return Ok(());
4273 }
4274 }
4275 }
4276
4277 let has_running_agents = running_agent_count(app) > 0;
4278 if reconcile_turn_liveness(app, Instant::now(), has_running_agents) {
4279 app.needs_redraw = true;
4280 }
4281 maybe_throttled_recovery_snapshot(app, Instant::now(), &mut last_recovery_snapshot_at);
4282 let history_has_live_motion = history_has_live_motion(&app.history);
4283 crate::tui::pet_watch::tick(app, Instant::now());
4284 let active_cell_has_live_motion = active_cell_has_live_motion(app);
4285 let translation_placeholder_has_live_motion = app.translation_enabled
4286 && (pending_thinking_translations > 0 || app.streaming_thinking_active_entry.is_some());
4287 // The ordinary terminal stays quiet. Only the underwater theme earns
4288 // ambient redraws; its column can breathe at any usable size and its
4289 // life needs the collision-safe water budget.
4290 let underwater_atmosphere_enabled = app.theme_id == codewhale_palette::ThemeId::Underwater;
4291 let deepsea_field_breathes = underwater_atmosphere_enabled
4292 && crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some();
4293 let browsing_history = !app.viewport.transcript_scroll.is_at_tail();
4294 let empty_water_visible = app.history.is_empty()
4295 && app
4296 .active_cell
4297 .as_ref()
4298 .is_none_or(crate::tui::active_cell::ActiveCell::is_empty)
4299 && !app.is_loading;
4300 // A paused terminal owns the eye. Modal/launch/onboarding visibility
4301 // and attention stillness are centralized in the shell motion gate.
4302 let underwater_surface_obscured = event_broker.is_paused();
4303 let underwater_motion_visible = underwater_motion_surface_visible(
4304 app.viewport.last_transcript_area,
4305 underwater_atmosphere_enabled,
4306 deepsea_field_breathes,
4307 empty_water_visible,
4308 underwater_surface_obscured,
4309 );
4310 let shell_motion_enabled = crate::tui::underwater::decorative_shell_motion_enabled(app);
4311 let shell_phase_working = matches!(
4312 crate::tui::underwater::ShellPhase::from_app(app),
4313 crate::tui::underwater::ShellPhase::Working
4314 | crate::tui::underwater::ShellPhase::Verifying
4315 );
4316 // A fully idle shell settles: no live turn, no sub-agents, no active
4317 // durable tasks, completion exhale finished, and the user isn't
4318 // browsing. After a short grace the aquarium stops requesting frames
4319 // and the scene is genuinely still until real activity resumes
4320 // (owner pain, captains-log #16).
4321 let durable_tasks_active = app
4322 .task_panel
4323 .iter()
4324 .any(|task| matches!(task.status.as_str(), "queued" | "running" | "waiting"));
4325 let ambient_busy = shell_phase_working
4326 || app.turn_started_at.is_some()
4327 || has_running_agents
4328 || durable_tasks_active
4329 || app.is_loading
4330 || browsing_history
4331 || app.ocean_completion_started_at.is_some_and(|started| {
4332 started.elapsed()
4333 < Duration::from_millis(crate::tui::ocean::COMPLETION_SETTLE_MS as u64)
4334 });
4335 let ambient_settled = app.ambient_idle_settled(ambient_busy, Instant::now());
4336 let underwater_ambient_motion = shell_motion_enabled
4337 && underwater_motion_visible
4338 && !ambient_settled
4339 && (browsing_history || shell_phase_working || empty_water_visible);
4340 let underwater_completion_motion = shell_motion_enabled
4341 && underwater_atmosphere_enabled
4342 && !underwater_surface_obscured
4343 && matches!(app.runtime_turn_status.as_deref(), Some("completed"))
4344 && app.ocean_completion_started_at.is_some_and(|started| {
4345 started.elapsed()
4346 < Duration::from_millis(crate::tui::ocean::COMPLETION_SETTLE_MS as u64)
4347 });
4348 // The launch screen has no transcript widget to drive the ambient
4349 // clock, so it asks for frames itself: while the mark surfaces or
4350 // the card dissolves, and while the underwater field is alive
4351 // (settling on the same idle grace as the transcript's empty water).
4352 let launch_motion = crate::tui::underwater::launch_motion_active(
4353 app,
4354 underwater_surface_obscured,
4355 ambient_settled,
4356 );
4357 let status_motion = should_tick_status_animation(
4358 app,
4359 has_running_agents,
4360 history_has_live_motion,
4361 active_cell_has_live_motion,
4362 translation_placeholder_has_live_motion,
4363 );
4364 let animation_interval_ms = animation_interval_ms(
4365 app,
4366 status_motion,
4367 underwater_ambient_motion || underwater_completion_motion || launch_motion,
4368 );
4369 let motion_policy = app.motion_policy();
4370 if (status_motion
4371 || underwater_ambient_motion
4372 || underwater_completion_motion
4373 || launch_motion)
4374 && last_status_frame.elapsed() >= Duration::from_millis(animation_interval_ms)
4375 {
4376 let translation_animated = streaming_thinking::animate_pending_translation(
4377 app,
4378 pending_thinking_translations > 0,
4379 );
4380 if !matches!(motion_policy.mode(), MotionMode::Still)
4381 && (history_has_live_motion || active_cell_has_live_motion)
4382 {
4383 if translation_animated {
4384 if history_has_live_motion {
4385 app.mark_live_history_motion_updated();
4386 }
4387 } else {
4388 app.mark_live_motion_updated();
4389 }
4390 }
4391 // Coalesce decorative animation wakes through the shared requester.
4392 // Reduced/Still drop these requests; state-change redraws still set
4393 // needs_redraw directly below for phase/working chrome.
4394 frame_requester.request_frame(Instant::now(), motion_policy);
4395 if frame_requester.take_due(Instant::now(), motion_policy)
4396 || !motion_policy.should_request_animation_frames()
4397 {
4398 // Full: emit only when the requester fires. Reduced/Still: keep
4399 // the existing calm redraw so working/phase chrome stays truthful
4400 // without decorative spin (TUI-DOG-008).
4401 app.needs_redraw = true;
4402 }
4403 last_status_frame = Instant::now();
4404 }
4405
4406 if event_broker.is_paused() {
4407 let grace_active = terminal_paused_at
4408 .map(|paused_at| paused_at.elapsed() < Duration::from_millis(500))
4409 .unwrap_or(false);
4410 if terminal_pause_has_live_owner(app) || grace_active {
4411 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4412 continue;
4413 }
4414 resume_terminal(
4415 terminal,
4416 app.use_alt_screen(),
4417 app.use_mouse_capture,
4418 app.use_bracketed_paste,
4419 app.synchronized_output_enabled,
4420 )?;
4421 event_broker.resume_events();
4422 terminal_input.resume_after_child_terminal();
4423 terminal_paused_at = None;
4424 app.status_message = Some("Terminal controls restored".to_string());
4425 app.needs_redraw = true;
4426 force_terminal_repaint = true;
4427 }
4428
4429 let now = Instant::now();
4430 flush_paste_burst_before_composer(app, now);
4431 app.sync_status_message_to_toasts();
4432 // Drain background-LLM cost (compaction summaries, seam
4433 // recompaction, cycle briefings) accumulated since the last
4434 // tick and fold it into the session-cost counter (#526).
4435 // Background callers populate `cost_status::report`; we sweep
4436 // the pool once per loop iteration so the footer chip matches
4437 // the DeepSeek website's billing.
4438 // Money and its completeness are drained as one value, so the footer
4439 // total and the `/cost` coverage line can never come from different
4440 // observations of the pool (#4318).
4441 let pending_bg = crate::cost_status::drain();
4442 if !pending_bg.is_empty() {
4443 let runtime_usage_arrived = app.absorb_pending_background_cost(&pending_bg);
4444 if pending_bg.estimate.is_positive() {
4445 app.needs_redraw = true;
4446 }
4447 // Runtime-owned child usage can land after the parent's
4448 // TurnComplete snapshot. Queue a fresh snapshot from the same
4449 // drained money+identity batch so an immediate reload agrees with
4450 // the live footer and worker record.
4451 if runtime_usage_arrived
4452 && let Ok(manager) = SessionManager::default_location()
4453 && let Ok(session) = build_session_snapshot(app, &manager)
4454 {
4455 app.current_session_id = Some(session.metadata.id.clone());
4456 persistence_actor::persist(PersistRequest::SessionSnapshot(session));
4457 }
4458 }
4459 // Drain completed file-tree walks (initial build / expands) so the
4460 // spliced children repaint without waiting for an input event (#3900).
4461 if let Some(tree) = app.file_tree.as_mut()
4462 && tree.poll_background()
4463 {
4464 app.needs_redraw = true;
4465 }
4466 // Completion discovery is serialized off-thread. Polling is
4467 // non-blocking and makes a finished initial `@` scan visible even
4468 // after the user stops typing (#4365).
4469 if crate::tui::file_mention::poll_background_mention_discovery(app) {
4470 app.needs_redraw = true;
4471 }
4472 // Expire the "Press Ctrl+C again to quit" prompt silently after its
4473 // window. Triggers a redraw if the prompt was visible.
4474 app.tick_quit_armed();
4475 app.tick_receipt();
4476 crate::tui::footer_ui::maybe_log_provider_wait_incident(app);
4477 // While the user is drag-selecting past the transcript edge, advance
4478 // the viewport on a fixed cadence and extend the selection head so a
4479 // long passage can be selected in one drag (#1163).
4480 tick_selection_autoscroll(app);
4481 let allow_workspace_context_refresh =
4482 !app.is_loading && !has_running_agents && !app.is_compacting && !app.is_purging;
4483 workspace_context::refresh_if_needed(app, now, allow_workspace_context_refresh);
4484 // Native git chrome: at most one background probe per cache TTL, never
4485 // on the render path and never while a turn is live.
4486 if allow_workspace_context_refresh {
4487 static GIT_PROBE_LOCK: std::sync::OnceLock<std::sync::Mutex<Option<Instant>>> =
4488 std::sync::OnceLock::new();
4489 let slot = GIT_PROBE_LOCK.get_or_init(|| std::sync::Mutex::new(None));
4490 let should_probe = slot
4491 .lock()
4492 .map(|mut last| {
4493 let due = last.is_none_or(|t| t.elapsed() >= Duration::from_secs(2));
4494 if due {
4495 *last = Some(Instant::now());
4496 }
4497 due
4498 })
4499 .unwrap_or(false);
4500 if should_probe {
4501 let workspace = app.workspace.clone();
4502 std::thread::spawn(move || {
4503 crate::tui::git_status::refresh_if_stale(&workspace);
4504 });
4505 }
4506 }
4507
4508 // Draw is gated by the frame-rate limiter (120 FPS cap). When a
4509 // redraw is needed but the limiter says we're inside the cooldown
4510 // window, leave `needs_redraw = true` and shorten the poll timeout
4511 // so the loop wakes up exactly when drawing is allowed.
4512
4513 // Central motion contract: frame cap and stream catch-up both read
4514 // from MotionPolicy so reduced motion stays semantically calm (not a
4515 // slow typewriter) and Full motion keeps the steady display clock.
4516 let motion_policy = app.motion_policy();
4517 frame_rate_limiter.set_low_motion(motion_policy.uses_constrained_frame_rate());
4518 stream_display_clock.set_allow_catch_up(motion_policy.allows_catch_up_bursts());
4519
4520 // Content-driven cadence: atmosphere rate when only ocean life moves;
4521 // full interactive rate while streaming, selecting, typing, or hovering.
4522 {
4523 use crate::tui::display_refresh::{
4524 cadence_tier_from_signals, content_driven_draw_interval, probe_display_refresh,
4525 };
4526 let tier = cadence_tier_from_signals(
4527 app.is_loading || has_running_agents,
4528 app.viewport.transcript_selection.is_active(),
4529 !app.input.is_empty(),
4530 crate::tui::hover_layer::current_hover().is_some(),
4531 );
4532 let probe = probe_display_refresh();
4533 frame_rate_limiter.set_adaptive_interval(Some(content_driven_draw_interval(
4534 tier,
4535 probe.hz,
4536 motion_policy.uses_constrained_frame_rate(),
4537 )));
4538 }
4539
4540 let draw_wait = if app.needs_redraw {
4541 frame_rate_limiter.time_until_next_draw(now)
4542 } else {
4543 None
4544 };
4545 // Merge the per-app full-repaint hint (set by theme switches)
4546 // into the loop-level flag before the draw decision.
4547 if app.force_next_full_repaint {
4548 force_terminal_repaint = true;
4549 app.force_next_full_repaint = false;
4550 }
4551 if app.needs_redraw && draw_wait.is_none() && !terminal_unfocused {
4552 draw_app_frame_inner(terminal, app, config, force_terminal_repaint)?;
4553 force_terminal_repaint = false;
4554 frame_rate_limiter.mark_emitted(Instant::now());
4555 app.needs_redraw = false;
4556 }
4557
4558 let mut poll_timeout =
4559 if app.is_loading || has_running_agents || app.is_compacting || app.is_purging {
4560 Duration::from_millis(active_poll_ms(app))
4561 } else {
4562 Duration::from_millis(idle_poll_ms(app))
4563 };
4564 if let Some(until_flush) = app.paste_burst_next_flush_delay_if_enabled(now) {
4565 poll_timeout = poll_timeout.min(until_flush);
4566 }
4567 if let Some(until_draw) = draw_wait {
4568 poll_timeout = poll_timeout.min(until_draw);
4569 }
4570 if let Some(until_stream_commit) = stream_display_clock.due_in(now) {
4571 poll_timeout = poll_timeout.min(until_stream_commit);
4572 }
4573 if let Some(until_anim) = frame_requester.due_in(now) {
4574 poll_timeout = poll_timeout.min(until_anim);
4575 }
4576 // While the quit-confirmation prompt is armed, ensure we wake up to
4577 // expire it on time even if no input event arrives.
4578 if let Some(deadline) = app.quit_armed_until {
4579 let remaining = deadline.saturating_duration_since(now);
4580 poll_timeout = poll_timeout.min(remaining.max(Duration::from_millis(50)));
4581 }
4582 // Drag-edge auto-scroll wakes the loop on its own cadence so the
4583 // viewport keeps advancing while the user holds the mouse outside
4584 // the transcript rect (#1163).
4585 if let Some(state) = app.viewport.selection_autoscroll {
4586 let remaining = state.next_tick.saturating_duration_since(now);
4587 poll_timeout = poll_timeout.min(remaining);
4588 }
4589 poll_timeout = clamp_event_poll_timeout(poll_timeout);
4590
4591 // #549/#3216: give the engine task a scheduler turn before waiting on
4592 // the terminal-input channel. Crossterm's blocking poll/read runs on
4593 // `TerminalInputPump`, so engine floods cannot pin the OS input read.
4594 tokio::task::yield_now().await;
4595
4596 let maybe_terminal_event =
4597 next_terminal_event(&terminal_input, &mut pending_terminal_events, poll_timeout)?;
4598 if maybe_terminal_event.is_none() {
4599 let now = Instant::now();
4600 let input_stalled_for = terminal_input.stalled_for(now);
4601 if terminal_input_recovery_relevant(app, has_running_agents)
4602 && input_stalled_for >= TERMINAL_INPUT_STALL_TIMEOUT
4603 && now.duration_since(last_terminal_input_recovery)
4604 >= TERMINAL_INPUT_RECOVERY_COOLDOWN
4605 {
4606 tracing::warn!(
4607 stalled_ms = input_stalled_for.as_millis(),
4608 "terminal input pump heartbeat stalled; attempting terminal input recovery"
4609 );
4610 recover_terminal_modes(
4611 terminal.backend_mut(),
4612 app.use_mouse_capture,
4613 app.use_bracketed_paste,
4614 );
4615 match terminal_input.restart_detached() {
4616 Ok(()) => {
4617 tracing::info!("terminal input pump recovered");
4618 }
4619 Err(err) => {
4620 tracing::warn!(error = %err, "failed to restart terminal input pump");
4621 app.push_status_toast(
4622 "Terminal input stalled; recovery failed. Restart Codewhale if keys stop responding.",
4623 StatusToastLevel::Error,
4624 None,
4625 );
4626 }
4627 }
4628 terminal_input.mark_alive();
4629 last_terminal_input_recovery = now;
4630 if app.is_loading
4631 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
4632 {
4633 persist_recovery_snapshot(app);
4634 last_recovery_snapshot_at = Some(now);
4635 }
4636 force_terminal_repaint = true;
4637 app.needs_redraw = true;
4638 }
4639 }
4640
4641 if let Some(observed_terminal_event) = maybe_terminal_event {
4642 let event_observed_at = observed_terminal_event.observed_at;
4643 let evt = observed_terminal_event.event;
4644 if app.launch.mark_reveal_started_at.is_some()
4645 && matches!(&evt, Event::Key(_) | Event::Paste(_) | Event::Resize(_, _))
4646 {
4647 app.launch.mark_reveal_started_at = Some(
4648 Instant::now() - Duration::from_millis(crate::tui::mark::REVEAL_MS as u64),
4649 );
4650 }
4651 app.needs_redraw = true;
4652 terminal_unfocused = next_unfocused(terminal_unfocused, &evt);
4653
4654 // Handle bracketed paste events
4655 if app.redaction_gate && app.onboarding == OnboardingState::None {
4656 if let Event::Mouse(mouse) = &evt {
4657 match mouse.kind {
4658 event::MouseEventKind::ScrollDown => app
4659 .redaction_gate_scroll
4660 .set(app.redaction_gate_scroll.get().saturating_add(1)),
4661 event::MouseEventKind::ScrollUp => app
4662 .redaction_gate_scroll
4663 .set(app.redaction_gate_scroll.get().saturating_sub(1)),
4664 _ => {}
4665 }
4666 app.needs_redraw = true;
4667 continue;
4668 }
4669 if matches!(&evt, Event::Paste(_)) {
4670 continue;
4671 }
4672 }
4673 if let Event::Paste(text) = &evt {
4674 if app.launch.return_to_session && app.view_stack.is_empty() {
4675 app.launch.dismiss();
4676 }
4677 handle_bracketed_paste(app, text);
4678 continue;
4679 }
4680
4681 // Re-establish terminal mode flags on focus-gain and force a full
4682 // viewport reset before repainting. App-switching and interactive
4683 // handoffs can leave the host terminal scrolled away from row 0
4684 // and (on macOS) can drop the keyboard, mouse-tracking, or
4685 // bracketed-paste modes — recover_terminal_modes() is the
4686 // canonical place those flags live.
4687 if terminal_event_needs_viewport_recapture(&evt) {
4688 let now = Instant::now();
4689 if now.duration_since(last_focus_recovery) >= FOCUS_RECOVERY_DEBOUNCE {
4690 recover_terminal_modes(
4691 terminal.backend_mut(),
4692 app.use_mouse_capture,
4693 app.use_bracketed_paste,
4694 );
4695 last_focus_recovery = now;
4696 }
4697 force_terminal_repaint = true;
4698 app.needs_redraw = true;
4699 }
4700 if let Event::Resize(width, height) = evt {
4701 tracing::debug!(
4702 width,
4703 height,
4704 use_alt_screen = app.use_alt_screen(),
4705 "Event::Resize received; clearing terminal"
4706 );
4707 // Drain any further Resize events queued in this poll cycle so we
4708 // act on the final size only, then issue a single clear + redraw.
4709 // crossterm coalesces some resize events but rapid drag-resizes
4710 // can still queue several; processing them all here avoids the
4711 // common "stale art on the right edge" symptom (#65) caused by
4712 // the diff renderer skipping cells that match a stale back
4713 // buffer between intermediate sizes.
4714 let mut final_w = width;
4715 let mut final_h = height;
4716 while let Some(next_observed) =
4717 try_next_terminal_event(&terminal_input, &mut pending_terminal_events)?
4718 {
4719 match next_observed.event {
4720 Event::Resize(w, h) => {
4721 final_w = w;
4722 final_h = h;
4723 }
4724 other => {
4725 pending_terminal_events.push_back(ObservedTerminalEvent::new(
4726 other,
4727 next_observed.observed_at,
4728 ));
4729 break;
4730 }
4731 }
4732 }
4733
4734 if final_w == 0 || final_h == 0 {
4735 tracing::debug!(
4736 final_w,
4737 final_h,
4738 "zero-size Resize event ignored while terminal is hidden/minimized"
4739 );
4740 force_terminal_repaint = true;
4741 app.needs_redraw = true;
4742 continue;
4743 }
4744
4745 // #582: commit the event-reported size to ratatui's
4746 // viewport explicitly before the redraw, instead of
4747 // relying on `crossterm::terminal::size()` which gets
4748 // queried internally during `terminal.draw`. On
4749 // Windows ConHost specifically, `terminal::size()` has
4750 // been observed to return stale dimensions briefly
4751 // during a maximize→windowed transition; the next
4752 // `draw` then paints into a buffer that does not
4753 // match the post-restore viewport, producing the
4754 // unrecoverable black screen reported by @imakid.
4755 // The `Event::Resize` payload itself carries the
4756 // authoritative new size, so we forward it.
4757 //
4758 // Inline mode cannot use `resize`: ratatui keeps an inline
4759 // viewport at the rows it was built with, so the viewport is
4760 // rebuilt at the new height instead.
4761 let refit = if app.screen_mode == ScreenMode::Inline {
4762 refit_inline_viewport(terminal, Size::new(final_w, final_h))
4763 } else {
4764 terminal.resize(Rect::new(0, 0, final_w, final_h))
4765 };
4766 if let Err(err) = refit {
4767 tracing::warn!(
4768 ?err,
4769 final_w,
4770 final_h,
4771 "terminal.resize during Resize event failed; falling back to clear+draw"
4772 );
4773 }
4774
4775 app.handle_resize(final_w, final_h);
4776 // #6311: a resize that lands while unfocused records the size
4777 // but must not emit the frame — same deferral as zero-size.
4778 if terminal_unfocused {
4779 force_terminal_repaint = true;
4780 app.needs_redraw = true;
4781 continue;
4782 }
4783 // #macos-resize: some terminals (macOS Terminal.app, Windows
4784 // ConHost) briefly report stale dimensions via
4785 // `terminal::size()` after a resize. ratatui's `draw()` calls
4786 // `autoresize()` internally, which queries the backend size;
4787 // if it sees the old dimension it shrinks the viewport back,
4788 // leaving the newly-expanded area filled with stale content
4789 // from the previous frame (duplicate UI panels).
4790 //
4791 // We force the backend to report the resize-event size for
4792 // this single draw so the buffer matches the real viewport.
4793 {
4794 let backend = terminal.backend_mut();
4795 let new_size = Size::new(final_w, final_h);
4796 backend.force_size(new_size);
4797 backend.set_terminal_size(new_size);
4798 }
4799 draw_app_frame_inner(terminal, app, config, true)?;
4800 {
4801 let backend = terminal.backend_mut();
4802 backend.clear_forced_size();
4803 }
4804 app.needs_redraw = false;
4805 continue;
4806 }
4807
4808 if app.use_mouse_capture
4809 && let Event::Mouse(mouse) = evt
4810 {
4811 // Mouse interaction clears the ✅ completion marker.
4812 crate::tui::notifications::reset_title_on_interaction();
4813 if should_drop_loading_mouse_motion(app, mouse) {
4814 continue;
4815 }
4816 // Fold the rest of this wheel gesture into one frame.
4817 let mouse = coalesce_scroll_burst(
4818 app,
4819 mouse,
4820 &terminal_input,
4821 &mut pending_terminal_events,
4822 )?;
4823 let events = handle_mouse_event(app, mouse);
4824 if handle_view_events_boxed(
4825 terminal,
4826 app,
4827 config,
4828 &task_manager,
4829 &mut engine_handle,
4830 events,
4831 )
4832 .await?
4833 {
4834 return Ok(());
4835 }
4836 if app.pending_launch_action.is_none() {
4837 restore_launch_card_after_view_close(app);
4838 }
4839 if let Some(action) = app.pending_launch_action.take() {
4840 match action {
4841 crate::tui::underwater::LaunchAction::None => {}
4842 crate::tui::underwater::LaunchAction::ReturnToSession => {
4843 app.launch.dismiss()
4844 }
4845 crate::tui::underwater::LaunchAction::NewSession => {
4846 let result = begin_launch_session(app, None);
4847 if apply_command_result(
4848 terminal,
4849 app,
4850 &mut engine_handle,
4851 &task_manager,
4852 config,
4853 result,
4854 )
4855 .await?
4856 {
4857 return Ok(());
4858 }
4859 }
4860 crate::tui::underwater::LaunchAction::ResumeSession(session_id) => {
4861 let result = resume_launch_session(app, &session_id);
4862 if apply_command_result(
4863 terminal,
4864 app,
4865 &mut engine_handle,
4866 &task_manager,
4867 config,
4868 result,
4869 )
4870 .await?
4871 {
4872 return Ok(());
4873 }
4874 }
4875 crate::tui::underwater::LaunchAction::BrowseSessions => {
4876 // A launched command dissolves the card; Esc
4877 // out of the picker brings it back.
4878 app.launch.dissolve_card(app.ambient_clock_ms);
4879 app.view_stack.push(
4880 SessionPickerView::new(&app.workspace, app.ui_locale)
4881 .with_current_session(app.current_session_id.as_deref()),
4882 );
4883 }
4884 crate::tui::underwater::LaunchAction::McpRemedy => {
4885 type_launch_mcp_remedy(app);
4886 }
4887 crate::tui::underwater::LaunchAction::McpManager => {
4888 app.launch.dissolve_card(app.ambient_clock_ms);
4889 open_mcp_extensions(app);
4890 }
4891 crate::tui::underwater::LaunchAction::Help => {
4892 toggle_help_view(app);
4893 }
4894 }
4895 app.needs_redraw = true;
4896 }
4897 if let Some(chord) = app.pending_composer_submit.take() {
4898 if dispatch_session_composer_submit(
4899 terminal,
4900 app,
4901 &mut engine_handle,
4902 &task_manager,
4903 config,
4904 chord,
4905 )
4906 .await?
4907 {
4908 return Ok(());
4909 }
4910 app.needs_redraw = true;
4911 }
4912 if let Some(slot) = app.pending_hotbar_slot.take()
4913 && let Some(dispatch) = dispatch_hotbar_slot(app, config, slot)?
4914 {
4915 match dispatch {
4916 HotbarDispatch::Handled => app.needs_redraw = true,
4917 HotbarDispatch::AppAction(action) => {
4918 if apply_command_result(
4919 terminal,
4920 app,
4921 &mut engine_handle,
4922 &task_manager,
4923 config,
4924 commands::CommandResult::action(action),
4925 )
4926 .await?
4927 {
4928 return Ok(());
4929 }
4930 if let Err(err) = persist_pending_work_checkpoint(app).await {
4931 app.status_message = Some(format!(
4932 "Hotbar change applied, but its Work receipt is pending ({err})"
4933 ));
4934 }
4935 app.needs_redraw = true;
4936 }
4937 }
4938 }
4939 continue;
4940 }
4941
4942 // User interaction — clear the ✅ completion marker from the title.
4943 crate::tui::notifications::reset_title_on_interaction();
4944
4945 let Event::Key(mut key) = evt else {
4946 continue;
4947 };
4948
4949 if key.kind != KeyEventKind::Press {
4950 continue;
4951 }
4952
4953 // Normalize macOS modifiers: map SUPER (Cmd) to CONTROL so that
4954 // keyboard shortcuts work consistently across terminal emulators
4955 // (Terminal.app, iTerm2, Kitty, etc.) that may report different
4956 // modifier flags (#2938). The select-all chord is exempt: `Cmd+A`
4957 // must stay distinguishable from readline `Ctrl+A` (start of
4958 // input) on terminals that forward Cmd, so it keeps its SUPER
4959 // modifier and routes through `is_select_all_shortcut`.
4960 if !key_shortcuts::is_select_all_shortcut(&key) {
4961 let mapped = crate::tui::composer_ui::normalize_macos_modifiers(key.modifiers);
4962 key.modifiers = mapped;
4963 }
4964
4965 // Normalize the raw Ctrl+C control byte (0x03) delivered in
4966 // PTY/raw-mode — and by some kitty-keyboard-protocol terminals —
4967 // to canonical Ctrl+C so the quit-arm flow always runs (#4090).
4968 normalize_raw_ctrl_c(&mut key);
4969
4970 // The `[redaction] model_bound` opt-out gate owns every key until
4971 // it is answered, exactly like onboarding above. Enter never
4972 // confirms by reflex (same discipline as workspace trust): the
4973 // three explicit choices are advertised in the action rail.
4974 if app.redaction_gate && app.onboarding == OnboardingState::None {
4975 let gate_binding = shell_binding_for_key(app, &key);
4976 match key.code {
4977 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4978 let _ = engine_handle.send(Op::Shutdown).await;
4979 return Ok(());
4980 }
4981 _ if gate_binding == Some(ShellBindingId::RedactionGateConfirm) => {
4982 if !app.redaction_gate_confirming {
4983 // First confirm only advances to the final
4984 // confirmation stage; nothing is persisted yet.
4985 app.retire_redaction_gate_notice(RedactionGateNotice::EnterGuidance);
4986 app.redaction_gate_confirming = true;
4987 app.redaction_gate_scroll.set(0);
4988 } else {
4989 match crate::tui::redaction_gate::record_confirmation(config) {
4990 Ok(_) => {
4991 // The engine already spawned with masking on
4992 // (the unconfirmed safe default). Rebuild it so
4993 // its client picks up the confirmed opt-out.
4994 let _ = engine_handle.send(Op::Shutdown).await;
4995 engine_handle =
4996 spawn_tui_engine_with_session(app, config).await?;
4997 app.retire_redaction_gate_notice(
4998 RedactionGateNotice::EnterGuidance,
4999 );
5000 app.retire_redaction_gate_notice(
5001 RedactionGateNotice::WriteFailure,
5002 );
5003 app.retire_action_notices(None);
5004 app.redaction_gate = false;
5005 app.redaction_gate_confirming = false;
5006 app.needs_redraw = true;
5007 }
5008 Err(err) => {
5009 tracing::warn!(
5010 "redaction confirmation could not be saved: {err}"
5011 );
5012 app.push_status_toast_record(
5013 StatusToast::new(
5014 app.tr(MessageId::RedactionGateSaveFailed).into_owned(),
5015 StatusToastLevel::Error,
5016 None,
5017 )
5018 .for_redaction_gate(RedactionGateNotice::WriteFailure),
5019 );
5020 app.redaction_gate_scroll.set(0);
5021 }
5022 }
5023 }
5024 }
5025 _ if gate_binding == Some(ShellBindingId::RedactionGateKeepOrBack) => {
5026 if app.redaction_gate_confirming {
5027 // Second-stage "back": return to the first stage
5028 // without recording anything.
5029 app.retire_redaction_gate_notice(RedactionGateNotice::EnterGuidance);
5030 app.redaction_gate_confirming = false;
5031 app.redaction_gate_scroll.set(0);
5032 } else {
5033 // Keep masking on for this launch. Nothing is
5034 // persisted and no config file is rewritten;
5035 // because the config field still requests
5036 // "disabled", the next launch asks again.
5037 app.retire_redaction_gate_notice(RedactionGateNotice::EnterGuidance);
5038 app.retire_redaction_gate_notice(RedactionGateNotice::WriteFailure);
5039 app.redaction_gate = false;
5040 app.needs_redraw = true;
5041 }
5042 }
5043 _ if gate_binding == Some(ShellBindingId::RedactionGateQuit) => {
5044 let _ = engine_handle.send(Op::Shutdown).await;
5045 return Ok(());
5046 }
5047 // Esc on the final-confirmation stage steps back to the
5048 // first stage (the user was mid-decision); on the first
5049 // stage it quits, matching the trust screen.
5050 KeyCode::Esc if app.redaction_gate_confirming => {
5051 app.retire_redaction_gate_notice(RedactionGateNotice::EnterGuidance);
5052 app.redaction_gate_confirming = false;
5053 app.redaction_gate_scroll.set(0);
5054 }
5055 KeyCode::Esc => {
5056 let _ = engine_handle.send(Op::Shutdown).await;
5057 return Ok(());
5058 }
5059 KeyCode::Enter => {
5060 app.push_status_toast_record(
5061 StatusToast::new(
5062 app.tr(MessageId::RedactionGateEnterHint).into_owned(),
5063 StatusToastLevel::Info,
5064 Some(12_000),
5065 )
5066 .for_redaction_gate(RedactionGateNotice::EnterGuidance),
5067 );
5068 app.redaction_gate_scroll.set(0);
5069 }
5070 KeyCode::Down | KeyCode::PageDown
5071 if gate_binding == Some(ShellBindingId::RedactionGateScroll) =>
5072 {
5073 app.redaction_gate_scroll
5074 .set(app.redaction_gate_scroll.get().saturating_add(1))
5075 }
5076 KeyCode::Up | KeyCode::PageUp
5077 if gate_binding == Some(ShellBindingId::RedactionGateScroll) =>
5078 {
5079 app.redaction_gate_scroll
5080 .set(app.redaction_gate_scroll.get().saturating_sub(1))
5081 }
5082 KeyCode::Home => app.redaction_gate_scroll.set(0),
5083 KeyCode::End => app.redaction_gate_scroll.set(usize::MAX),
5084 _ => {}
5085 }
5086 app.needs_redraw = true;
5087 submit_initial_input_if_ready(app, config, &engine_handle).await?;
5088 continue;
5089 }
5090
5091 // Login cancellation precedes modal/focus dispatch: Extensions
5092 // must not consume the Esc promised by the authorization notice.
5093 if handle_mcp_login_key(app, &key) {
5094 continue;
5095 }
5096
5097 // A route change made in-session is temporary and stays that way
5098 // until the user EXPLICITLY persists it with a command
5099 // (/fleet save updates the selected Fleet, /fleet save-as saves a
5100 // new Fleet, /model save-default remembers the startup default).
5101 // Nothing here intercepts keys: a scripted or automated terminal
5102 // types exactly what it types, and plain typing can never trigger
5103 // a fleet write by accident.
5104
5105 // Decision prompts keep their ordinary option/typing keys while
5106 // explicit transcript navigation reviews the evidence above them
5107 // (#4371, #6045). Bare arrows still belong to the question sheet.
5108 if handle_prompt_transcript_key(app, &key) {
5109 continue;
5110 }
5111
5112 // Clicking the WorkflowPanel gives its non-text controls focus,
5113 // but ordinary characters always return directly to the composer.
5114 // This keeps the panel keyboard-accessible without stealing the
5115 // first t/c/j/k (or any other letter) of a new chat.
5116 if app.view_stack.is_empty() && handle_workflow_panel_key(app, &key) {
5117 submit_initial_input_if_ready(app, config, &engine_handle).await?;
5118 continue;
5119 }
5120
5121 // The Ocean work surface is a real focus owner. Route its keys
5122 // before global transcript/composer navigation so PageUp/Down,
5123 // Home/End, arrows, and row actions stay panel-local.
5124 if app.view_stack.is_empty()
5125 && let Some(action) = crate::tui::work_surface::handle_key(app, key)
5126 {
5127 if let Some(action) = action {
5128 match action {
5129 crate::tui::app::SidebarRowAction::Command(command) => {
5130 if execute_command_input(
5131 terminal,
5132 app,
5133 &mut engine_handle,
5134 &task_manager,
5135 config,
5136 &command,
5137 )
5138 .await?
5139 {
5140 return Ok(());
5141 }
5142 }
5143 crate::tui::app::SidebarRowAction::CancelAgent { agent_id } => {
5144 app.status_message = Some(format!("Cancelling {agent_id}..."));
5145 if engine_handle
5146 .send(Op::CancelSubAgent {
5147 agent_id: agent_id.clone(),
5148 })
5149 .await
5150 .is_err()
5151 {
5152 app.status_message = Some(format!("Could not cancel {agent_id}"));
5153 }
5154 }
5155 other => {
5156 let _ = crate::tui::mouse_ui::apply_sidebar_row_action(app, other);
5157 }
5158 }
5159 }
5160 submit_initial_input_if_ready(app, config, &engine_handle).await?;
5161 continue;
5162 }
5163
5164 // The shell's key admission runs through one table
5165 // (`shell_key_routing::SHELL_BINDINGS`) keyed by one focus owner
5166 // (`app.focus()`) — never by whether the composer happens to hold
5167 // text. Help and Settings are shell-global, including onboarding,
5168 // launch, and modal surfaces (`/help` and `/provider` remain the
5169 // guaranteed textual routes); Shift+Tab is a shell-level
5170 // permission control and is claimed here, before the launch
5171 // screen swallows it. The remaining bindings are admitted by the
5172 // same table at their owners' seams below, where the composer's
5173 // completions and the agent-focus projection get the key first.
5174 match shell_binding_for_key(app, &key) {
5175 Some(ShellBindingId::Help) => {
5176 app.note_footer_hint_used(crate::tui::footer_hints::HELP_ROUTE);
5177 toggle_help_view(app);
5178 continue;
5179 }
5180 Some(ShellBindingId::Settings) => {
5181 toggle_settings_view(app);
5182 continue;
5183 }
5184 Some(ShellBindingId::PermissionCycle) => {
5185 cycle_permission_posture(app, config, &engine_handle).await;
5186 app.note_footer_hint_used(crate::tui::footer_hints::PERMISSION_CYCLE);
5187 continue;
5188 }
5189 Some(ShellBindingId::ViewCycle) => {
5190 crate::tui::work_surface::cycle_view(app, true);
5191 app.note_footer_hint_used(crate::tui::footer_hints::DOCK_OPEN);
5192 continue;
5193 }
5194 Some(ShellBindingId::ViewCycleBack) => {
5195 crate::tui::work_surface::cycle_view(app, false);
5196 continue;
5197 }
5198 _ => {}
5199 }
5200
5201 // Provider onboarding is a real ProviderPickerView, not a
5202 // parallel ten-provider key handler. Route its keys before the
5203 // legacy onboarding switch so List/Key/Model/Confirm retain the
5204 // same behavior as `/provider` and `/setup`.
5205 match onboarding_key_route(app.onboarding, app.view_stack.top_kind(), &key) {
5206 // #4763: onboarding must never be a trap. Ctrl+C terminates
5207 // from every onboarding state, including while the picker
5208 // owns the keys — the legacy handler below is unreachable
5209 // once a modal is on the stack.
5210 OnboardingKeyRoute::Quit => {
5211 let _ = engine_handle.send(Op::Shutdown).await;
5212 return Ok(());
5213 }
5214 // #3927: no provider is selected and no route is activated.
5215 // The picker (a preview surface, never route authority) is
5216 // popped without applying anything it was showing.
5217 OnboardingKeyRoute::ExploreOffline => {
5218 if app.view_stack.top_kind() == Some(ModalKind::ProviderPicker) {
5219 let _ = app.view_stack.pop();
5220 }
5221 onboarding::choose_offline_explore(app);
5222 continue;
5223 }
5224 // Every other key, Escape included, belongs to the picker.
5225 // The picker's own per-stage Escape walks key/OAuth entry
5226 // back to the list and only dismisses from the list, where
5227 // `ProviderPickerDismissed` runs the same non-mutating
5228 // onboarding back-transition the shell used to force.
5229 OnboardingKeyRoute::ProviderPicker => {
5230 if key_shortcuts::is_paste_shortcut(&key)
5231 && paste_provider_picker_from_clipboard(app)
5232 {
5233 app.needs_redraw = true;
5234 continue;
5235 }
5236 let events = app.view_stack.handle_key(key);
5237 app.needs_redraw = true;
5238 if handle_view_events_boxed(
5239 terminal,
5240 app,
5241 config,
5242 &task_manager,
5243 &mut engine_handle,
5244 events,
5245 )
5246 .await?
5247 {
5248 return Ok(());
5249 }
5250 continue;
5251 }
5252 OnboardingKeyRoute::Legacy => {}
5253 }
5254
5255 // Handle onboarding flow
5256 if app.onboarding != OnboardingState::None {
5257 match key.code {
5258 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5259 let _ = engine_handle.send(Op::Shutdown).await;
5260 return Ok(());
5261 }
5262 KeyCode::Esc if app.onboarding == OnboardingState::Provider => {
5263 back_from_provider_onboarding(app);
5264 }
5265 KeyCode::Esc if app.onboarding == OnboardingState::Language => {
5266 app.onboarding = OnboardingState::Welcome;
5267 app.status_message = None;
5268 }
5269 // Language picker hotkeys select + persist (#566).
5270 //
5271 // Note: this used to be a single match-guard with `&& let`,
5272 // but `if_let_guard` is a nightly-only feature on Rust
5273 // before 1.94. Rewriting as a plain guard + nested `if let`
5274 // keeps `cargo install` working on stable.
5275 KeyCode::Char(c)
5276 if app.onboarding == OnboardingState::Language
5277 && (c.is_ascii_digit() || c.is_ascii_lowercase()) =>
5278 {
5279 if let Some((_, tag, _, _)) = onboarding::language::LANGUAGE_OPTIONS
5280 .iter()
5281 .find(|(hotkey, _, _, _)| *hotkey == c)
5282 {
5283 match app.set_locale_from_onboarding(tag) {
5284 Ok(()) => {
5285 app.push_status_toast(
5286 format!("Language set to {tag}"),
5287 StatusToastLevel::Info,
5288 Some(2_500),
5289 );
5290 onboarding::advance_onboarding_after_language(app);
5291 }
5292 Err(err) => {
5293 app.status_message =
5294 Some(format!("Failed to save locale: {err}"));
5295 }
5296 }
5297 }
5298 }
5299 KeyCode::Enter => match app.onboarding {
5300 OnboardingState::Welcome => {
5301 onboarding::advance_onboarding_from_welcome(app);
5302 }
5303 OnboardingState::Language => {
5304 // Enter without a digit pick keeps the existing
5305 // setting (which defaults to "auto").
5306 onboarding::advance_onboarding_after_language(app);
5307 }
5308 OnboardingState::Provider => {
5309 let recover_configured_route = app.onboarding_missing_key_recovery;
5310 open_onboarding_provider_picker(
5311 app,
5312 config,
5313 &engine_handle,
5314 recover_configured_route,
5315 )
5316 .await;
5317 }
5318 OnboardingState::TrustDirectory => {
5319 // Trusting a workspace is a security boundary, so it
5320 // must be a deliberate choice. Enter — the "advance"
5321 // key on every other onboarding screen — must NOT
5322 // grant trust by reflex (accidental-trust risk). Nor
5323 // is it a silent dead key: point the user at the
5324 // explicit keys the rail advertises.
5325 app.status_message =
5326 Some(app.tr(MessageId::OnboardTrustEnterHint).to_string());
5327 }
5328 OnboardingState::Ready => {
5329 // Enter opens the product: the real composer,
5330 // pre-seeded with a first task for this folder —
5331 // never another educational surface.
5332 onboarding::finish_ready_and_open_composer(app);
5333 app.maybe_show_feature_intro();
5334 }
5335 OnboardingState::None => {}
5336 },
5337 // "Customize later": the appearance choice from the ready
5338 // screen, as an optional secondary action. Onboarding is
5339 // finished first so the theme picker is an ordinary modal
5340 // over the live product, not a required step.
5341 KeyCode::Char('c') | KeyCode::Char('C')
5342 if app.onboarding == OnboardingState::Ready =>
5343 {
5344 onboarding::finish_ready_and_open_composer(app);
5345 open_theme_picker(app);
5346 }
5347 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1')
5348 if app.onboarding == OnboardingState::TrustDirectory =>
5349 {
5350 if let Err(err) = complete_trust_directory_onboarding(app, config) {
5351 app.status_message = Some(format!("Failed to trust workspace: {err}"));
5352 }
5353 }
5354 // Number keys mirror the footer's reading order (1 trust,
5355 // 2 continue untrusted, 3 quit) so the displayed digits
5356 // are sequential instead of 1/3/2.
5357 KeyCode::Char('u') | KeyCode::Char('U') | KeyCode::Char('2')
5358 if app.onboarding == OnboardingState::TrustDirectory =>
5359 {
5360 continue_without_trusting_directory(app);
5361 }
5362 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('3')
5363 if app.onboarding == OnboardingState::TrustDirectory =>
5364 {
5365 let _ = engine_handle.send(Op::Shutdown).await;
5366 return Ok(());
5367 }
5368 KeyCode::Esc if app.onboarding == OnboardingState::TrustDirectory => {
5369 let _ = engine_handle.send(Op::Shutdown).await;
5370 return Ok(());
5371 }
5372 _ => {}
5373 }
5374 continue;
5375 }
5376
5377 // F3 is the non-printable keyboard counterpart to the clickable
5378 // route segment in the shared topbar. Route it through the same
5379 // typed event as mouse input; `/provider` remains the portable
5380 // direct command path for terminals that do not forward F-keys.
5381 if shell_binding_for_key(app, &key) == Some(ShellBindingId::ProviderRoute) {
5382 if handle_view_events_boxed(
5383 terminal,
5384 app,
5385 config,
5386 &task_manager,
5387 &mut engine_handle,
5388 vec![ViewEvent::TopbarRoutePickerRequested],
5389 )
5390 .await?
5391 {
5392 return Ok(());
5393 }
5394 continue;
5395 }
5396
5397 // The pre-session launch menu owns every key until the user has
5398 // chosen a real session/worktree action. Resume and changelog may
5399 // place a shared surface above it; those views keep their normal
5400 // handlers while the launch screen remains the stable backdrop.
5401 if app.launch.visible {
5402 if !app.view_stack.is_empty() {
5403 let events = app.view_stack.handle_key(key);
5404 app.needs_redraw = true;
5405 if handle_view_events_boxed(
5406 terminal,
5407 app,
5408 config,
5409 &task_manager,
5410 &mut engine_handle,
5411 events,
5412 )
5413 .await?
5414 {
5415 return Ok(());
5416 }
5417 restore_launch_card_after_view_close(app);
5418 continue;
5419 }
5420
5421 let launch_locale = app.ui_locale;
5422 // The pre-session composer is the session's own composer.
5423 // While it holds focus, this admission guard only claims the
5424 // launch-specific keys (list navigation/run, F1 help,
5425 // submit); every editing key falls through to the
5426 // conversation composer match below — the single composer
5427 // input authority — so word motion, selection, completion
5428 // menus, attachments, history, and vim behavior cannot drift
5429 // from the shell.
5430 let mut composer_authority = false;
5431 // A menu-run Enter defers its action to the chord match
5432 // below, which owns every launch action's execution.
5433 let mut menu_run_action: Option<crate::tui::underwater::LaunchAction> = None;
5434 if app.launch.composer_focus {
5435 match crate::tui::underwater::handle_launch_composer_key(app, key) {
5436 crate::tui::underwater::LaunchComposerKey::Consumed => {
5437 app.needs_redraw = true;
5438 continue;
5439 }
5440 crate::tui::underwater::LaunchComposerKey::MenuChord => {
5441 // The same key then drives the launch chords.
5442 }
5443 crate::tui::underwater::LaunchComposerKey::ComposerAuthority => {
5444 // Skip the menu handler; the conversation
5445 // composer match below owns this key.
5446 composer_authority = true;
5447 }
5448 crate::tui::underwater::LaunchComposerKey::MenuSelect => {
5449 // A completion popup entry was applied; the key is
5450 // consumed without submitting.
5451 app.needs_redraw = true;
5452 continue;
5453 }
5454 crate::tui::underwater::LaunchComposerKey::MenuNavigate(delta) => {
5455 // The card is up: Up/Down move its row selection
5456 // over the full row list (Enter still runs a row
5457 // the plan shed on a tiny stage).
5458 let rows = crate::tui::underwater::launch_rows_for_app(app);
5459 let entries = rows.len().max(1) as i32;
5460 // First arrow lands on the first (Up: last)
5461 // row; from there it moves.
5462 app.launch.menu_selected = Some(match app.launch.menu_selected {
5463 None if delta < 0 => (entries - 1) as usize,
5464 None => 0,
5465 Some(current) => {
5466 (current as i32 + delta).rem_euclid(entries) as usize
5467 }
5468 });
5469 app.needs_redraw = true;
5470 continue;
5471 }
5472 crate::tui::underwater::LaunchComposerKey::MenuRun => {
5473 // Enter with an empty composer while the card is
5474 // up runs the highlighted row below, through the
5475 // same arms clicks use.
5476 let rows = crate::tui::underwater::launch_rows_for_app(app);
5477 menu_run_action = Some(crate::tui::underwater::run_launch_card_row(
5478 &rows,
5479 app.launch.menu_selected,
5480 ));
5481 }
5482 crate::tui::underwater::LaunchComposerKey::Submit => {
5483 let chord = composer_submit_chord(key, app.composer_multiline_mode)
5484 .unwrap_or(ComposerSubmitChord::Enter);
5485 if dispatch_launch_composer_submit(
5486 terminal,
5487 app,
5488 &mut engine_handle,
5489 &task_manager,
5490 config,
5491 chord,
5492 )
5493 .await?
5494 {
5495 return Ok(());
5496 }
5497 app.needs_redraw = true;
5498 continue;
5499 }
5500 }
5501 }
5502 if composer_authority {
5503 // Fall out of the launch branch: the global chords and
5504 // the conversation composer match below handle this key
5505 // exactly as they would in a live session.
5506 } else {
5507 // Ctrl+C on the launch screen follows the same two-tap
5508 // contract as the session shell (`CtrlCDisposition`):
5509 // first press arms the visible exit prompt, the second
5510 // inside QUIT_CONFIRMATION_WINDOW exits. Selection
5511 // copy and turn cancel cannot apply before a session
5512 // exists, so every other disposition arms.
5513 if key.code == KeyCode::Char('c')
5514 && key.modifiers.contains(KeyModifiers::CONTROL)
5515 {
5516 match ctrl_c_disposition(app) {
5517 CtrlCDisposition::ConfirmExit => {
5518 let _ = engine_handle.send(Op::Shutdown).await;
5519 return Ok(());
5520 }
5521 _ => app.arm_quit(),
5522 }
5523 app.needs_redraw = true;
5524 continue;
5525 }
5526 let action = menu_run_action.take().unwrap_or_else(|| {
5527 crate::tui::underwater::handle_launch_key(
5528 &mut app.launch,
5529 key,
5530 launch_locale,
5531 )
5532 });
5533 match action {
5534 crate::tui::underwater::LaunchAction::None => {}
5535 crate::tui::underwater::LaunchAction::ReturnToSession => {
5536 app.launch.dismiss()
5537 }
5538 crate::tui::underwater::LaunchAction::NewSession => {
5539 let result = begin_launch_session(app, None);
5540 if apply_command_result(
5541 terminal,
5542 app,
5543 &mut engine_handle,
5544 &task_manager,
5545 config,
5546 result,
5547 )
5548 .await?
5549 {
5550 return Ok(());
5551 }
5552 }
5553 crate::tui::underwater::LaunchAction::ResumeSession(session_id) => {
5554 crate::tui::underwater::open_launch_resume_confirm(app, &session_id);
5555 }
5556 crate::tui::underwater::LaunchAction::BrowseSessions => {
5557 // A launched command dissolves the card; Esc
5558 // out of the picker brings it back.
5559 app.launch.dissolve_card(app.ambient_clock_ms);
5560 app.view_stack.push(
5561 SessionPickerView::new(&app.workspace, app.ui_locale)
5562 .with_current_session(app.current_session_id.as_deref()),
5563 );
5564 }
5565 crate::tui::underwater::LaunchAction::McpRemedy => {
5566 type_launch_mcp_remedy(app);
5567 }
5568 crate::tui::underwater::LaunchAction::McpManager => {
5569 app.launch.dissolve_card(app.ambient_clock_ms);
5570 open_mcp_extensions(app);
5571 }
5572 crate::tui::underwater::LaunchAction::Help => {
5573 toggle_help_view(app);
5574 } // `handle_launch_key` never yields this; the mouse send
5575 // path above is the only producer. The arm keeps the
5576 // match exhaustive.
5577 }
5578 app.needs_redraw = true;
5579 continue;
5580 }
5581 }
5582
5583 if key.code == KeyCode::Char('x')
5584 && key.modifiers.contains(KeyModifiers::CONTROL)
5585 && prefill_jobs_cancel_all_if_tasks_sidebar(app)
5586 {
5587 continue;
5588 }
5589
5590 if key.code == KeyCode::Char('k') && key.modifiers.contains(KeyModifiers::CONTROL) {
5591 // When the composer is the active input target (no modal/pager
5592 // intercepting keys), Ctrl+K performs an emacs-style kill to
5593 // end-of-line. If the kill is a no-op (cursor at end of empty
5594 // input), fall through to the existing command palette.
5595 if app.view_stack.is_empty() && app.kill_to_end_of_line() {
5596 continue;
5597 }
5598 codewhale_telemetry::session_counters()
5599 .bump(codewhale_telemetry::Counter::CommandPaletteOpen);
5600 app.view_stack.push(CommandPaletteView::new_for_locale(
5601 app.ui_locale,
5602 build_command_palette_entries(
5603 app.ui_locale,
5604 &app.skills_dir,
5605 app.skills_scan_codewhale_only,
5606 &app.workspace,
5607 &app.mcp_config_path,
5608 app.mcp_snapshot.as_ref(),
5609 app.plugin_registry.as_ref(),
5610 ),
5611 ));
5612 continue;
5613 }
5614
5615 // Shifted shortcuts toggle the file-tree pane. Keep plain Ctrl+E
5616 // reserved for the composer end-of-line binding used by shells.
5617 if key_shortcuts::is_file_tree_toggle_shortcut(&key) {
5618 if let Some(_state) = app.file_tree.as_mut() {
5619 // File tree visible → hide it.
5620 app.file_tree = None;
5621 app.status_message = Some("File tree closed".to_string());
5622 } else {
5623 // Build the file tree from the current workspace.
5624 let state = crate::tui::file_tree::FileTreeState::new(&app.workspace);
5625 app.file_tree = Some(state);
5626 app.status_message = Some(
5627 "File tree: \u{2191}/\u{2193} navigate Enter select Esc close"
5628 .to_string(),
5629 );
5630 }
5631 app.needs_redraw = true;
5632 continue;
5633 }
5634
5635 // Ctrl+P opens the fuzzy file-picker overlay. Bound only when the
5636 // composer is focused (no other modal or inline popup on top) and the
5637 // engine is not actively streaming a turn.
5638 if key.code == KeyCode::Char('p')
5639 && key.modifiers.contains(KeyModifiers::CONTROL)
5640 && visible_slash_menu_entries(app, SLASH_MENU_LIMIT).is_empty()
5641 && app.view_stack.is_empty()
5642 && !app.is_loading
5643 {
5644 file_picker_relevance::open_file_picker(app);
5645 continue;
5646 }
5647
5648 if matches!(key.code, KeyCode::Char('l') | KeyCode::Char('L'))
5649 && key.modifiers.contains(KeyModifiers::CONTROL)
5650 && app.view_stack.is_empty()
5651 {
5652 try_queue_manual_compaction(app, config, &engine_handle, None);
5653 continue;
5654 }
5655
5656 if matches!(key.code, KeyCode::Char('b') | KeyCode::Char('B'))
5657 && key_shortcuts::has_control_like_modifier(key.modifiers)
5658 && app.view_stack.is_empty()
5659 {
5660 // #3032/#3859: Ctrl+B moves the active foreground shell wait
5661 // into /jobs instead of opening a two-step shell-control menu.
5662 // When nothing is movable, the status message tells the user
5663 // what's going on.
5664 request_foreground_shell_background(app);
5665 app.needs_redraw = true;
5666 continue;
5667 }
5668
5669 if shell_binding_for_key(app, &key) == Some(ShellBindingId::ContextInspector) {
5670 open_context_inspector(app);
5671 continue;
5672 }
5673
5674 if !app.view_stack.is_empty() {
5675 if key_shortcuts::is_paste_shortcut(&key)
5676 && paste_provider_picker_from_clipboard(app)
5677 {
5678 app.needs_redraw = true;
5679 continue;
5680 }
5681 let closing_work_inspector = app.work_surface.opened.is_some()
5682 && app.view_stack.top_kind() == Some(ModalKind::Pager);
5683 let events = app.view_stack.handle_key(key);
5684 clear_work_inspector_after_pager_close(app, closing_work_inspector);
5685 app.needs_redraw = true;
5686 if handle_view_events_boxed(
5687 terminal,
5688 app,
5689 config,
5690 &task_manager,
5691 &mut engine_handle,
5692 events,
5693 )
5694 .await?
5695 {
5696 return Ok(());
5697 }
5698 continue;
5699 }
5700
5701 if let Some(slot) = hotbar_slot_from_key(app, &key) {
5702 if let Some(dispatch) = dispatch_hotbar_slot(app, config, slot)? {
5703 match dispatch {
5704 HotbarDispatch::Handled => {
5705 app.needs_redraw = true;
5706 }
5707 HotbarDispatch::AppAction(action) => {
5708 if apply_command_result(
5709 terminal,
5710 app,
5711 &mut engine_handle,
5712 &task_manager,
5713 config,
5714 commands::CommandResult::action(action),
5715 )
5716 .await?
5717 {
5718 return Ok(());
5719 }
5720 if let Err(err) = persist_pending_work_checkpoint(app).await {
5721 app.status_message = Some(format!(
5722 "Hotbar change applied, but its Work receipt is pending ({err})"
5723 ));
5724 }
5725 app.needs_redraw = true;
5726 }
5727 }
5728 }
5729 continue;
5730 }
5731
5732 // File-tree navigation: delegated to key_actions module.
5733 if key_actions::handle_file_tree_key(app, &key) {
5734 continue;
5735 }
5736
5737 if app.is_history_search_active() {
5738 handle_history_search_key(app, key);
5739 continue;
5740 }
5741
5742 if matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R'))
5743 && key.modifiers.contains(KeyModifiers::ALT)
5744 && !key.modifiers.contains(KeyModifiers::CONTROL)
5745 && !key.modifiers.contains(KeyModifiers::SUPER)
5746 {
5747 app.start_history_search();
5748 continue;
5749 }
5750
5751 let now = event_observed_at;
5752 flush_paste_burst_before_composer(app, now);
5753
5754 // On Windows, AltGr is delivered as `Ctrl+Alt`; treat
5755 // AltGr-typed chars (e.g. European layouts producing `@`, `\`,
5756 // `|`) as plain text rather than swallowing them as a modified
5757 // shortcut. `key_hint::has_ctrl_or_alt` filters AltGr out.
5758 let has_ctrl_alt_or_super =
5759 crate::tui::widgets::key_hint::has_ctrl_or_alt(key.modifiers)
5760 || key.modifiers.contains(KeyModifiers::SUPER);
5761 let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super;
5762 // Only bare Enter participates in trailing-newline paste-burst
5763 // protection. Modified Enter chords are deliberate composer
5764 // actions: flush any buffered text, then route the chord normally
5765 // so Shift/Alt+Enter newline and Ctrl+Enter steer are never eaten
5766 // after fast typing or an unbracketed paste.
5767 let is_plain_enter =
5768 matches!(key.code, KeyCode::Enter) && key.modifiers == KeyModifiers::NONE;
5769
5770 // Tool details: Alt+V / Option+V only. Bare `v` always types `v`
5771 // in every focus state (TUI-DOG-002).
5772 if shell_binding_for_key(app, &key) == Some(ShellBindingId::ToolDetails) {
5773 // While a worker is focused the details chord is that
5774 // worker's bounded Agent Details projection.
5775 if let Some(agent_id) = app.agent_focus.as_ref().map(|f| f.agent_id.clone()) {
5776 if !crate::tui::agent_details::open_agent_details(app, &agent_id) {
5777 app.status_message = Some("Agent details are unavailable".to_string());
5778 }
5779 app.needs_redraw = true;
5780 continue;
5781 }
5782 open_tool_details_pager(app);
5783 continue;
5784 }
5785
5786 if !is_plain_char
5787 && !is_plain_enter
5788 && let Some(pending) = app.flush_paste_burst_before_modified_input_if_enabled()
5789 {
5790 app.insert_str(&pending);
5791 }
5792
5793 if (is_plain_char || is_plain_enter) && handle_plain_key_before_composer(app, &key, now)
5794 {
5795 continue;
5796 }
5797
5798 let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
5799 let slash_menu_open = !slash_menu_entries.is_empty();
5800 if slash_menu_open && app.slash_menu_selected >= slash_menu_entries.len() {
5801 app.slash_menu_selected = slash_menu_entries.len().saturating_sub(1);
5802 }
5803 let mention_menu_limit = app.mention_menu_limit;
5804 let mention_menu_entries =
5805 crate::tui::file_mention::visible_mention_menu_entries(app, mention_menu_limit);
5806 let mention_menu_open = !mention_menu_entries.is_empty();
5807 if mention_menu_open && app.mention_menu_selected >= mention_menu_entries.len() {
5808 app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
5809 }
5810
5811 // Cancel a pending Esc-Esc prime as soon as any non-Esc key
5812 // arrives. Without this the prime would hang around for the
5813 // rest of the session and the user's next genuine Esc would
5814 // suddenly skip straight into the backtrack overlay.
5815 if !matches!(key.code, KeyCode::Esc)
5816 && matches!(
5817 app.backtrack.phase,
5818 crate::tui::backtrack::BacktrackPhase::Primed
5819 )
5820 {
5821 app.backtrack.reset();
5822 }
5823
5824 // Global keybindings — voice first (⌥V) so it doesn't insert a char.
5825 if handle_voice_key(app, &key) {
5826 continue;
5827 }
5828 if handle_reasoning_effort_key(app, &key) {
5829 if let Err(err) = persist_pending_work_checkpoint(app).await {
5830 app.status_message = Some(format!(
5831 "Reasoning effort changed, but its Work receipt is pending ({err})"
5832 ));
5833 }
5834 continue;
5835 }
5836
5837 // A second, empty Enter after queueing is the portable steer
5838 // gesture. Handle it before transcript/detail Enter shortcuts so
5839 // it can never open an unrelated overlay instead (#382).
5840 let portable_submit_chord = composer_submit_chord(key, app.composer_multiline_mode);
5841 // Inside the double-tap window every queued message steers,
5842 // oldest first — the same path Ctrl+Enter takes (one steering
5843 // path). Outside it, an empty Enter still promotes the oldest
5844 // queued message.
5845 if matches!(portable_submit_chord, Some(ComposerSubmitChord::Enter))
5846 && app.input.trim().is_empty()
5847 && !slash_menu_open
5848 && !mention_menu_open
5849 {
5850 let steers = app.take_queued_for_double_tap_steer();
5851 if !steers.is_empty() {
5852 let mut pending = steers.into_iter();
5853 for message in pending.by_ref() {
5854 let steered = attempt_steer_with_queue_fallback(
5855 app,
5856 config,
5857 &engine_handle,
5858 message,
5859 DispatchRecovery::Queued {
5860 restore_index: None,
5861 },
5862 )
5863 .await;
5864 if !steered {
5865 // The failed message is already restored; the
5866 // queue holds exactly it, so the unattempted
5867 // remainder appends behind it in order.
5868 for message in pending.by_ref() {
5869 app.queue_message(message);
5870 }
5871 break;
5872 }
5873 }
5874 persist_offline_queue_state(app);
5875 app.note_footer_hint_used(crate::tui::footer_hints::ENTER_AGAIN);
5876 continue;
5877 }
5878 }
5879 if matches!(portable_submit_chord, Some(ComposerSubmitChord::Enter))
5880 && matches!(
5881 app.decide_composer_submit(ComposerSubmitChord::Enter),
5882 ComposerSubmitAction::SendQueuedNow
5883 )
5884 {
5885 let _ = send_next_queued_message_now(app, config, &engine_handle).await?;
5886 continue;
5887 }
5888
5889 if let Some(shortcut) = crate::tui::agent_focus::shell_shortcut(
5890 app,
5891 &key,
5892 slash_menu_open || mention_menu_open,
5893 ) {
5894 app.note_footer_hint_used(crate::tui::footer_hints::AGENT_ARROWS);
5895 match shortcut {
5896 crate::tui::agent_focus::AgentShellShortcut::FocusAgents => {
5897 if !crate::tui::work_surface::enter_agents(app) {
5898 open_agents_register(app, &engine_handle).await;
5899 }
5900 }
5901 crate::tui::agent_focus::AgentShellShortcut::ManageAgents => {
5902 open_agents_register(app, &engine_handle).await;
5903 }
5904 }
5905 continue;
5906 }
5907
5908 match key.code {
5909 KeyCode::Enter
5910 if key.modifiers == KeyModifiers::NONE
5911 && app.input.is_empty()
5912 && app.viewport.transcript_selection.is_active()
5913 && open_pager_for_selection(app) =>
5914 {
5915 continue;
5916 }
5917 KeyCode::Enter
5918 if key.modifiers == KeyModifiers::NONE
5919 && app.input.is_empty()
5920 && detail_target_cell_index(app).is_some()
5921 && open_focused_cell_pager(app) =>
5922 {
5923 continue;
5924 }
5925 KeyCode::Enter
5926 if key.modifiers == KeyModifiers::NONE
5927 && app.input.is_empty()
5928 && detail_target_cell_index(app)
5929 .is_some_and(|idx| app.toggle_tool_run_expansion_at(idx)) =>
5930 {
5931 continue;
5932 }
5933 KeyCode::Char('l')
5934 if key_shortcuts::alt_nav_modifiers(key.modifiers)
5935 && open_pager_for_last_message(app) =>
5936 {
5937 continue;
5938 }
5939 _ if key_shortcuts::is_reasoning_detail_shortcut(&key)
5940 && open_reasoning_detail_pager(app) =>
5941 {
5942 continue;
5943 }
5944 _ if key_shortcuts::is_turn_inspector_shortcut(&key)
5945 && open_turn_inspector_pager(app) =>
5946 {
5947 continue;
5948 }
5949 // Space toggles fold/unfold of the focused thinking block
5950 // when the composer is empty. For thinking cells, toggles
5951 // between summary and full content; for other cells, toggles
5952 // visibility (#1972, #2348). Uses virtual-cell lookup so
5953 // in-flight active reasoning works too.
5954 KeyCode::Char(' ')
5955 if key.modifiers == KeyModifiers::NONE && app.input.is_empty() =>
5956 {
5957 let _ = handle_transcript_space(app);
5958 continue;
5959 }
5960 KeyCode::Char('t') | KeyCode::Char('T')
5961 if key.modifiers.contains(KeyModifiers::CONTROL)
5962 && key.modifiers.contains(KeyModifiers::SHIFT) =>
5963 {
5964 toggle_live_transcript_overlay(app);
5965 continue;
5966 }
5967 KeyCode::Char('1')
5968 if key.modifiers.contains(KeyModifiers::ALT)
5969 && key_shortcuts::has_control_like_modifier(key.modifiers) =>
5970 {
5971 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Tasks);
5972 continue;
5973 }
5974 KeyCode::Char('2')
5975 if key.modifiers.contains(KeyModifiers::ALT)
5976 && key_shortcuts::has_control_like_modifier(key.modifiers) =>
5977 {
5978 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Agents);
5979 continue;
5980 }
5981 KeyCode::Char('3')
5982 if key.modifiers.contains(KeyModifiers::ALT)
5983 && key_shortcuts::has_control_like_modifier(key.modifiers) =>
5984 {
5985 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Context);
5986 continue;
5987 }
5988 KeyCode::Char('4')
5989 if key.modifiers.contains(KeyModifiers::ALT)
5990 && key_shortcuts::has_control_like_modifier(key.modifiers) =>
5991 {
5992 apply_alt_4_shortcut(app, key.modifiers);
5993 continue;
5994 }
5995 // Rail panel selection via Alt+! / Alt+@ / Alt+# / Alt+$ / Alt+%
5996 // AltGr on European keyboards emits Ctrl+Alt on Windows, so
5997 // exclude Ctrl to avoid swallowing AltGr-typed characters
5998 // like @ (AltGr+0 on French AZERTY) and # (AltGr+3). This
5999 // matches the has_ctrl_or_alt / is_altgr philosophy in
6000 // key_hint.rs: treat Ctrl+Alt as AltGr, not a shortcut.
6001 KeyCode::Char('!')
6002 if key.modifiers.contains(KeyModifiers::ALT)
6003 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
6004 {
6005 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Tasks);
6006 continue;
6007 }
6008 KeyCode::Char('@')
6009 if key.modifiers.contains(KeyModifiers::ALT)
6010 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
6011 {
6012 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Agents);
6013 continue;
6014 }
6015 KeyCode::Char('#')
6016 if key.modifiers.contains(KeyModifiers::ALT)
6017 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
6018 {
6019 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Context);
6020 continue;
6021 }
6022 KeyCode::Char('$') | KeyCode::Char('%')
6023 if key.modifiers.contains(KeyModifiers::ALT)
6024 && !key.modifiers.contains(KeyModifiers::CONTROL) =>
6025 {
6026 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Files);
6027 continue;
6028 }
6029 KeyCode::Char('0')
6030 if key.modifiers.contains(KeyModifiers::ALT)
6031 && key.modifiers.contains(KeyModifiers::CONTROL) =>
6032 {
6033 apply_alt_0_shortcut(app, key.modifiers);
6034 continue;
6035 }
6036 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6037 // Scope the picker to the current workspace so Ctrl+R
6038 // never restores a different project's history by
6039 // surprise (#1395). Press `a` inside the picker to
6040 // broaden to every saved session.
6041 app.view_stack.push(
6042 SessionPickerView::new(&app.workspace, app.ui_locale)
6043 .with_current_session(app.current_session_id.as_deref()),
6044 );
6045 continue;
6046 }
6047 KeyCode::Char('c') | KeyCode::Char('C')
6048 if key_shortcuts::is_copy_shortcut(&key) =>
6049 {
6050 let sel = app.selected_text();
6051 if !sel.is_empty() {
6052 if app.clipboard.write_text(&sel).is_ok() {
6053 app.push_status_toast(
6054 "Copied to clipboard",
6055 StatusToastLevel::Info,
6056 None,
6057 );
6058 app.clear_selection();
6059 } else {
6060 app.push_status_toast("Copy failed", StatusToastLevel::Error, None);
6061 }
6062 } else {
6063 copy_active_selection(app);
6064 }
6065 }
6066 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6067 // Four behaviors layered on Ctrl+C in priority order — see
6068 // `CtrlCDisposition` for the unit-tested decision table.
6069 // 1. selection active → copy + clear (Windows convention,
6070 // #1337); 2. turn in flight → cancel; 3. quit-armed →
6071 // exit; 4. otherwise → arm the 2-second exit prompt.
6072 match ctrl_c_disposition(app) {
6073 CtrlCDisposition::CopySelection => {
6074 copy_active_selection(app);
6075 clear_transcript_selection(app);
6076 }
6077 CtrlCDisposition::CancelTurn => {
6078 let compacting = app.is_compacting || app.manual_compaction_queued;
6079 if compacting {
6080 try_cancel_compaction(app, &engine_handle);
6081 if !compact_interrupt_should_stop_turn(app) {
6082 app.disarm_quit();
6083 continue;
6084 }
6085 }
6086 let was_waiting = app.goal_continuation_waiting;
6087 engine_handle.cancel();
6088 if was_waiting {
6089 app.goal_continuation_waiting = false;
6090 app.status_message =
6091 Some(app.tr(MessageId::GoalContinuationStopped).to_string());
6092 app.disarm_quit();
6093 continue;
6094 }
6095 mark_active_turn_cancelled_locally(app);
6096 current_streaming_text.clear();
6097 stream_display_clock.reset();
6098 let prompt_restored = app.restore_last_submitted_prompt_if_empty();
6099 let base = if prompt_restored {
6100 "Request cancelled; prompt restored to composer"
6101 } else {
6102 "Request cancelled"
6103 };
6104 app.status_message = Some(parent_stop_status(app, base));
6105 app.disarm_quit();
6106 }
6107 CtrlCDisposition::ConfirmExit => {
6108 let _ = engine_handle.send(Op::Shutdown).await;
6109 return Ok(());
6110 }
6111 CtrlCDisposition::ArmExit => {
6112 app.arm_quit();
6113 }
6114 }
6115 }
6116 KeyCode::Char('d')
6117 if key.modifiers.contains(KeyModifiers::CONTROL) && app.input.is_empty() =>
6118 {
6119 let _ = engine_handle.send(Op::Shutdown).await;
6120 return Ok(());
6121 }
6122 // Agent focus: Esc on an empty composer returns to the main
6123 // conversation before any other Esc meaning applies.
6124 KeyCode::Esc
6125 if app.agent_focus.is_some()
6126 && app.input.is_empty()
6127 && !slash_menu_open
6128 && !mention_menu_open =>
6129 {
6130 crate::tui::agent_focus::exit_focus(app);
6131 continue;
6132 }
6133 // Vim composer mode: Esc from Insert/Visual → Normal.
6134 // This arm runs before the generic Esc handler so Insert mode
6135 // Esc doesn't accidentally cancel an in-flight request.
6136 KeyCode::Esc
6137 if app.composer.vim_enabled
6138 && app.composer.vim_mode != crate::tui::app::VimMode::Normal =>
6139 {
6140 app.vim_enter_normal();
6141 continue;
6142 }
6143 KeyCode::Esc if app.clear_composer_attachment_selection() => {
6144 continue;
6145 }
6146 // An idle operator can dismiss the persistent context warning
6147 // without affecting vim or attachment handling. While a turn
6148 // is active Esc retains its cancellation meaning.
6149 KeyCode::Esc
6150 if !app.is_loading
6151 && app.input.is_empty()
6152 && !slash_menu_open
6153 && !mention_menu_open
6154 && app.dismiss_context_pressure_warning() =>
6155 {
6156 continue;
6157 }
6158 KeyCode::Esc if mention_menu_open => {
6159 app.mention_menu_hidden = true;
6160 app.mention_menu_selected = 0;
6161 }
6162 KeyCode::Esc if app.sidebar_hover_tooltip.is_some() => {
6163 app.sidebar_hover_tooltip = None;
6164 app.needs_redraw = true;
6165 }
6166 KeyCode::Esc => {
6167 match next_escape_action(app, slash_menu_open) {
6168 EscapeAction::CloseSlashMenu => {
6169 // A popup-style action wins over backtrack — clear
6170 // any prime so a stale Primed state can't jump us
6171 // straight into Selecting on the next Esc.
6172 app.backtrack.reset();
6173 app.close_slash_menu();
6174 }
6175 EscapeAction::CancelRequest => {
6176 app.backtrack.reset();
6177 app.note_footer_hint_used(crate::tui::footer_hints::ESC_INTERRUPT);
6178 if escape_cancel_request(
6179 app,
6180 &engine_handle,
6181 &mut current_streaming_text,
6182 &mut stream_display_clock,
6183 ) {
6184 continue;
6185 }
6186 }
6187 EscapeAction::PauseCommand => {
6188 app.backtrack.reset();
6189 pause_pausable_command(app, &engine_handle);
6190 }
6191 EscapeAction::DiscardQueuedDraft => {
6192 app.backtrack.reset();
6193 if app.cancel_queued_draft_edit() {
6194 app.status_message =
6195 Some("Queued edit canceled; follow-up restored".to_string());
6196 }
6197 }
6198 EscapeAction::DismissPluginCta => {
6199 app.backtrack.reset();
6200 let _ = app.dismiss_plugin_cta();
6201 }
6202 EscapeAction::ClearInput => {
6203 app.backtrack.reset();
6204 app.edit_in_progress = false;
6205 app.clear_input_recoverable();
6206 let _ = app.maybe_show_behavioral_tip(
6207 crate::tui::behavioral_tips::BehavioralTip::ClearedInputRestore,
6208 );
6209 }
6210 EscapeAction::Noop => {
6211 // Nothing else cares about this Esc — route it
6212 // through the backtrack state machine. While
6213 // streaming or with the live transcript already
6214 // open, fall through silently (#133 acceptance:
6215 // "during streaming Esc-Esc is a silent no-op").
6216 if app.is_loading
6217 || app.view_stack.top_kind() == Some(ModalKind::LiveTranscript)
6218 {
6219 continue;
6220 }
6221 let total = count_user_history_cells(app);
6222 match app.backtrack.handle_esc(total) {
6223 crate::tui::backtrack::EscEffect::None => {}
6224 crate::tui::backtrack::EscEffect::Prime => {
6225 app.status_message =
6226 Some("Press Esc again to backtrack".to_string());
6227 app.needs_redraw = true;
6228 }
6229 crate::tui::backtrack::EscEffect::Cancel => {
6230 app.status_message = Some("Backtrack canceled".to_string());
6231 app.needs_redraw = true;
6232 }
6233 crate::tui::backtrack::EscEffect::OpenOverlay => {
6234 open_backtrack_overlay(app);
6235 }
6236 }
6237 }
6238 }
6239 }
6240 KeyCode::Up if key.modifiers.contains(KeyModifiers::SUPER) => {
6241 app.scroll_up(app.viewport.last_transcript_visible.max(3));
6242 }
6243 KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
6244 app.scroll_up(3);
6245 }
6246 KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => {
6247 app.scroll_up(3);
6248 }
6249 KeyCode::Up
6250 if key.modifiers.is_empty()
6251 && mention_menu_open
6252 && app.mention_menu_selected > 0 =>
6253 {
6254 app.mention_menu_selected = app.mention_menu_selected.saturating_sub(1);
6255 }
6256 KeyCode::Up if key.modifiers.is_empty() && slash_menu_open => {
6257 select_previous_slash_menu_entry(app, slash_menu_entries.len());
6258 }
6259 KeyCode::Char('p')
6260 if key.modifiers.contains(KeyModifiers::CONTROL) && slash_menu_open =>
6261 {
6262 select_previous_slash_menu_entry(app, slash_menu_entries.len());
6263 }
6264 KeyCode::Up
6265 if key.modifiers.is_empty()
6266 && app.selected_composer_attachment_index().is_some() =>
6267 {
6268 let _ = app.select_previous_composer_attachment();
6269 }
6270 KeyCode::Up
6271 if key.modifiers.is_empty()
6272 && app.cursor_position == 0
6273 && !mention_menu_open
6274 && !slash_menu_open
6275 && app.composer_attachment_count() > 0 =>
6276 {
6277 let _ = app.select_previous_composer_attachment();
6278 continue;
6279 }
6280 // #85: ↑ edits the most-recent queued message when the composer
6281 // is idle and the pending-input preview is showing queued work.
6282 KeyCode::Up
6283 if key.modifiers.is_empty()
6284 && app.input.is_empty()
6285 && app.cursor_position == 0
6286 && app.queued_draft.is_none()
6287 && !app.queued_messages.is_empty()
6288 && !mention_menu_open
6289 && !slash_menu_open
6290 && app.selected_composer_attachment_index().is_none() =>
6291 {
6292 let _ = app.pop_last_queued_into_draft();
6293 }
6294 KeyCode::Down if key.modifiers.contains(KeyModifiers::SUPER) => {
6295 app.scroll_down(app.viewport.last_transcript_visible.max(3));
6296 }
6297 KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => {
6298 app.scroll_down(3);
6299 }
6300 KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
6301 app.scroll_down(3);
6302 }
6303 KeyCode::Down if key.modifiers.is_empty() && mention_menu_open => {
6304 app.mention_menu_selected = (app.mention_menu_selected + 1)
6305 .min(mention_menu_entries.len().saturating_sub(1));
6306 }
6307 KeyCode::Down if key.modifiers.is_empty() && slash_menu_open => {
6308 select_next_slash_menu_entry(app, slash_menu_entries.len());
6309 }
6310 KeyCode::Char('n')
6311 if key.modifiers.contains(KeyModifiers::CONTROL) && slash_menu_open =>
6312 {
6313 select_next_slash_menu_entry(app, slash_menu_entries.len());
6314 }
6315 // Paging and edge motions from the shared vocabulary (#6290),
6316 // claimed before the unconditional transcript-scroll arms.
6317 KeyCode::PageUp if key.modifiers.is_empty() && slash_menu_open => {
6318 move_slash_menu_selection(
6319 app,
6320 slash_menu_entries.len(),
6321 crate::tui::list_nav::Motion::PagePrev,
6322 );
6323 }
6324 KeyCode::PageDown if key.modifiers.is_empty() && slash_menu_open => {
6325 move_slash_menu_selection(
6326 app,
6327 slash_menu_entries.len(),
6328 crate::tui::list_nav::Motion::PageNext,
6329 );
6330 }
6331 // Home/End deliberately stay cursor keys while the menu is open:
6332 // the composer is still the focused input (same as Left/Right
6333 // and the mention menu), so only vertical travel belongs to
6334 // the popup.
6335 KeyCode::Down
6336 if key.modifiers.is_empty()
6337 && app.selected_composer_attachment_index().is_some() =>
6338 {
6339 let _ = app.select_next_composer_attachment();
6340 }
6341 KeyCode::PageUp => {
6342 let page = app.viewport.last_transcript_visible.max(1);
6343 app.scroll_up(page);
6344 }
6345 KeyCode::PageDown => {
6346 let page = app.viewport.last_transcript_visible.max(1);
6347 app.scroll_down(page);
6348 }
6349 KeyCode::Tab => {
6350 match dispatch_tab_key(app, &key, &mention_menu_entries, &slash_menu_entries) {
6351 TabDispatch::Completion | TabDispatch::Ignored => continue,
6352 TabDispatch::ModeCycled {
6353 prior_mode,
6354 prior_model,
6355 } => {
6356 if app.mode != prior_mode {
6357 sync_mode_update(app, &engine_handle).await;
6358 }
6359 if app.model != prior_model {
6360 let _ = engine_handle
6361 .send(Op::SetModel {
6362 model: app.model.clone(),
6363 mode: app.mode,
6364 route_limits: app.active_route_limits,
6365 })
6366 .await;
6367 }
6368 }
6369 }
6370 }
6371 // Transcript-nav shortcuts now require Alt, leaving most bare
6372 // letters free to insert as text. Requiring Alt is also why
6373 // none of them asks whether the composer is empty: an Alt
6374 // chord is never composer text, so `input.is_empty()` there
6375 // was guessing at focus and only ever broke the shortcut for
6376 // anyone mid-draft. Before v0.8.30, bare `g`,
6377 // `G`, `[`, `]`, `?`, and `l` on an empty composer were
6378 // hijacked for navigation — typing "good" yielded "ood" with
6379 // no whale and no warning. The Alt-prefixed shortcuts mirror
6380 // the Alt+R / Alt+C pattern already in use. Shift is
6381 // permitted for most capital-letter forms.
6382 KeyCode::Char('g')
6383 if key_shortcuts::alt_nav_modifiers(key.modifiers) && !slash_menu_open =>
6384 {
6385 if let Some(anchor) =
6386 TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0)
6387 {
6388 app.viewport.transcript_scroll = anchor;
6389 }
6390 }
6391 KeyCode::Char('G')
6392 if key_shortcuts::alt_nav_modifiers(key.modifiers) && !slash_menu_open =>
6393 {
6394 app.scroll_to_bottom();
6395 }
6396 KeyCode::Char('[')
6397 if key_shortcuts::alt_nav_modifiers(key.modifiers)
6398 && !slash_menu_open
6399 && !jump_to_adjacent_tool_cell(app, SearchDirection::Backward) =>
6400 {
6401 app.status_message = Some("No previous tool output".to_string());
6402 }
6403 KeyCode::Char(']')
6404 if key_shortcuts::alt_nav_modifiers(key.modifiers)
6405 && !slash_menu_open
6406 && !jump_to_adjacent_tool_cell(app, SearchDirection::Forward) =>
6407 {
6408 app.status_message = Some("No next tool output".to_string());
6409 }
6410 // Help chords (Alt+?, F1, Ctrl+/) are handled above via
6411 // shell_key_routing::is_help_shortcut so printable layout
6412 // characters stay text.
6413 // Input handling
6414 _ if is_composer_newline_key(key, app.composer_multiline_mode)
6415 && !(is_plain_enter && (slash_menu_open || mention_menu_open)) =>
6416 {
6417 app.insert_char('\n');
6418 }
6419 KeyCode::Enter
6420 if key.modifiers == KeyModifiers::NONE
6421 && mention_menu_open
6422 && crate::tui::file_mention::apply_mention_menu_selection(
6423 app,
6424 &mention_menu_entries,
6425 ) =>
6426 {
6427 continue;
6428 }
6429 // Accept Ctrl+Enter when the terminal reports it distinctly.
6430 // It is deliberately not advertised because several common
6431 // terminals encode it exactly like bare Enter.
6432 _ if is_forced_submit_key(key) => {
6433 let action = app.decide_composer_submit(ComposerSubmitChord::CtrlEnter);
6434 if let Some(input) = app.submit_input() {
6435 if handle_bang_shell_input(app, &engine_handle, &input).await? {
6436 continue;
6437 }
6438 if looks_like_slash_command_input(&input) {
6439 if execute_command_input(
6440 terminal,
6441 app,
6442 &mut engine_handle,
6443 &task_manager,
6444 config,
6445 &input,
6446 )
6447 .await?
6448 {
6449 return Ok(());
6450 }
6451 } else {
6452 let (queued, recovery) = message_from_submitted_input(app, input);
6453 dispatch_composer_message(
6454 app,
6455 config,
6456 &engine_handle,
6457 queued,
6458 recovery,
6459 action,
6460 )
6461 .await?;
6462 }
6463 }
6464 }
6465 KeyCode::Enter => {
6466 let action = app.decide_composer_submit(
6467 portable_submit_chord.unwrap_or(ComposerSubmitChord::Enter),
6468 );
6469 // Slash-menu selection, draft consumption, and the
6470 // memory/`!`/`/`/message branches are the shared tail the
6471 // mouse `[↵]` dispatcher also runs, so keyboard and pointer
6472 // submit behavior cannot drift apart.
6473 if submit_decided_composer_input(
6474 terminal,
6475 app,
6476 &mut engine_handle,
6477 &task_manager,
6478 config,
6479 action,
6480 )
6481 .await?
6482 {
6483 return Ok(());
6484 }
6485 }
6486 KeyCode::Backspace
6487 if key.modifiers.contains(KeyModifiers::SUPER)
6488 && !app.remove_selected_composer_attachment() =>
6489 {
6490 app.delete_to_start_of_line();
6491 }
6492 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => {}
6493 KeyCode::Backspace
6494 if key.modifiers.contains(KeyModifiers::ALT)
6495 && !app.remove_selected_composer_attachment() =>
6496 {
6497 app.delete_word_backward();
6498 }
6499 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {}
6500 KeyCode::Backspace
6501 if key.modifiers.contains(KeyModifiers::CONTROL)
6502 && !app.remove_selected_composer_attachment() =>
6503 {
6504 app.delete_word_backward();
6505 }
6506 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::CONTROL) => {}
6507 KeyCode::Delete
6508 if key.modifiers.contains(KeyModifiers::ALT)
6509 && !app.remove_selected_composer_attachment() =>
6510 {
6511 app.delete_word_forward();
6512 }
6513 KeyCode::Delete if key.modifiers.contains(KeyModifiers::ALT) => {}
6514 KeyCode::Delete
6515 if key.modifiers.contains(KeyModifiers::CONTROL)
6516 && !app.remove_selected_composer_attachment() =>
6517 {
6518 app.delete_word_forward();
6519 }
6520 KeyCode::Delete if key.modifiers.contains(KeyModifiers::CONTROL) => {}
6521 KeyCode::Backspace if !app.remove_selected_composer_attachment() => {
6522 app.delete_char();
6523 }
6524 KeyCode::Backspace => {}
6525 KeyCode::Char('h')
6526 if key_shortcuts::is_ctrl_h_backspace(&key)
6527 && !app.remove_selected_composer_attachment() =>
6528 {
6529 app.delete_char();
6530 }
6531 KeyCode::Char('h') if key_shortcuts::is_ctrl_h_backspace(&key) => {}
6532 KeyCode::Delete if !app.remove_selected_composer_attachment() => {
6533 app.delete_char_forward();
6534 }
6535 KeyCode::Delete => {}
6536 _ if key_shortcuts::is_select_all_shortcut(&key) => {
6537 app.select_all();
6538 }
6539 KeyCode::Left
6540 if key.modifiers.contains(KeyModifiers::SHIFT)
6541 && is_word_cursor_modifier(key.modifiers) =>
6542 {
6543 if app.selection_anchor.is_none() {
6544 app.selection_anchor = Some(app.cursor_position);
6545 }
6546 app.move_cursor_word_backward();
6547 }
6548 KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => {
6549 if app.selection_anchor.is_none() {
6550 app.selection_anchor = Some(app.cursor_position);
6551 }
6552 app.move_cursor_left();
6553 }
6554 KeyCode::Left if is_word_cursor_modifier(key.modifiers) => {
6555 app.clear_selection();
6556 app.move_cursor_word_backward();
6557 }
6558 KeyCode::Left => {
6559 app.clear_selection();
6560 app.move_cursor_left();
6561 }
6562 KeyCode::Right
6563 if key.modifiers.contains(KeyModifiers::SHIFT)
6564 && is_word_cursor_modifier(key.modifiers) =>
6565 {
6566 if app.selection_anchor.is_none() {
6567 app.selection_anchor = Some(app.cursor_position);
6568 }
6569 app.move_cursor_word_forward();
6570 }
6571 KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => {
6572 if app.selection_anchor.is_none() {
6573 app.selection_anchor = Some(app.cursor_position);
6574 }
6575 app.move_cursor_right();
6576 }
6577 KeyCode::Right if is_word_cursor_modifier(key.modifiers) => {
6578 app.clear_selection();
6579 app.move_cursor_word_forward();
6580 }
6581 KeyCode::Right => {
6582 app.clear_selection();
6583 app.move_cursor_right();
6584 }
6585 // Selection-extending Home/End. Ctrl+Shift extends to the
6586 // buffer edge, bare Shift to the logical line edge. These sit
6587 // above the Ctrl+Home/Ctrl+End transcript-scroll arms so the
6588 // shifted chords always edit the selection, never the
6589 // viewport.
6590 KeyCode::Home
6591 if key.modifiers.contains(KeyModifiers::SHIFT)
6592 && key.modifiers.contains(KeyModifiers::CONTROL) =>
6593 {
6594 if app.selection_anchor.is_none() {
6595 app.selection_anchor = Some(app.cursor_position);
6596 }
6597 app.move_cursor_start();
6598 }
6599 KeyCode::End
6600 if key.modifiers.contains(KeyModifiers::SHIFT)
6601 && key.modifiers.contains(KeyModifiers::CONTROL) =>
6602 {
6603 if app.selection_anchor.is_none() {
6604 app.selection_anchor = Some(app.cursor_position);
6605 }
6606 app.move_cursor_end();
6607 }
6608 KeyCode::Home if key.modifiers.contains(KeyModifiers::SHIFT) => {
6609 if app.selection_anchor.is_none() {
6610 app.selection_anchor = Some(app.cursor_position);
6611 }
6612 app.move_cursor_line_start();
6613 }
6614 KeyCode::End if key.modifiers.contains(KeyModifiers::SHIFT) => {
6615 if app.selection_anchor.is_none() {
6616 app.selection_anchor = Some(app.cursor_position);
6617 }
6618 app.move_cursor_line_end();
6619 }
6620 KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => {
6621 if let Some(anchor) =
6622 TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0)
6623 {
6624 app.viewport.transcript_scroll = anchor;
6625 }
6626 }
6627 KeyCode::End if key.modifiers.contains(KeyModifiers::CONTROL) => {
6628 app.scroll_to_bottom();
6629 }
6630 KeyCode::Home | KeyCode::Char('a')
6631 if key.modifiers.contains(KeyModifiers::CONTROL) =>
6632 {
6633 app.clear_selection();
6634 app.move_cursor_start();
6635 }
6636 KeyCode::Home => {
6637 app.clear_selection();
6638 app.move_cursor_line_start();
6639 }
6640 KeyCode::End => {
6641 app.clear_selection();
6642 app.move_cursor_line_end();
6643 }
6644 KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6645 app.clear_selection();
6646 app.move_cursor_end();
6647 }
6648 _ if handle_composer_alt_word_motion_key(app, key) => {}
6649 _ if key_shortcuts::is_external_editor_shortcut(&key) => {
6650 // Ctrl+Shift+O (or F4 on terminals that cannot report the
6651 // shifted chord): spawn $EDITOR on the composer contents
6652 // (#91). Plain Ctrl+O belongs exclusively to the Turn
6653 // Inspector, even while the composer holds a draft (#4482).
6654 // Only fires when no modal is active (the !view_stack
6655 // branch above already returns early in that case) and
6656 // the composer is the focused input target. We accept the
6657 // shortcut whether or not a model turn is streaming —
6658 // editing the buffer never disturbs in-flight work.
6659 let seed = app.input.clone();
6660 let editor_result = match terminal_input.pause_for_child_terminal().await {
6661 Err(err) => Err(err),
6662 Ok(()) => {
6663 let result = prepare_terminal_input_handoff(
6664 &terminal_input,
6665 &mut pending_terminal_events,
6666 )
6667 .and_then(|ready| {
6668 if ready {
6669 crate::tui::external_editor::spawn_editor_for_input(
6670 terminal,
6671 app.use_alt_screen(),
6672 app.use_mouse_capture,
6673 app.use_bracketed_paste,
6674 &seed,
6675 )
6676 } else {
6677 Err(io::Error::new(
6678 io::ErrorKind::Interrupted,
6679 "editor handoff cancelled by pending terminal input",
6680 ))
6681 }
6682 });
6683 terminal_input.resume_after_child_terminal();
6684 force_terminal_repaint = true;
6685 result
6686 }
6687 };
6688 match editor_result {
6689 Ok(crate::tui::external_editor::EditorOutcome::Edited(new)) => {
6690 app.apply_external_edit(new);
6691 let editor = std::env::var("VISUAL")
6692 .ok()
6693 .filter(|s| !s.trim().is_empty())
6694 .or_else(|| {
6695 std::env::var("EDITOR")
6696 .ok()
6697 .filter(|s| !s.trim().is_empty())
6698 })
6699 .unwrap_or_else(|| "vi".to_string());
6700 app.status_message = Some(format!("Edited in {editor}"));
6701 }
6702 Ok(crate::tui::external_editor::EditorOutcome::Unchanged) => {
6703 app.status_message = Some("Editor closed (no changes)".to_string());
6704 }
6705 Ok(crate::tui::external_editor::EditorOutcome::Cancelled) => {
6706 app.status_message = Some("Editor cancelled".to_string());
6707 }
6708 Err(err) => {
6709 app.status_message = Some(format!("Editor error: {err}"));
6710 }
6711 }
6712 app.needs_redraw = true;
6713 }
6714 KeyCode::Up => {
6715 let _ =
6716 handle_composer_history_arrow(app, key, slash_menu_open, mention_menu_open);
6717 }
6718 KeyCode::Down => {
6719 let _ =
6720 handle_composer_history_arrow(app, key, slash_menu_open, mention_menu_open);
6721 }
6722 // Ctrl+Shift+U is the shifted-Ctrl chord for `/update install`
6723 // (same family as Ctrl+Shift+A/E/O). It routes through the
6724 // exact typed-command path, so the managed-install gate and
6725 // the "already up to date" outcome are inherited from
6726 // `commands::update` rather than reimplemented here. Placed
6727 // above the readline Ctrl+U arm so the shifted chord is never
6728 // swallowed by clear-input.
6729 _ if key_shortcuts::is_update_install_shortcut(&key) => {
6730 if execute_command_input(
6731 terminal,
6732 app,
6733 &mut engine_handle,
6734 &task_manager,
6735 config,
6736 "/update install",
6737 )
6738 .await?
6739 {
6740 return Ok(());
6741 }
6742 }
6743 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6744 app.clear_input_recoverable();
6745 let _ = app.maybe_show_behavioral_tip(
6746 crate::tui::behavioral_tips::BehavioralTip::ClearedInputRestore,
6747 );
6748 }
6749 KeyCode::Char('z')
6750 if key.modifiers.contains(KeyModifiers::CONTROL)
6751 && app.restore_last_cleared_input_if_empty() =>
6752 {
6753 app.status_message = Some("Restored cleared draft".to_string());
6754 }
6755 KeyCode::Char('w') | KeyCode::Char('W')
6756 if key.modifiers.contains(KeyModifiers::CONTROL) =>
6757 {
6758 app.delete_word_backward();
6759 }
6760 KeyCode::Char('s')
6761 | KeyCode::Char('S')
6762 | KeyCode::Char('g')
6763 | KeyCode::Char('G')
6764 if key.modifiers == KeyModifiers::CONTROL =>
6765 {
6766 // #440: park the current draft to the persistent stash and
6767 // clear the composer. Ctrl+G is the terminal-safe alias for
6768 // hosts such as Cursor/VS Code that reserve Ctrl+S for Save.
6769 // Empty composers are a no-op so a stray shortcut cannot
6770 // pollute the file. Surface a toast so the user sees the
6771 // confirmation (no-op feels broken otherwise).
6772 if !app.input.is_empty() {
6773 crate::composer_stash::push_stash(&app.input);
6774 if app.queued_draft.is_some() {
6775 // Stash the edited text while preserving the
6776 // original queued follow-up in its queue slot.
6777 let _ = app.cancel_queued_draft_edit();
6778 } else {
6779 app.clear_input_recoverable();
6780 }
6781 app.push_status_toast(
6782 "Draft stashed — `/stash pop` to restore",
6783 StatusToastLevel::Info,
6784 Some(3_000),
6785 );
6786 }
6787 }
6788 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6789 // #379: context-sensitive Ctrl+Y.
6790 // When the composer has content → emacs-style yank
6791 // from the kill buffer at the cursor.
6792 // When the composer is empty (transcript focus) →
6793 // copy the focused cell text to the system clipboard.
6794 if app.input.is_empty() && app.view_stack.is_empty() {
6795 if copy_focused_cell(app) {
6796 app.push_status_toast(
6797 "Copied to clipboard",
6798 StatusToastLevel::Info,
6799 Some(2_000),
6800 );
6801 } else {
6802 app.status_message = Some("No transcript cell to copy".to_string());
6803 }
6804 } else {
6805 app.yank();
6806 }
6807 }
6808 KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6809 let sel = app.selected_text();
6810 if !sel.is_empty() {
6811 if app.clipboard.write_text(&sel).is_ok() {
6812 app.push_status_toast("Cut to clipboard", StatusToastLevel::Info, None);
6813 app.delete_selection();
6814 } else {
6815 app.push_status_toast("Cut failed", StatusToastLevel::Error, None);
6816 }
6817 }
6818 }
6819 _ if key_shortcuts::is_paste_shortcut(&key) => {
6820 app.paste_from_clipboard();
6821 }
6822 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => {
6823 apply_mode_update(app, &engine_handle, config, AppMode::Agent).await;
6824 continue;
6825 }
6826 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => {
6827 apply_yolo_compat_update(app, &engine_handle, config).await;
6828 continue;
6829 }
6830 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => {
6831 apply_mode_update(app, &engine_handle, config, AppMode::Plan).await;
6832 continue;
6833 }
6834 KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => {
6835 apply_mode_update(app, &engine_handle, config, AppMode::Agent).await;
6836 continue;
6837 }
6838 KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => {
6839 apply_yolo_compat_update(app, &engine_handle, config).await;
6840 continue;
6841 }
6842 KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => {
6843 apply_mode_update(app, &engine_handle, config, AppMode::Plan).await;
6844 continue;
6845 }
6846 // Vim composer: Normal-mode motion / operator keys.
6847 // Only fires when vim is enabled, the input is focused (no modal
6848 // open on top), and the key has no modifier (pure char).
6849 KeyCode::Char(c)
6850 if app.vim_is_normal_mode()
6851 && key.modifiers.is_empty()
6852 && !slash_menu_open
6853 && !mention_menu_open
6854 && app.view_stack.is_empty() =>
6855 {
6856 vim_mode::handle_vim_normal_key(app, c);
6857 continue;
6858 }
6859 // Vim composer: in Visual mode plain chars are ignored
6860 // (no text insertion until `i` / `a` enters Insert).
6861 KeyCode::Char(_)
6862 if app.vim_is_visual_mode()
6863 && key.modifiers.is_empty()
6864 && app.view_stack.is_empty() =>
6865 {
6866 // absorb — Visual mode not yet fully implemented
6867 }
6868 KeyCode::Char(c) if is_plain_char => {
6869 app.insert_char(c);
6870 }
6871 KeyCode::Char(_) => {}
6872 _ => {}
6873 }
6874
6875 if !is_plain_char && !is_plain_enter {
6876 app.paste_burst.deactivate_keep_window();
6877 }
6878 }
6879 }
6880 }
6881
6882 /// Apply one MCP session-boot event. Failures stay on the snapshot (and
6883 /// therefore the session page) rather than as toast-only Status copy.
6884 /// A direct `/mcp` snapshot invalidates only the event generation it
6885 /// superseded. Older spawn-time updates cannot overwrite it, while a later
6886 /// engine-authored generation can continue updating the live surface.
6887 pub(crate) fn apply_mcp_session_boot_event(
6888 app: &mut App,
6889 generation: u64,
6890 snapshot: crate::mcp::McpManagerSnapshot,
6891 connecting: Vec<String>,
6892 finished: bool,
6893 ) {
6894 if generation < app.mcp_snapshot_generation
6895 || (generation == app.mcp_snapshot_generation && app.mcp_snapshot_generation_invalidated)
6896 {
6897 return;
6898 }
6899 app.mcp_snapshot_generation = generation;
6900 app.mcp_snapshot_generation_invalidated = false;
6901 app.mcp_configured_count = snapshot.servers.len();
6902 app.hotbar_actions.replace_mcp_tools(Some(&snapshot));
6903 if finished && app.mcp_reload_in_flight {
6904 // One completion receipt for the explicit reload that started this
6905 // pass; session boot never sets the flag.
6906 app.mcp_reload_in_flight = false;
6907 crate::tui::mcp_routing::add_mcp_message(
6908 app,
6909 crate::tui::ui::provider_routes::mcp_reload_summary(&snapshot),
6910 );
6911 }
6912 app.mcp_snapshot = Some(snapshot);
6913 app.mcp_connecting = connecting;
6914 app.mcp_initializing = !finished;
6915 app.needs_redraw = true;
6916 }
6917
6918 pub(crate) async fn run_cache_warmup(app: &App, config: &Config) -> Result<CacheWarmupOutcome> {
6919 let route = resolve_cache_replay_route(app, config)?
6920 .validate()
6921 .map_err(anyhow::Error::msg)?;
6922 let base_url = route.client.base_url().to_string();
6923 let reasoning_effort = app
6924 .reasoning_effort_api_value_for_replay(route.identity.provider, &base_url, &route.model)
6925 .map(str::to_string);
6926 let request = MessageRequest {
6927 model: route.model.clone(),
6928 messages: app.api_messages.as_ref().clone(),
6929 max_tokens: CACHE_WARMUP_MAX_TOKENS,
6930 system: app.system_prompt.clone(),
6931 tools: app.session.last_tool_catalog.clone(),
6932 tool_choice: None,
6933 metadata: None,
6934 thinking: None,
6935 reasoning_effort,
6936 stream: None,
6937 temperature: None,
6938 top_p: None,
6939 };
6940 let warmup = build_cache_warmup_request(&request);
6941 let inspection = inspect_prompt_for_request(&warmup);
6942 let response =
6943 tokio::time::timeout(Duration::from_secs(45), route.client.create_message(warmup))
6944 .await??;
6945 Ok(CacheWarmupOutcome {
6946 usage: response.usage,
6947 provider_identity: route.identity.key,
6948 model: route.model,
6949 base_url,
6950 inspection,
6951 })
6952 }
6953
6954 /// Switch a first-run / missing-key session onto a live local Ollama tag.
6955 async fn adopt_live_local_ollama_catalog(
6956 app: &mut App,
6957 engine_handle: &mut EngineHandle,
6958 config: &mut Config,
6959 catalog: crate::local_ollama::LiveLocalOllamaCatalog,
6960 ) {
6961 let Some(tag) = catalog.preferred_tag().map(str::to_string) else {
6962 return;
6963 };
6964 // switch_provider resolves against the lake we just refreshed.
6965 let switched = switch_provider(
6966 app,
6967 engine_handle,
6968 config,
6969 ApiProvider::Ollama,
6970 Some(tag.clone()),
6971 )
6972 .await;
6973 if !switched {
6974 return;
6975 }
6976 app.onboarding_needs_api_key = false;
6977 app.onboarding_missing_key_recovery = false;
6978 app.status_message = Some(format!("Local Ollama ready · {tag} (from GET /api/tags)"));
6979 app.needs_redraw = true;
6980 }
6981
6982 pub(crate) async fn run_prepared_dispatch(
6983 app: &mut App,
6984 config: &Config,
6985 engine_handle: &EngineHandle,
6986 prepare: UserDispatchPrepare,
6987 recovery: DispatchRecovery,
6988 ) -> Result<()> {
6989 // Unit tests that intentionally omit the production completion mailbox
6990 // apply the result inline. Run the owned async phase as a task just like
6991 // production does so its large future is polled from a clean executor
6992 // stack instead of nesting under the test helper's call chain.
6993 let apply = tokio::spawn(spawned_dispatch_inner(
6994 prepare,
6995 recovery,
6996 engine_handle.clone(),
6997 ))
6998 .await
6999 .map_err(|err| anyhow::anyhow!("dispatch task was lost: {err}"))?;
7000 apply(app, engine_handle, config)
7001 }
7002
7003 pub(crate) async fn run_xai_device_login_from_tui(
7004 terminal: &mut AppTerminal,
7005 app: &mut App,
7006 engine_handle: &mut EngineHandle,
7007 config: &mut Config,
7008 ) -> Result<bool> {
7009 pause_terminal(
7010 terminal,
7011 app.use_alt_screen(),
7012 app.use_mouse_capture,
7013 app.use_bracketed_paste,
7014 )?;
7015 let login_result = crate::oauth::login(crate::oauth::OAuthProvider::Xai).await;
7016 resume_terminal(
7017 terminal,
7018 app.use_alt_screen(),
7019 app.use_mouse_capture,
7020 app.use_bracketed_paste,
7021 app.synchronized_output_enabled,
7022 )?;
7023
7024 let switched = match login_result {
7025 Ok(pending) => {
7026 apply_codewhale_owned_xai_login(
7027 app,
7028 engine_handle,
7029 config,
7030 pending,
7031 "xAI device login complete",
7032 )
7033 .await
7034 }
7035 Err(err) => {
7036 let message = format!("xAI device login failed: {err}");
7037 app.add_message(HistoryCell::System {
7038 content: message.clone(),
7039 });
7040 app.status_message = Some(message);
7041 false
7042 }
7043 };
7044 app.needs_redraw = true;
7045 Ok(switched)
7046 }
7047
7048 pub(crate) async fn run_chatgpt_pkce_login_from_tui(
7049 terminal: &mut AppTerminal,
7050 app: &mut App,
7051 engine_handle: &mut EngineHandle,
7052 config: &mut Config,
7053 ) -> Result<bool> {
7054 pause_terminal(
7055 terminal,
7056 app.use_alt_screen(),
7057 app.use_mouse_capture,
7058 app.use_bracketed_paste,
7059 )?;
7060 let login_result = crate::oauth::login(crate::oauth::OAuthProvider::Chatgpt).await;
7061 resume_terminal(
7062 terminal,
7063 app.use_alt_screen(),
7064 app.use_mouse_capture,
7065 app.use_bracketed_paste,
7066 app.synchronized_output_enabled,
7067 )?;
7068
7069 let switched = match login_result {
7070 Ok(pending) => {
7071 apply_codewhale_owned_chatgpt_login(
7072 app,
7073 engine_handle,
7074 config,
7075 pending,
7076 "ChatGPT sign-in complete",
7077 )
7078 .await
7079 }
7080 Err(err) => {
7081 let message = format!("ChatGPT sign-in failed: {err}");
7082 app.add_message(HistoryCell::System {
7083 content: message.clone(),
7084 });
7085 app.status_message = Some(message);
7086 false
7087 }
7088 };
7089 app.needs_redraw = true;
7090 Ok(switched)
7091 }
7092
7093 /// Move held permission receipts into the transcript: those for `tool_id`
7094 /// when given, otherwise every remaining one. Returns whether anything moved.
7095 pub(super) fn flush_gate_receipts_for(app: &mut App, tool_id: Option<&str>) -> bool {
7096 let (ready, held): (Vec<_>, Vec<_>) = std::mem::take(&mut app.pending_gate_receipts)
7097 .into_iter()
7098 .partition(|(id, _)| tool_id.is_none_or(|wanted| id == wanted));
7099 app.pending_gate_receipts = held;
7100 let moved = !ready.is_empty();
7101 for (_, content) in ready {
7102 app.add_message(HistoryCell::System { content });
7103 }
7104 moved
7105 }
7106
7107 /// Open the `/agents` register (the manage view: focus, stop, refresh) and ask
7108 /// the engine for a fresh listing.
7109 async fn open_agents_register(app: &mut App, engine_handle: &EngineHandle) {
7110 if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
7111 let agents = subagent_view_agents(app, &app.subagent_cache);
7112 app.view_stack
7113 .push(crate::tui::views::SubAgentsView::for_app(app, agents));
7114 }
7115 let _ = engine_handle.send(Op::ListSubAgents).await;
7116 app.needs_redraw = true;
7117 }
7118
7119 #[cfg(test)]
7120 mod session_boot_event_tests {
7121 use super::*;
7122 use crate::mcp::{McpManagerSnapshot, McpServerCapabilityMetadata, McpServerSnapshot};
7123 use std::path::PathBuf;
7124
7125 fn server(name: &str, connected: bool) -> McpServerSnapshot {
7126 McpServerSnapshot {
7127 name: name.to_string(),
7128 enabled: true,
7129 required: false,
7130 transport: "stdio".to_string(),
7131 command_or_url: format!("cmd-{name}"),
7132 connect_timeout: 5,
7133 execute_timeout: 5,
7134 read_timeout: 5,
7135 connected,
7136 error: None,
7137 auth_required: false,
7138 capability_metadata: McpServerCapabilityMetadata::NotObserved,
7139 tools: Vec::new(),
7140 resources: Vec::new(),
7141 prompts: Vec::new(),
7142 }
7143 }
7144
7145 fn snapshot(servers: Vec<McpServerSnapshot>) -> McpManagerSnapshot {
7146 McpManagerSnapshot {
7147 config_path: PathBuf::from("mcp.json"),
7148 config_exists: true,
7149 reload_required: false,
7150 servers,
7151 }
7152 }
7153
7154 fn test_app() -> App {
7155 crate::test_support::test_app_with_options(crate::test_support::test_tui_options(
7156 PathBuf::from("."),
7157 ))
7158 }
7159
7160 #[test]
7161 fn boot_event_names_every_connecting_server_on_the_app() {
7162 let mut app = test_app();
7163 apply_mcp_session_boot_event(
7164 &mut app,
7165 1,
7166 snapshot(vec![server("alpha", false), server("beta", false)]),
7167 vec!["alpha".into(), "beta".into()],
7168 false,
7169 );
7170 assert!(app.mcp_initializing);
7171 assert_eq!(app.mcp_connecting, vec!["alpha", "beta"]);
7172 assert_eq!(app.mcp_configured_count, 2);
7173 let surface = crate::tui::session_boot::SessionBootSurface::from_app(&app);
7174 let chip = surface
7175 .activity_notice(codewhale_localization::Locale::En, 80)
7176 .map(|notice| notice.text)
7177 .expect("chip");
7178 assert!(chip.contains("alpha"), "{chip}");
7179 assert!(chip.contains("beta"), "{chip}");
7180 assert!(!chip.to_ascii_lowercase().contains("slack"), "{chip}");
7181 }
7182
7183 #[test]
7184 fn direct_mcp_snapshot_rejects_an_unseen_older_boot_generation() {
7185 let mut app = test_app();
7186 assert_eq!(app.mcp_snapshot_generation, 0);
7187 app.mcp_snapshot = Some(snapshot(vec![server("direct", true)]));
7188 // The direct engine response carries generation 2 even though the UI
7189 // has not rendered queued boot generation 1 yet.
7190 app.mcp_snapshot_generation = 2;
7191 app.mcp_snapshot_generation_invalidated = true;
7192 app.mcp_connecting = vec!["alpha".into()];
7193 apply_mcp_session_boot_event(
7194 &mut app,
7195 1,
7196 snapshot(vec![server("stale", true)]),
7197 vec!["stale".into()],
7198 true,
7199 );
7200 assert_eq!(app.mcp_connecting, vec!["alpha"]);
7201 assert_eq!(
7202 app.mcp_snapshot
7203 .as_ref()
7204 .and_then(|snapshot| snapshot.servers.first())
7205 .map(|server| server.name.as_str()),
7206 Some("direct")
7207 );
7208
7209 // A queued event emitted by the direct operation itself is the same
7210 // generation and cannot replace the already-applied response.
7211 apply_mcp_session_boot_event(
7212 &mut app,
7213 2,
7214 snapshot(vec![server("same-pass", true)]),
7215 Vec::new(),
7216 true,
7217 );
7218 assert_eq!(
7219 app.mcp_snapshot
7220 .as_ref()
7221 .and_then(|snapshot| snapshot.servers.first())
7222 .map(|server| server.name.as_str()),
7223 Some("direct")
7224 );
7225
7226 apply_mcp_session_boot_event(
7227 &mut app,
7228 3,
7229 snapshot(vec![server("fresh", true)]),
7230 Vec::new(),
7231 true,
7232 );
7233 assert_eq!(app.mcp_snapshot_generation, 3);
7234 assert!(!app.mcp_snapshot_generation_invalidated);
7235 assert_eq!(
7236 app.mcp_snapshot
7237 .as_ref()
7238 .and_then(|snapshot| snapshot.servers.first())
7239 .map(|server| server.name.as_str()),
7240 Some("fresh")
7241 );
7242 }
7243
7244 fn translation_test_route() -> crate::cost_status::EffectiveRouteEnvelope {
7245 crate::cost_status::EffectiveRouteEnvelope {
7246 provider: crate::config::ApiProvider::Deepseek,
7247 provider_identity: "deepseek".to_string(),
7248 model: "deepseek-chat".to_string(),
7249 openrouter_vendor: None,
7250 billing_surface: crate::pricing::billing_surface_for_route(
7251 crate::config::ApiProvider::Deepseek,
7252 Some("https://api.deepseek.com/v1"),
7253 )
7254 .map(str::to_string),
7255 endpoint_fingerprint: crate::cost_status::endpoint_fingerprint(
7256 "https://api.deepseek.com/v1",
7257 ),
7258 provider_live_pricing: None,
7259 billing_mode: crate::cost_status::RouteBillingMode::Metered,
7260 dispatched_at: chrono::Utc::now(),
7261 }
7262 }
7263
7264 #[test]
7265 fn assistant_and_thinking_translation_usage_each_accrue_once() {
7266 let _scope = crate::cost_status::test_scope();
7267 let mut app = test_app();
7268 app.current_session_id = Some("session-translation".to_string());
7269 app.runtime_turn_id = Some("turn-translation".to_string());
7270 let usage_a = codewhale_models::Usage {
7271 input_tokens: 5,
7272 output_tokens: 2,
7273 ..codewhale_models::Usage::default()
7274 };
7275 let usage_b = codewhale_models::Usage {
7276 input_tokens: 3,
7277 output_tokens: 1,
7278 ..codewhale_models::Usage::default()
7279 };
7280
7281 let assistant = TranslationAccountingContext::capture(&app, "assistant", 1).settle(Ok(
7282 crate::client::TranslationProviderResponse {
7283 translated: Ok("助理".to_string()),
7284 route: translation_test_route(),
7285 usage: Some(usage_a.clone()),
7286 },
7287 ));
7288 let thinking = TranslationAccountingContext::capture(&app, "thinking", 2).settle(Ok(
7289 crate::client::TranslationProviderResponse {
7290 translated: Err(anyhow::anyhow!("incomplete: max_tokens")),
7291 route: translation_test_route(),
7292 usage: Some(usage_b.clone()),
7293 },
7294 ));
7295
7296 assert_eq!(assistant.usage.as_ref(), Some(&usage_a));
7297 assert_eq!(thinking.usage.as_ref(), Some(&usage_b));
7298 assert!(
7299 thinking.translated.is_err(),
7300 "semantic rejection is preserved"
7301 );
7302 accrue_translation_usage(&mut app, assistant.usage.as_ref().expect("assistant usage"));
7303 accrue_translation_usage(&mut app, thinking.usage.as_ref().expect("thinking usage"));
7304 assert_eq!(app.session.total_input_tokens, 8);
7305 assert_eq!(app.session.total_output_tokens, 3);
7306 assert_eq!(app.session.total_tokens, 11);
7307
7308 let pending = crate::cost_status::drain();
7309 assert_eq!(
7310 pending.priced_turns.saturating_add(pending.unpriced_turns),
7311 2,
7312 "each decoded provider response is audited exactly once"
7313 );
7314 }
7315
7316 #[test]
7317 fn translation_unreceipted_success_is_marked_once_but_transport_failure_is_not() {
7318 let _scope = crate::cost_status::test_scope();
7319 let mut app = test_app();
7320 app.current_session_id = Some("session-translation-missing-usage".to_string());
7321 app.runtime_turn_id = Some("turn-translation-missing-usage".to_string());
7322
7323 for _ in 0..2 {
7324 let settled = TranslationAccountingContext::capture(&app, "assistant", 7).settle(Ok(
7325 crate::client::TranslationProviderResponse {
7326 translated: Ok("translation remains usable".to_string()),
7327 route: translation_test_route(),
7328 usage: None,
7329 },
7330 ));
7331 assert_eq!(
7332 settled.translated.expect("semantic output remains usable"),
7333 "translation remains usable"
7334 );
7335 assert_eq!(settled.usage, None);
7336 }
7337 let transport = TranslationAccountingContext::capture(&app, "assistant", 8)
7338 .settle(Err(anyhow::anyhow!("HTTP 429")));
7339 assert!(transport.translated.is_err());
7340 assert_eq!(transport.usage, None);
7341
7342 let pending = crate::cost_status::drain();
7343 assert_eq!(pending.priced_turns, 0);
7344 assert_eq!(
7345 pending.unpriced_turns, 1,
7346 "stable response id dedupes replay"
7347 );
7348 assert_eq!(pending.cny_unpriced_turns, 1);
7349 assert!(
7350 pending
7351 .unpriced_reasons
7352 .contains("provider_success_missing_usage")
7353 );
7354 }
7355
7356 #[test]
7357 fn late_translation_delivery_isolated_from_new_session_or_turn() {
7358 let mut app = test_app();
7359 app.current_session_id = Some("session-a".to_string());
7360 app.runtime_turn_id = Some("turn-a".to_string());
7361 let (session, turn) = translation_origin(&app);
7362 assert!(translation_origin_is_current(
7363 &app,
7364 session.as_deref(),
7365 turn.as_deref()
7366 ));
7367
7368 app.current_session_id = Some("session-b".to_string());
7369 assert!(!translation_session_is_current(&app, session.as_deref()));
7370 assert!(!translation_origin_is_current(
7371 &app,
7372 session.as_deref(),
7373 turn.as_deref()
7374 ));
7375 app.current_session_id = Some("session-a".to_string());
7376 app.runtime_turn_id = Some("turn-b".to_string());
7377 assert!(
7378 translation_session_is_current(&app, session.as_deref()),
7379 "same-session late usage still belongs in session totals"
7380 );
7381 assert!(!translation_origin_is_current(
7382 &app,
7383 session.as_deref(),
7384 turn.as_deref()
7385 ));
7386
7387 let usage = codewhale_models::Usage {
7388 input_tokens: 4,
7389 output_tokens: 2,
7390 ..codewhale_models::Usage::default()
7391 };
7392 if translation_session_is_current(&app, session.as_deref()) {
7393 accrue_translation_usage(&mut app, &usage);
7394 }
7395 assert_eq!(app.session.total_tokens, 6);
7396 app.current_session_id = Some("session-b".to_string());
7397 if translation_session_is_current(&app, session.as_deref()) {
7398 accrue_translation_usage(&mut app, &usage);
7399 }
7400 assert_eq!(
7401 app.session.total_tokens, 6,
7402 "cross-session late usage must not pollute the new session"
7403 );
7404
7405 let shared_prefix = "x".repeat(300);
7406 app.current_session_id = Some(format!("{shared_prefix}:old"));
7407 app.runtime_turn_id = Some("turn-long".to_string());
7408 let (long_session, long_turn) = translation_origin(&app);
7409 app.current_session_id = Some(format!("{shared_prefix}:new"));
7410 assert!(
7411 !translation_origin_is_current(&app, long_session.as_deref(), long_turn.as_deref()),
7412 "fixed fingerprints must distinguish ids with the same long prefix"
7413 );
7414 }
7415 }
7416
7417 #[cfg(test)]
7418 mod fleet_workers_status_tests {
7419 use super::current_session_fleet_workers_status;
7420 use codewhale_localization::Locale;
7421
7422 #[test]
7423 fn current_session_fleet_worker_status_keeps_the_english_session_boundary() {
7424 assert_eq!(
7425 current_session_fleet_workers_status(Locale::En, 3),
7426 "Current-session fleet workers: 3 total"
7427 );
7428 }
7429 }
7430
7431 /// Per-tick budget for the runtime store-failure tap. These events are rare;
7432 /// the bound only keeps a burst from starving the frame.
7433 const RUNTIME_STORE_FAILURE_DRAIN_BUDGET: usize = 64;
7434
7435 /// Drain the background runtime's event tap and show every
7436 /// `runtime.store_failure` (#5931). Other runtime events keep their own
7437 /// consumers (the task timeline, SSE); this reads only the operator's fault.
7438 fn drain_runtime_store_failures(
7439 app: &mut App,
7440 rx: &mut Option<tokio::sync::broadcast::Receiver<crate::runtime_threads::RuntimeEventRecord>>,
7441 ) -> bool {
7442 use tokio::sync::broadcast::error::TryRecvError;
7443 let Some(receiver) = rx.as_mut() else {
7444 return false;
7445 };
7446 let mut shown = false;
7447 for _ in 0..RUNTIME_STORE_FAILURE_DRAIN_BUDGET {
7448 match receiver.try_recv() {
7449 Ok(event) => shown |= show_runtime_store_failure(app, &event),
7450 Err(TryRecvError::Empty) => break,
7451 Err(TryRecvError::Lagged(skipped)) => {
7452 tracing::warn!(
7453 skipped,
7454 "runtime event tap lagged; a store-failure notice may have been missed"
7455 );
7456 }
7457 Err(TryRecvError::Closed) => {
7458 *rx = None;
7459 break;
7460 }
7461 }
7462 }
7463 shown
7464 }
7465
7466 /// Show one `runtime.store_failure` event as a warning toast and a transcript
7467 /// line that names the file and the next action. Any other event is ignored.
7468 pub(crate) fn show_runtime_store_failure(
7469 app: &mut App,
7470 event: &crate::runtime_threads::RuntimeEventRecord,
7471 ) -> bool {
7472 if event.event != crate::runtime_threads::RUNTIME_STORE_FAILURE_EVENT {
7473 return false;
7474 }
7475 let notice = match serde_json::from_value::<crate::runtime_threads::RuntimeStoreFailureNotice>(
7476 event.payload.clone(),
7477 ) {
7478 Ok(notice) => notice,
7479 Err(error) => {
7480 tracing::warn!(%error, "runtime store failure notice had an unreadable payload");
7481 return false;
7482 }
7483 };
7484 let message = runtime_store_failure_notice(app, &notice);
7485 app.push_status_toast(message.clone(), StatusToastLevel::Warning, Some(12_000));
7486 app.add_message(HistoryCell::System { content: message });
7487 true
7488 }
7489
7490 /// Text for a runtime store fault: the record, the file, the root cause, and
7491 /// the remedy the failed operation calls for.
7492 pub(crate) fn runtime_store_failure_notice(
7493 app: &App,
7494 notice: &crate::runtime_threads::RuntimeStoreFailureNotice,
7495 ) -> String {
7496 use crate::runtime_threads::RuntimeStoreOperation;
7497 let id = match notice.failure.operation {
7498 RuntimeStoreOperation::Write => MessageId::RuntimeStoreUnwritableNotice,
7499 RuntimeStoreOperation::Read | RuntimeStoreOperation::Parse => {
7500 MessageId::RuntimeStoreUnreadableNotice
7501 }
7502 };
7503 app.tr(id)
7504 .replace(
7505 "{record}",
7506 &format!(
7507 "{} {}",
7508 notice.failure.record_kind, notice.failure.record_id
7509 ),
7510 )
7511 .replace("{path}", &notice.failure.path.display().to_string())
7512 .replace("{reason}", &notice.reason)
7513 }
7514
7514 lines RUST