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