返回 CodeWhale
handlers.rs
根目录 / crates / tui / src / tui / ui / handlers.rs
1 //! `handle_*` helpers: turning one input event, view event, or external
2 //! action into `App` state changes.
3 //!
4 //! Moved verbatim out of `ui.rs`.
5
6 use super::*;
7
8 /// How long the picker's ⇧F receipt stays in the footer: long enough to read
9 /// a route and its roles, short enough to leave the chrome still.
10 const FLEET_TOGGLE_TOAST_TTL_MS: u64 = 6_000;
11
12 /// Push the effective roster (saved fleet + config + plugins) to the running
13 /// engine through `Op::SetFleetRoster`. The one path every fleet mutation
14 /// takes: the saved-fleet views call it directly, and `/fleet add|remove`,
15 /// ⇧F, and auto-enroll reach it through `App::fleet_roster_stale`.
16 pub(crate) fn sync_fleet_roster(app: &mut App, config: &Config, engine_handle: &EngineHandle) {
17 let roster = crate::fleet::identity::load_effective_roster(
18 &config.fleet_config(),
19 &app.workspace,
20 Some(app.plugin_registry.as_ref()),
21 );
22 if let Some(error) = roster.load_error() {
23 app.set_sticky_status(error.to_string(), StatusToastLevel::Error, None);
24 }
25 let _ = engine_handle.try_send(Op::SetFleetRoster {
26 roster: std::sync::Arc::new(roster),
27 });
28 }
29
30 /// Refresh the Fleet roster when it is parked on top of the stack after a
31 /// store mutation (#5954).
32 ///
33 /// The roster now stays open underneath the saved-teams list, so selecting or
34 /// deleting a team has to update the view the user pops back to — otherwise
35 /// it keeps painting the pre-change team. Cursor and detail scroll survive,
36 /// because losing them is the disruption the back path exists to avoid.
37 pub(crate) fn refresh_parked_fleet_roster(app: &mut App, config: &Config) {
38 if app.view_stack.top_kind() != Some(ModalKind::FleetRoster) {
39 return;
40 }
41 let Some(mut view) = app.view_stack.pop() else {
42 return;
43 };
44 if let Some(roster) = view
45 .as_any_mut()
46 .downcast_mut::<crate::tui::views::fleet_roster::FleetRosterView>()
47 {
48 roster.reload(app, config);
49 }
50 app.view_stack.push_boxed(view);
51 }
52
53 pub(super) fn dismiss_fleet_assignment(app: &mut App, editor_id: uuid::Uuid) {
54 if let Some(mut boxed) = app.view_stack.pop() {
55 let remove = if let Some(view) = boxed
56 .as_any_mut()
57 .downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>(
58 ) {
59 view.route_selection(editor_id).is_some()
60 } else if let Some(view) = boxed
61 .as_any_mut()
62 .downcast_mut::<crate::tui::views::fleet_detail::FleetDetailView>(
63 ) {
64 view.is_direct_assignment(editor_id)
65 } else {
66 false
67 };
68 if !remove {
69 app.view_stack.push_boxed(boxed);
70 }
71 }
72 }
73
74 /// Once per event-loop iteration: deliver a pending fleet mutation to the
75 /// engine and clear the flag.
76 pub(crate) fn flush_stale_fleet_roster(
77 app: &mut App,
78 config: &Config,
79 engine_handle: &EngineHandle,
80 ) {
81 if std::mem::take(&mut app.fleet_roster_stale) {
82 sync_fleet_roster(app, config, engine_handle);
83 }
84 }
85
86 /// Persist a `# foo` quick-add through the native memory store and surface
87 /// a status note to the user. Errors land in the same status channel so a
88 /// missing memory directory becomes visible without crashing the composer.
89 pub(crate) fn handle_memory_quick_add(app: &mut App, input: &str, config: &Config) {
90 let path = config.memory_path();
91 let note = input.trim_start_matches('#').trim();
92 let result = crate::native_memory::NativeMemoryStore::from_global_path(&path)
93 .ok_or_else(|| format!("{} is not a native memory path", path.display()))
94 .and_then(|store| {
95 store
96 .remember(crate::native_memory::MemoryScope::Global, None, note)
97 .map(|hit| hit.source)
98 .map_err(|err| err.to_string())
99 });
100 match result {
101 Ok(source) => {
102 app.status_message = Some(format!("memory: appended to {}", source.display()));
103 }
104 Err(err) => {
105 app.status_message = Some(format!(
106 "memory: failed to write {}: {}",
107 path.display(),
108 err
109 ));
110 }
111 }
112 }
113
114 /// Route one terminal bracketed-paste event without exposing its contents.
115 ///
116 /// Keeping the routing in one function makes the credential and ordinary
117 /// composer paths exercise the same observability boundary.
118 pub(crate) fn handle_bracketed_paste(app: &mut App, text: &str) {
119 tracing::debug!(
120 paste_bytes = text.len(),
121 paste_chars = text.chars().count(),
122 "Received bracketed paste event"
123 );
124 // Once a real bracketed-paste event has been observed in this session,
125 // the rapid-keystroke heuristic in paste_burst is redundant — disable it
126 // so fast typing / IME commits / autocomplete bursts don't get
127 // mis-classified as a paste.
128 app.bracketed_paste_seen = true;
129 if app.is_history_search_active() {
130 app.history_search_insert_str(text);
131 } else if paste_text_into_provider_picker(app, text) || app.view_stack.handle_paste(text) {
132 // Modal consumed the paste (e.g. provider picker key entry).
133 } else if !app.view_stack.is_empty() {
134 // A non-consumed modal is open — don't leak paste into composer.
135 } else {
136 // Main-input paste takes the same keyboard ownership as typed text.
137 // Otherwise the visible composer command's Enter stays with the dock.
138 crate::tui::work_surface::release_focus(app);
139 app.insert_paste_text(text);
140 }
141 }
142
143 /// Voice input toggle via Option+V (⌥V) — matches Muse Spark UX:
144 /// "Recording (⌥V to finish)" with a transient voice indicator, no slash
145 /// command needed. Handles both Alt+V and the macOS ⌥V glyph.
146 pub(crate) fn handle_voice_key(app: &mut App, key: &event::KeyEvent) -> bool {
147 let is_alt_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'))
148 && key.modifiers.contains(KeyModifiers::ALT)
149 && !key.modifiers.contains(KeyModifiers::CONTROL)
150 && !key.modifiers.contains(KeyModifiers::SUPER);
151 // Some terminals emit the literal "√" (Option+V on macOS) instead of Alt+V.
152 let is_glyph = matches!(key.code, KeyCode::Char('√') | KeyCode::Char('∫'));
153 if !is_alt_v && !is_glyph {
154 return false;
155 }
156 // Toggle voice capture — same path as /voice but via hotkey.
157 let result = crate::commands::voice::voice(app);
158 // Surface a Spark-style transient hint; the capture itself is async.
159 if app.voice_enabled {
160 app.status_message = Some("● Recording (⌥V to finish)".to_string());
161 }
162 // Suppress the default char insertion for this combo.
163 let _ = result;
164 true
165 }
166
167 /// The event-loop seam for Ctrl+T. Keeping the `KeyEvent` predicate and App
168 /// mutation together makes the real terminal route directly testable rather
169 /// than testing `cycle_effort` in isolation.
170 pub(crate) fn handle_reasoning_effort_key(app: &mut App, key: &event::KeyEvent) -> bool {
171 if !matches!(key.code, KeyCode::Char('t') | KeyCode::Char('T'))
172 || key.modifiers != KeyModifiers::CONTROL
173 {
174 return false;
175 }
176 let _ = app.cycle_effort();
177 true
178 }
179
180 /// Let the transcript remain reviewable while a decision prompt owns focus.
181 pub(crate) fn handle_prompt_transcript_key(app: &mut App, key: &event::KeyEvent) -> bool {
182 if !matches!(
183 app.view_stack.top_kind(),
184 Some(ModalKind::Approval | ModalKind::UserInput)
185 ) {
186 return false;
187 }
188
189 let page = app.viewport.last_transcript_visible.max(1);
190 match key.code {
191 KeyCode::PageUp => app.scroll_up(page),
192 KeyCode::PageDown => app.scroll_down(page),
193 KeyCode::Up
194 if key
195 .modifiers
196 .intersects(KeyModifiers::ALT | KeyModifiers::SHIFT | KeyModifiers::CONTROL) =>
197 {
198 app.scroll_up(3);
199 }
200 KeyCode::Down
201 if key
202 .modifiers
203 .intersects(KeyModifiers::ALT | KeyModifiers::SHIFT | KeyModifiers::CONTROL) =>
204 {
205 app.scroll_down(3);
206 }
207 KeyCode::Home => app.scroll_up(usize::MAX),
208 KeyCode::End => app.scroll_to_bottom(),
209 _ => return false,
210 }
211 true
212 }
213
214 /// Route only non-text controls to a focused workflow panel.
215 ///
216 /// Returning `false` for every character is deliberate: the caller then lets
217 /// the normal composer path insert it. A prior bare-letter contract used
218 /// t/c/j/k here, which made the first matching letter of a new chat disappear
219 /// after the user clicked the workflow card.
220 pub(crate) fn handle_workflow_panel_key(app: &mut App, key: &event::KeyEvent) -> bool {
221 if !app
222 .workflow_panel
223 .as_ref()
224 .is_some_and(|panel| panel.keyboard_focus)
225 {
226 return false;
227 }
228
229 if matches!(key.code, KeyCode::Char(_)) {
230 if let Some(panel) = app.workflow_panel.as_mut() {
231 panel.keyboard_focus = false;
232 }
233 app.needs_redraw = true;
234 return false;
235 }
236
237 if !key.modifiers.is_empty() && key.code != KeyCode::Esc {
238 return false;
239 }
240
241 match key.code {
242 KeyCode::Esc => {
243 if let Some(panel) = app.workflow_panel.as_mut() {
244 panel.keyboard_focus = false;
245 }
246 app.needs_redraw = true;
247 true
248 }
249 KeyCode::Enter => {
250 if let Some(panel) = app.workflow_panel.as_mut() {
251 let _ = panel.toggle_expanded();
252 }
253 app.needs_redraw = true;
254 true
255 }
256 KeyCode::Down => {
257 if let Some(panel) = app.workflow_panel.as_mut() {
258 panel.select_next_phase();
259 }
260 app.needs_redraw = true;
261 true
262 }
263 KeyCode::Up => {
264 if let Some(panel) = app.workflow_panel.as_mut() {
265 panel.select_prev_phase();
266 }
267 app.needs_redraw = true;
268 true
269 }
270 KeyCode::Delete => {
271 let Some(run_id) = app
272 .workflow_panel
273 .as_ref()
274 .and_then(|panel| panel.lifecycle.is_running().then(|| panel.run_id.clone()))
275 else {
276 return false;
277 };
278 app.input = format!("/workflow cancel {run_id}");
279 app.cursor_position = app.input.chars().count();
280 app.status_message = Some(app.tr(MessageId::SidebarDestructiveArmed).into_owned());
281 if let Some(panel) = app.workflow_panel.as_mut() {
282 panel.keyboard_focus = false;
283 }
284 app.needs_redraw = true;
285 true
286 }
287 _ => false,
288 }
289 }
290
291 /// One-shot "draft my constitution" call against the user's first configured
292 /// model, requested by `A` on the setup Constitution card. Runs inline in the
293 /// event loop like [`fetch_available_models`] (the wizard modal stays open
294 /// underneath) with a hard timeout so a slow provider cannot wedge setup.
295 ///
296 /// On success the sanitized, bounded draft is installed into the open wizard
297 /// and its ratification preview opens on top — nothing persists until the
298 /// user ratifies with `G`. Every failure (no client, timeout, request error,
299 /// invalid or empty JSON) is a status line, never an error state: the
300 /// deterministic guided draft remains the standing fallback.
301 pub(crate) async fn handle_setup_constitution_model_draft(
302 app: &mut App,
303 config: &Config,
304 draft: crate::tui::setup::GuidedConstitutionDraft,
305 freeform_note: Option<String>,
306 locale: codewhale_localization::Locale,
307 ) {
308 // Spawn the draft off the event loop (same pattern as the fleet drafter,
309 // #3757 review): awaiting it inline parked the whole TUI for up to the
310 // timeout. The loop polls constitution_draft_cell and delivers the result.
311 const DRAFT_TIMEOUT: Duration = Duration::from_secs(20);
312 let model_label = app.model_display_label();
313 let client = match CodewhaleClient::new(config) {
314 Ok(client) => client,
315 Err(err) => {
316 deliver_constitution_draft_result(
317 app,
318 model_label.clone(),
319 locale,
320 Err(format!("provider not ready: {err:#}")),
321 );
322 return;
323 }
324 };
325 let request_model = app.model.clone();
326 let cell = app.constitution_draft_cell.clone();
327 let spawn_label = model_label.clone();
328 let request_gen = app.next_draft_gen();
329 app.status_message = Some(match locale {
330 codewhale_localization::Locale::ZhHans => {
331 format!(
332 "{model_label} 正在生成协作准则草案……(最多 {}s)",
333 DRAFT_TIMEOUT.as_secs()
334 )
335 }
336 _ => format!(
337 "{model_label} is drafting your constitution… (up to {}s)",
338 DRAFT_TIMEOUT.as_secs()
339 ),
340 });
341 app.needs_redraw = true;
342 tokio::spawn(async move {
343 let outcome = match tokio::time::timeout(
344 DRAFT_TIMEOUT,
345 crate::tui::setup::draft_constitution_with_model(
346 &client,
347 &request_model,
348 draft,
349 freeform_note,
350 locale,
351 ),
352 )
353 .await
354 {
355 Err(_) => Err(format!("timed out after {}s", DRAFT_TIMEOUT.as_secs())),
356 Ok(result) => result,
357 };
358 if let Ok(mut guard) = cell.lock() {
359 *guard = Some((request_gen, spawn_label, locale, outcome));
360 }
361 });
362 }
363
364 /// One-shot fleet-profile draft: same contract as the constitution drafter —
365 /// minimal payload out, untrusted gate in, preview before ratify, degrade to
366 /// the manual authoring flow on any failure.
367 pub(crate) async fn handle_fleet_profile_model_draft(
368 app: &mut App,
369 config: &Config,
370 role: String,
371 model: String,
372 provider: Option<String>,
373 reasoning_effort: Option<String>,
374 locale: codewhale_localization::Locale,
375 ) {
376 // The route the operator actually picked at `m`-press time (#4093). A
377 // model draft always comes back `provider: None` (the untrusted gate
378 // strips any provider), so this captured `(provider, model)` is what the
379 // ratified profile is pinned to — immune to the model omitting/altering
380 // the route AND to the selection changing while the draft is in flight.
381 // `None` for an `inherit` pick (no concrete route to keep).
382 let picked_route = provider.map(|provider| (provider, model.clone()));
383 // Do NOT await the network call on the event loop — that parks the whole
384 // TUI for up to the timeout (#3757 review). Spawn it into the shared
385 // fleet_draft_cell and let the loop poll + deliver the result, keeping
386 // the wizard interactive with a drafting status.
387 const DRAFT_TIMEOUT: Duration = Duration::from_secs(20);
388 let model_label = app.model_display_label();
389 let client = match CodewhaleClient::new(config) {
390 Ok(client) => client,
391 Err(err) => {
392 deliver_fleet_draft_result(
393 app,
394 model_label.clone(),
395 picked_route.clone(),
396 reasoning_effort.clone(),
397 Err(format!("provider not ready: {err:#}")),
398 locale,
399 );
400 return;
401 }
402 };
403 let request_model = app.model.clone();
404 let cell = app.fleet_draft_cell.clone();
405 let spawn_label = model_label.clone();
406 let request_gen = app.next_draft_gen();
407 let workspace = app.workspace.clone();
408 app.status_message = Some(match locale {
409 codewhale_localization::Locale::ZhHans => {
410 format!(
411 "{model_label} 正在起草配置……(最多 {}s)",
412 DRAFT_TIMEOUT.as_secs()
413 )
414 }
415 _ => format!(
416 "{model_label} is drafting the profile… (up to {}s)",
417 DRAFT_TIMEOUT.as_secs()
418 ),
419 });
420 app.needs_redraw = true;
421 tokio::spawn(async move {
422 // Redacted, bounded workspace fingerprint (manifest names, test
423 // commands, branch + dirty count — no contents, secrets, or absolute
424 // paths). Computed off the event loop; the untrusted-output gate on
425 // the reply is unchanged.
426 let fingerprint = tokio::task::spawn_blocking(move || {
427 crate::tui::setup::workspace_fingerprint(&workspace)
428 })
429 .await
430 .unwrap_or_default();
431 let outcome = match tokio::time::timeout(
432 DRAFT_TIMEOUT,
433 crate::tui::setup::draft_fleet_profile_with_model(
434 &client,
435 &request_model,
436 &role,
437 &model,
438 locale,
439 &fingerprint,
440 ),
441 )
442 .await
443 {
444 Err(_) => Err(format!("timed out after {}s", DRAFT_TIMEOUT.as_secs())),
445 Ok(result) => result,
446 };
447 if let Ok(mut guard) = cell.lock() {
448 *guard = Some((
449 request_gen,
450 spawn_label,
451 picked_route,
452 reasoning_effort,
453 outcome,
454 ));
455 }
456 });
457 }
458
459 pub(crate) async fn handle_bang_shell_input(
460 app: &mut App,
461 engine_handle: &EngineHandle,
462 input: &str,
463 ) -> Result<bool> {
464 let command = match shell_command_from_bang_input(input) {
465 Ok(Some(command)) => command,
466 Ok(None) => return Ok(false),
467 Err(message) => {
468 app.status_message = Some(format!("Error: {message}"));
469 return Ok(true);
470 }
471 };
472
473 // #6150: composer input never awaits a full op channel — a saturated
474 // engine reports busy instead of freezing the loop.
475 match engine_handle.tx_op.clone().try_reserve_owned() {
476 Ok(permit) => {
477 engine_handle.send_reserved_op(
478 permit,
479 Op::RunShellCommand {
480 command: command.to_string(),
481 mode: app.mode,
482 allow_shell: app.allow_shell,
483 trust_mode: app.trust_mode,
484 auto_approve: app_auto_approve_enabled(app),
485 approval_mode: app.approval_mode,
486 },
487 );
488 app.status_message = Some(format!("Shell command submitted: {command}"));
489 }
490 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
491 app.status_message =
492 Some("Engine busy — shell command not sent; try again".to_string());
493 }
494 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
495 return Err(anyhow::anyhow!("engine channel closed"));
496 }
497 }
498 Ok(true)
499 }
500
501 fn report_mcp_login(app: &mut App, message: String, level: StatusToastLevel) {
502 app.push_status_toast(message.clone(), level, Some(12_000));
503 add_mcp_message(app, message);
504 app.needs_redraw = true;
505 }
506
507 fn start_mcp_login(app: &mut App, config: &Config, name: String, scopes: Vec<String>) {
508 use crate::tui::app::{McpLoginProgress, PendingMcpLogin};
509
510 if let Some(pending) = &app.mcp_login {
511 let server = pending.server.clone();
512 report_mcp_login(
513 app,
514 app.tr(MessageId::McpLoginInProgress)
515 .replace("{server}", &server)
516 .replace("{cancel_key}", "Esc"),
517 StatusToastLevel::Info,
518 );
519 return;
520 }
521
522 let path = app.mcp_config_path.clone();
523 let workspace = app.workspace.clone();
524 let plugin_registry = Arc::clone(&app.plugin_registry);
525 let network_policy = config.network.clone().map(|network| {
526 crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
527 });
528 let callback_port = config.mcp_oauth_callback_port;
529 let callback_url = config.mcp_oauth_callback_url.clone();
530 let locale = app.ui_locale;
531 let pending = PendingMcpLogin {
532 server: name.clone(),
533 cancel: tokio_util::sync::CancellationToken::new(),
534 progress: Arc::new(std::sync::Mutex::new(None)),
535 };
536 let cancel = pending.cancel.clone();
537 let progress = Arc::clone(&pending.progress);
538 app.mcp_login = Some(pending);
539 report_mcp_login(
540 app,
541 app.tr(MessageId::McpLoginStarting)
542 .replace("{server}", &name)
543 .replace("{cancel_key}", "Esc"),
544 StatusToastLevel::Info,
545 );
546
547 tokio::spawn(async move {
548 let handshake = async {
549 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
550 &path,
551 &workspace,
552 plugin_registry.as_ref(),
553 )?;
554 let server = cfg.servers.get(&name).ok_or_else(|| {
555 anyhow::anyhow!(
556 codewhale_localization::tr(locale, MessageId::McpLoginServerNotFound)
557 .replace("{server}", &name)
558 )
559 })?;
560 crate::mcp::oauth::begin_oauth_login_for_server_tool(
561 &name,
562 server,
563 (!scopes.is_empty()).then_some(scopes),
564 callback_port,
565 callback_url.as_deref(),
566 network_policy.as_ref(),
567 )
568 .await
569 };
570 let operation = async {
571 // Bound the whole handshake as well as each guarded HTTP request.
572 // The timeout is in the background: even an unresponsive issuer
573 // cannot delay redraw, input or cancellation.
574 let login = tokio::time::timeout(Duration::from_secs(15), handshake)
575 .await
576 .with_context(|| {
577 codewhale_localization::tr(locale, MessageId::McpLoginHandshakeTimeout)
578 .into_owned()
579 })??;
580 if let Ok(mut cell) = progress.lock() {
581 *cell = Some(McpLoginProgress::AuthorizationUrl(
582 login.authorization_url().to_string(),
583 ));
584 }
585 login.finish().await
586 };
587 let outcome = tokio::select! {
588 biased;
589 () = cancel.cancelled() => return,
590 result = operation => result.map_err(|error| {
591 crate::mcp::oauth::mask_oauth_secrets(&format!("{error:#}"))
592 }),
593 };
594 if let Ok(mut cell) = progress.lock() {
595 *cell = Some(McpLoginProgress::Finished(outcome));
596 }
597 });
598 }
599
600 pub(crate) fn poll_mcp_login(app: &mut App) {
601 use crate::tui::app::McpLoginProgress;
602
603 let delivery = app.mcp_login.as_ref().and_then(|pending| {
604 pending
605 .progress
606 .try_lock()
607 .ok()
608 .and_then(|mut cell| cell.take())
609 .map(|progress| (pending.server.clone(), progress))
610 });
611 let Some((server, progress)) = delivery else {
612 return;
613 };
614 let (message, level) = match progress {
615 McpLoginProgress::AuthorizationUrl(url) => (
616 app.tr(MessageId::McpLoginBrowser)
617 .replace("{server}", &server)
618 .replace("{cancel_key}", "Esc")
619 .replace("{url}", &url),
620 StatusToastLevel::Info,
621 ),
622 McpLoginProgress::Finished(outcome) => {
623 app.mcp_login = None;
624 match outcome {
625 Ok(()) => (
626 app.tr(MessageId::McpLoginStored)
627 .replace("{server}", &server)
628 .replace("{command}", "/mcp reload"),
629 StatusToastLevel::Success,
630 ),
631 Err(error) => (
632 app.tr(MessageId::McpLoginFailed)
633 .replace("{server}", &server)
634 .replace("{error}", &error),
635 StatusToastLevel::Error,
636 ),
637 }
638 }
639 };
640 report_mcp_login(app, message, level);
641 }
642
643 pub(crate) fn handle_mcp_login_key(app: &mut App, key: &KeyEvent) -> bool {
644 if key.kind == KeyEventKind::Press && key.code == KeyCode::Esc && app.mcp_login.is_some() {
645 cancel_mcp_login(app);
646 true
647 } else {
648 false
649 }
650 }
651
652 pub(crate) fn cancel_mcp_login(app: &mut App) {
653 if let Some(pending) = app.mcp_login.take() {
654 // Drop cancels before the next input event; future writes belong only
655 // to this abandoned mailbox, even if the same server starts again.
656 let server = pending.server.clone();
657 drop(pending);
658 report_mcp_login(
659 app,
660 app.tr(MessageId::McpLoginCancelled)
661 .replace("{server}", &server),
662 StatusToastLevel::Info,
663 );
664 }
665 }
666
667 pub(crate) async fn handle_mcp_ui_action(
668 app: &mut App,
669 engine_handle: &EngineHandle,
670 config: &Config,
671 action: crate::tui::app::McpUiAction,
672 ) {
673 use crate::mcp::{self, McpWriteStatus};
674
675 let path = app.mcp_config_path.clone();
676 let mut changed = false;
677 let mut message = None;
678 let is_reload = matches!(&action, crate::tui::app::McpUiAction::Reload);
679 // A reload already running owns the live surface, and starting a second
680 // pass restarts every server the first one is still connecting. `Extensions`
681 // rows read `[connecting]` while that happens and answer no key, so a user
682 // who presses Enter again gets another full reconnect and another receipt —
683 // four presses became four overlapping 12-server reloads and a wall of
684 // duplicate notes. The flag was already tracked; nothing ever read it.
685 if is_reload && app.mcp_reload_in_flight {
686 add_mcp_message(app, app.tr(MessageId::McpReloadAlreadyRunning).into_owned());
687 return;
688 }
689 let retry_name = match &action {
690 crate::tui::app::McpUiAction::Retry { name } => Some(name.clone()),
691 _ => None,
692 };
693 let snapshot_live_pool = matches!(&action, crate::tui::app::McpUiAction::Show);
694 let discover = mcp_ui_action_refreshes_discovery(&action);
695
696 let approve_import = matches!(&action, crate::tui::app::McpUiAction::ImportApprove { .. });
697 let action_result = match action {
698 crate::tui::app::McpUiAction::Diagnose { name } => {
699 let receipt = mcp_server_diagnosis(app, &name);
700 report_mcp_login(app, receipt, StatusToastLevel::Info);
701 return;
702 }
703 crate::tui::app::McpUiAction::Show => Ok(()),
704 crate::tui::app::McpUiAction::Init { force } => {
705 changed = true;
706 match mcp::init_config(&path, force) {
707 Ok(McpWriteStatus::Created) => {
708 message = Some(format!("Created MCP config at {}", path.display()));
709 Ok(())
710 }
711 Ok(McpWriteStatus::Overwritten) => {
712 message = Some(format!("Overwrote MCP config at {}", path.display()));
713 Ok(())
714 }
715 Ok(McpWriteStatus::SkippedExists) => {
716 changed = false;
717 message = Some(format!(
718 "MCP config already exists at {} (use /mcp init --force to overwrite)",
719 path.display()
720 ));
721 Ok(())
722 }
723 Err(err) => Err(err),
724 }
725 }
726 crate::tui::app::McpUiAction::AddStdio {
727 name,
728 command,
729 args,
730 } => {
731 changed = true;
732 mcp::add_server_config(&path, name.clone(), Some(command), None, args, None)
733 .map(|()| message = Some(format!("Added MCP stdio server '{name}'")))
734 }
735 crate::tui::app::McpUiAction::AddHttp {
736 name,
737 url,
738 transport,
739 } => {
740 changed = true;
741 mcp::add_server_config(&path, name.clone(), None, Some(url), Vec::new(), transport)
742 .map(|()| message = Some(format!("Added MCP HTTP/SSE server '{name}'")))
743 }
744 crate::tui::app::McpUiAction::Enable { name } => {
745 changed = true;
746 mcp::set_server_enabled(&path, &name, true)
747 .map(|()| message = Some(format!("Enabled MCP server '{name}'")))
748 }
749 crate::tui::app::McpUiAction::Disable { name } => {
750 changed = true;
751 mcp::set_server_enabled(&path, &name, false)
752 .map(|()| message = Some(format!("Disabled MCP server '{name}'")))
753 }
754 crate::tui::app::McpUiAction::Remove { name } => {
755 changed = true;
756 mcp::remove_server_config(&path, &name)
757 .map(|()| message = Some(format!("Removed MCP server '{name}'")))
758 }
759 crate::tui::app::McpUiAction::Login { name, scopes } => {
760 start_mcp_login(app, config, name, scopes);
761 // Login owns its background discovery. Do not start a second
762 // discovery here or await network work on the input loop.
763 return;
764 }
765 crate::tui::app::McpUiAction::Logout { name } => {
766 let result = (|| {
767 let cfg = mcp::load_config_with_workspace_and_plugins(
768 &path,
769 &app.workspace,
770 app.plugin_registry.as_ref(),
771 )?;
772 let server = cfg
773 .servers
774 .get(&name)
775 .ok_or_else(|| anyhow::anyhow!("MCP server '{name}' not found"))?;
776 mcp::oauth::delete_oauth_tokens_for_server(&name, server)
777 })();
778 result.map(|deleted| {
779 changed = deleted;
780 message = Some(if deleted {
781 format!(
782 "Deleted locally stored OAuth credentials for MCP server '{name}'. That clears this machine only — the provider may keep its grant; the next /mcp login re-prompts for consent. Run /mcp reload to reconnect."
783 )
784 } else {
785 format!("No stored OAuth credentials found for MCP server '{name}'.")
786 });
787 })
788 }
789 crate::tui::app::McpUiAction::ImportList => {
790 let path = path.clone();
791 let workspace = app.workspace.clone();
792 let plugins = app.plugin_registry.clone();
793 #[cfg(test)]
794 let ticket = crate::test_support::env_scope_ticket();
795 match tokio::task::spawn_blocking(move || {
796 #[cfg(test)]
797 let _membership = crate::test_support::join_env_scope(ticket);
798 mcp_external_import_status_text(&workspace, &path, plugins.as_ref())
799 })
800 .await
801 {
802 Ok(text) => {
803 message = Some(text);
804 Ok(())
805 }
806 Err(_) => Err(anyhow::anyhow!("MCP import preview failed")),
807 }
808 }
809 crate::tui::app::McpUiAction::ImportApprove { name }
810 | crate::tui::app::McpUiAction::ImportDecline { name } => {
811 let approve = approve_import;
812 let path = path.clone();
813 let workspace = app.workspace.clone();
814 let plugins = app.plugin_registry.clone();
815 #[cfg(test)]
816 let ticket = crate::test_support::env_scope_ticket();
817 match tokio::task::spawn_blocking(move || {
818 #[cfg(test)]
819 let _membership = crate::test_support::join_env_scope(ticket);
820 mcp_import_apply(&workspace, &path, plugins.as_ref(), &name, approve)
821 })
822 .await
823 {
824 Ok(Ok(msg)) => {
825 changed = approve;
826 message = Some(msg);
827 Ok(())
828 }
829 Ok(Err(err)) => Err(err),
830 Err(_) => Err(anyhow::anyhow!("MCP import failed")),
831 }
832 }
833 crate::tui::app::McpUiAction::Validate | crate::tui::app::McpUiAction::Reload => Ok(()),
834 crate::tui::app::McpUiAction::Retry { .. } => Ok(()),
835 };
836
837 if let Err(err) = action_result {
838 add_mcp_message(app, format!("MCP action failed: {err}"));
839 return;
840 }
841
842 if changed {
843 app.mcp_reload_required = true;
844 }
845 if let Some(message) = message {
846 add_mcp_message(app, message);
847 }
848
849 // Every branch below is an engine round-trip, and the engine services ops
850 // only between turns (`Engine::run` runs a turn inline and never polls
851 // `rx_op` mid-turn): awaiting one from this UI path parked every keypress
852 // and repaint behind the running turn — a full console freeze (#6159).
853 // While a turn (or its compaction work) owns the engine, serve the last
854 // known snapshot and say so; mutations name the deferral instead of
855 // freezing. `reject_inline_inference_while_runtime_chat_owns_run`
856 // (apply.rs) is the same fail-closed rule for inline inference.
857 let engine_busy = app.is_loading
858 || app.dispatch_in_flight
859 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
860 || app.is_compacting
861 || app.manual_compaction_queued;
862 if engine_busy && (retry_name.is_some() || snapshot_live_pool || is_reload || changed) {
863 if snapshot_live_pool {
864 match app.mcp_snapshot.clone() {
865 Some(snapshot) => {
866 app.mcp_configured_count = snapshot.servers.len();
867 app.mcp_snapshot = Some(snapshot);
868 app.mcp_initializing = false;
869 app.mcp_connecting.clear();
870 app.hotbar_actions
871 .replace_mcp_tools(app.mcp_snapshot.as_ref());
872 add_mcp_message(
873 app,
874 app.tr(MessageId::McpShowCachedWhileTurnRuns).into_owned(),
875 );
876 open_mcp_extensions(app);
877 }
878 None => add_mcp_message(
879 app,
880 app.tr(MessageId::McpShowUnavailableWhileTurnRuns)
881 .into_owned(),
882 ),
883 }
884 } else if let Some(name) = retry_name.as_deref() {
885 add_mcp_message(
886 app,
887 app.tr(MessageId::McpRetryDeferredWhileTurnRuns)
888 .replace("{server}", name),
889 );
890 } else {
891 add_mcp_message(
892 app,
893 app.tr(MessageId::McpLivePoolRefreshDeferredWhileTurnRuns)
894 .into_owned(),
895 );
896 }
897 return;
898 }
899
900 // A successful MCP mutation is an explicit request to change the tools
901 // available to this running session. Apply it to the engine-owned pool in
902 // the same operation instead of leaving Extensions and `/mcp` users on a
903 // second, easy-to-miss reload step. The standalone reload action remains
904 // the retry/compatibility path for externally edited configuration.
905 let rebuild_live_pool = is_reload || changed;
906 let snapshot_result = if let Some(name) = retry_name.as_deref() {
907 engine_handle
908 .retry_mcp_server(name)
909 .await
910 .map(|update| (update.snapshot, Some(update.generation)))
911 } else if snapshot_live_pool {
912 engine_handle
913 .bootstrap_mcp()
914 .await
915 .map(|update| (update.snapshot, Some(update.generation)))
916 } else if rebuild_live_pool {
917 match engine_handle.reload_mcp(path.clone()).await {
918 Ok(update) => {
919 // The reload no longer waits for the connect batch. The
920 // engine's supervised pass owns the live surface from here:
921 // apply the interim snapshot without invalidating its own
922 // generation, leave connecting/initializing to the pass's
923 // progress events, and let its finished event post the
924 // counts. A config mutation keeps its own receipt instead of
925 // the reload-started line.
926 app.mcp_reload_required = false;
927 app.mcp_reload_in_flight = true;
928 if is_reload {
929 add_mcp_message(
930 app,
931 format!(
932 "MCP reload started in the background: {} configured server(s) reconnecting. The status bar tracks progress; the next model turn uses the catalog as it settles.",
933 update.snapshot.servers.len()
934 ),
935 );
936 }
937 app.mcp_configured_count = update.snapshot.servers.len();
938 app.mcp_snapshot_generation = update.generation;
939 app.mcp_snapshot_generation_invalidated = false;
940 app.hotbar_actions.replace_mcp_tools(Some(&update.snapshot));
941 app.mcp_snapshot = Some(update.snapshot);
942 open_mcp_extensions(app);
943 return;
944 }
945 Err(error) => {
946 app.mcp_reload_required = true;
947 Err(error)
948 }
949 }
950 } else if discover {
951 let network_policy = config.network.clone().map(|toml_cfg| {
952 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
953 });
954 mcp::discover_manager_snapshot_with_workspace_and_plugins(
955 &path,
956 &app.workspace,
957 network_policy,
958 app.mcp_reload_required,
959 std::sync::Arc::clone(&app.plugin_registry),
960 )
961 .await
962 .map(|snapshot| (snapshot, None))
963 } else {
964 mcp::manager_snapshot_from_config_with_workspace_and_plugins(
965 &path,
966 &app.workspace,
967 app.mcp_reload_required,
968 app.plugin_registry.as_ref(),
969 )
970 .map(|snapshot| (snapshot, None))
971 };
972
973 match snapshot_result {
974 Ok((snapshot, generation)) => {
975 if discover {
976 add_mcp_message(
977 app,
978 "MCP discovery refreshed for the UI. Run /mcp reload after config or credential edits to rebuild the live model-visible tool pool.".to_string(),
979 );
980 }
981 // Keep the boot-time MCP-count chip in sync with the live
982 // snapshot so footers and panels reflect post-/mcp edits
983 // (#502).
984 app.mcp_configured_count = snapshot.servers.len();
985 if let Some(generation) = generation {
986 app.mcp_snapshot_generation = generation;
987 app.mcp_snapshot_generation_invalidated = true;
988 }
989 app.mcp_snapshot = Some(snapshot.clone());
990 app.mcp_initializing = false;
991 app.mcp_connecting.clear();
992 // #2068: keep the hotbar's MCP-tool actions in sync with the tools
993 // that are actually loaded; the hotbar never connects on its own.
994 app.hotbar_actions.replace_mcp_tools(Some(&snapshot));
995 open_mcp_extensions(app);
996 }
997 Err(err) if retry_name.is_some() => add_mcp_message(
998 app,
999 format!("MCP server retry failed; the live tool pool is unchanged: {err}"),
1000 ),
1001 Err(err) if rebuild_live_pool => add_mcp_message(
1002 app,
1003 format!("MCP reload failed; the live tool pool is unchanged: {err}"),
1004 ),
1005 Err(err) => add_mcp_message(app, format!("MCP snapshot failed: {err}")),
1006 }
1007 }
1008
1009 pub(crate) fn handle_shell_job_action(app: &mut App, action: crate::tui::app::ShellJobAction) {
1010 let Some(shell_manager) = app.runtime_services.shell_manager.clone() else {
1011 add_shell_job_message(app, "No shell session is active.".to_string());
1012 return;
1013 };
1014
1015 let mut manager = match shell_manager.lock() {
1016 Ok(manager) => manager,
1017 Err(_) => {
1018 add_shell_job_message(
1019 app,
1020 "Shell tracking hit an internal error — restart Codewhale to recover.".to_string(),
1021 );
1022 return;
1023 }
1024 };
1025 let active_session_id = app.current_session_id.clone().unwrap_or_default();
1026
1027 match action {
1028 crate::tui::app::ShellJobAction::List => {
1029 let jobs = manager.list_jobs_for_session(&active_session_id);
1030 let mut text = format_shell_job_list(&jobs);
1031 if let Ok(cloud) =
1032 crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.list())
1033 && !cloud.is_empty()
1034 {
1035 text.push_str("\n\n");
1036 text.push_str(&crate::cloud_dispatch::format_job_list(&cloud));
1037 }
1038 add_shell_job_message(app, text);
1039 }
1040 crate::tui::app::ShellJobAction::Show { id } => {
1041 match manager.inspect_job_for_session(&active_session_id, &id) {
1042 Ok(detail) => open_shell_job_pager(app, &detail),
1043 Err(err) => add_shell_job_message(app, format!("Command lookup failed: {err}")),
1044 }
1045 }
1046 crate::tui::app::ShellJobAction::Poll { id, wait } => {
1047 match manager.poll_delta_for_session(
1048 &active_session_id,
1049 &id,
1050 wait,
1051 if wait { 5_000 } else { 1_000 },
1052 ) {
1053 Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)),
1054 Err(err) => add_shell_job_message(app, format!("Command poll failed: {err}")),
1055 }
1056 }
1057 crate::tui::app::ShellJobAction::SendStdin { id, input, close } => {
1058 match manager.write_stdin_for_session(&active_session_id, &id, &input, close) {
1059 Ok(()) => {
1060 match manager.poll_delta_for_session(&active_session_id, &id, false, 1_000) {
1061 Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)),
1062 Err(err) => {
1063 add_shell_job_message(
1064 app,
1065 format!("Command input sent; poll failed: {err}"),
1066 );
1067 }
1068 }
1069 }
1070 Err(err) => add_shell_job_message(app, format!("Command input failed: {err}")),
1071 }
1072 }
1073 crate::tui::app::ShellJobAction::Cancel { id } => {
1074 match manager.kill_for_session(&active_session_id, &id) {
1075 Ok(result) => add_shell_job_message(app, format_shell_poll(&result)),
1076 Err(err) => add_shell_job_message(app, format!("Command cancel failed: {err}")),
1077 }
1078 }
1079 crate::tui::app::ShellJobAction::CancelAll => {
1080 match manager.kill_running_for_session(&active_session_id) {
1081 Ok(results) => {
1082 let count = results.len();
1083 if count == 0 {
1084 add_shell_job_message(app, "No running commands to cancel.".to_string());
1085 } else {
1086 let tasks: Vec<String> = results
1087 .iter()
1088 .filter_map(|result| result.task_id.clone())
1089 .collect();
1090 add_shell_job_message(
1091 app,
1092 format!("Canceled {count} command(s): {}", tasks.join(", ")),
1093 );
1094 }
1095 }
1096 Err(err) => add_shell_job_message(app, format!("Command cancel-all failed: {err}")),
1097 }
1098 }
1099 }
1100 }
1101
1102 pub(crate) async fn handle_skill_mutation_requested(
1103 app: &mut App,
1104 request: crate::skills::mutation::SkillMutationRequest,
1105 ) {
1106 use crate::skills::install::{DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL};
1107 use crate::skills::mutation::{MutationContext, SkillMutationOutcome, SkillMutationRequest};
1108
1109 let focus = match &request {
1110 SkillMutationRequest::ImportExternal { source_id, .. } => Some(source_id.clone()),
1111 SkillMutationRequest::Update { skill_id, .. }
1112 | SkillMutationRequest::Remove { skill_id, .. }
1113 | SkillMutationRequest::Trust { skill_id, .. } => Some(skill_id.clone()),
1114 SkillMutationRequest::InstallRemote { .. }
1115 | SkillMutationRequest::UpdateByName { .. }
1116 | SkillMutationRequest::RemoveByName { .. }
1117 | SkillMutationRequest::TrustByName { .. } => None,
1118 };
1119
1120 let workspace = app.workspace.clone();
1121 let home = crate::config::effective_home_dir();
1122 let cfg = crate::config::Config::load(None, None).unwrap_or_default();
1123 let network = cfg
1124 .network
1125 .clone()
1126 .map(|policy| policy.into_runtime())
1127 .unwrap_or_default();
1128 let skills_cfg = cfg.skills.as_ref();
1129 let max_size = skills_cfg
1130 .and_then(|s| s.max_install_size_bytes)
1131 .unwrap_or(DEFAULT_MAX_SIZE_BYTES);
1132 let registry_url = skills_cfg
1133 .and_then(|s| s.registry_url.clone())
1134 .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
1135
1136 let skills_dir = app.skills_dir.clone();
1137 let result = {
1138 let ctx = MutationContext {
1139 workspace: &workspace,
1140 home: home.as_deref(),
1141 configured_skills_dir: Some(skills_dir.as_path()),
1142 network: &network,
1143 max_size,
1144 registry_url: &registry_url,
1145 };
1146 crate::skills::mutation::execute(request, &ctx).await
1147 };
1148
1149 let (status, refresh_skills) = match result {
1150 Ok(receipt) => {
1151 let msg = match &receipt.outcome {
1152 SkillMutationOutcome::Installed => {
1153 format!(
1154 "Installed '{}' → {}",
1155 receipt.name, receipt.safe_target_path
1156 )
1157 }
1158 SkillMutationOutcome::Updated => format!("Updated '{}'", receipt.name),
1159 SkillMutationOutcome::NoChange => {
1160 format!("'{}': no upstream change", receipt.name)
1161 }
1162 SkillMutationOutcome::Removed => format!("Removed '{}'", receipt.name),
1163 SkillMutationOutcome::Trusted => format!("Trusted '{}'", receipt.name),
1164 SkillMutationOutcome::Imported => {
1165 format!("Imported '{}' → {}", receipt.name, receipt.safe_target_path)
1166 }
1167 SkillMutationOutcome::AlreadyPresent => {
1168 format!("'{}' already present (exact duplicate)", receipt.name)
1169 }
1170 SkillMutationOutcome::NeedsApproval(host) => {
1171 format!("Needs network approval for {host}")
1172 }
1173 SkillMutationOutcome::NetworkDenied(host) => {
1174 format!("Network denied for {host}")
1175 }
1176 };
1177 let refresh = !matches!(
1178 receipt.outcome,
1179 SkillMutationOutcome::NeedsApproval(_) | SkillMutationOutcome::NetworkDenied(_)
1180 );
1181 (msg, refresh)
1182 }
1183 Err(err) => (format!("Skill mutation failed: {err:#}"), false),
1184 };
1185
1186 app.status_message = Some(status.clone());
1187 if refresh_skills {
1188 app.refresh_skill_cache();
1189 }
1190 refresh_skills_manager_if_open(app, Some(status), focus.as_ref());
1191 app.needs_redraw = true;
1192 }
1193
1194 #[allow(clippy::too_many_arguments)]
1195 pub(crate) async fn handle_config_updated(
1196 terminal: &mut AppTerminal,
1197 app: &mut App,
1198 config: &mut Config,
1199 task_manager: &SharedTaskManager,
1200 engine_handle: &mut EngineHandle,
1201 key: String,
1202 value: String,
1203 persist: bool,
1204 ) -> Result<bool> {
1205 let result = prepare_config_update_result(
1206 commands::set_config_value(app, &key, &value, persist),
1207 persist,
1208 );
1209 let telemetry_toast = (key == "telemetry")
1210 .then(|| {
1211 result.message.clone().map(|message| {
1212 let level = if result.is_error {
1213 StatusToastLevel::Error
1214 } else {
1215 StatusToastLevel::Success
1216 };
1217 (message, level)
1218 })
1219 })
1220 .flatten();
1221 let normalized_value = value.trim().to_ascii_lowercase().replace([' ', '_'], "-");
1222 let cleared_root_approval = !result.is_error
1223 && persist
1224 && key == "approval_policy"
1225 && matches!(
1226 normalized_value.as_str(),
1227 "default" | "tui-default" | "use-tui-default"
1228 );
1229 // Theme / background changes require a full terminal repaint because
1230 // ratatui's incremental diff cannot see colors remapped by the backend.
1231 if matches!(
1232 key.as_str(),
1233 "theme" | "ui_theme" | "background_color" | "background" | "bg"
1234 ) {
1235 app.force_next_full_repaint = true;
1236 }
1237 if apply_command_result(terminal, app, engine_handle, task_manager, config, result).await? {
1238 return Ok(true);
1239 }
1240
1241 let focus_key = if cleared_root_approval {
1242 "permission_posture"
1243 } else {
1244 &key
1245 };
1246 refresh_config_view_if_open(app, focus_key);
1247 if let Some((message, level)) = telemetry_toast {
1248 // The modal stays open, so a transcript-only command receipt would be
1249 // invisible. Keep the durable disk truth in the rebuilt row and show
1250 // the localized result above it.
1251 app.push_status_toast(message, level, Some(12_000));
1252 }
1253 Ok(false)
1254 }
1255
1256 #[allow(clippy::too_many_arguments)]
1257 async fn handle_theme_selection_updated(
1258 terminal: &mut AppTerminal,
1259 app: &mut App,
1260 config: &mut Config,
1261 task_manager: &SharedTaskManager,
1262 engine_handle: &mut EngineHandle,
1263 theme: String,
1264 persist: bool,
1265 ) -> Result<bool> {
1266 let result = prepare_config_update_result(
1267 commands::set_config_value(app, "theme", &theme, persist),
1268 persist,
1269 );
1270 // The theme owns the shell paint and must bypass ratatui's incremental
1271 // cell diff, including an Esc rollback.
1272 app.force_next_full_repaint = true;
1273 if apply_command_result(terminal, app, engine_handle, task_manager, config, result).await? {
1274 return Ok(true);
1275 }
1276 refresh_config_view_if_open(app, "theme");
1277 Ok(false)
1278 }
1279
1280 #[allow(clippy::too_many_arguments)]
1281 pub(crate) async fn handle_view_events(
1282 terminal: &mut AppTerminal,
1283 app: &mut App,
1284 config: &mut Config,
1285 task_manager: &SharedTaskManager,
1286 engine_handle: &mut EngineHandle,
1287 events: Vec<ViewEvent>,
1288 ) -> Result<bool> {
1289 for event in events {
1290 match event {
1291 ViewEvent::CommandPaletteSelected { action } => match action {
1292 crate::tui::views::CommandPaletteAction::ExecuteCommand { command } => {
1293 if execute_command_input(
1294 terminal,
1295 app,
1296 engine_handle,
1297 task_manager,
1298 config,
1299 &command,
1300 )
1301 .await?
1302 {
1303 return Ok(true);
1304 }
1305 }
1306 crate::tui::views::CommandPaletteAction::InsertText { text } => {
1307 app.input = text;
1308 app.cursor_position = app.input.chars().count();
1309 app.status_message = Some(
1310 "Inserted into composer. Finish the input or press Enter.".to_string(),
1311 );
1312 }
1313 crate::tui::views::CommandPaletteAction::OpenTextPager { title, content } => {
1314 open_text_pager(app, title, content);
1315 }
1316 },
1317 ViewEvent::ExecutePanelCommand {
1318 command,
1319 pager_title,
1320 } => {
1321 // The Extensions panel stays open for this command. Inspect
1322 // rows divert their text output into a pager stacked on the
1323 // panel instead of a transcript dump; mutations keep their
1324 // transcript receipt either way.
1325 let mut result = crate::commands::execute(&command, app);
1326 if let Some(title) = pager_title
1327 && let Some(text) = result.message.take()
1328 {
1329 open_text_pager(app, title, text);
1330 }
1331 if apply_command_result(terminal, app, engine_handle, task_manager, config, result)
1332 .await?
1333 {
1334 return Ok(true);
1335 }
1336 // The row the user just changed re-reads live state, and so
1337 // does every sibling — a plugin enable, an MCP retry, or an
1338 // install lands on the still-open list instead of leaving it
1339 // stale until reopen.
1340 let snapshot = crate::tui::views::extensions::ExtensionsSnapshot::from_app(app);
1341 app.view_stack.refresh_extensions(snapshot);
1342 }
1343 ViewEvent::RefreshExtensions {
1344 mcp_generation,
1345 mcp_initializing,
1346 } => {
1347 // Bounded poll from the open panel: rebuild only when the
1348 // MCP generation or the initializing flag moved past what
1349 // the panel's snapshot last saw.
1350 if app.view_stack.extensions_is_top()
1351 && (mcp_generation != app.mcp_snapshot_generation
1352 || app.mcp_snapshot_generation_invalidated
1353 || mcp_initializing != app.mcp_initializing)
1354 {
1355 let snapshot = crate::tui::views::extensions::ExtensionsSnapshot::from_app(app);
1356 app.view_stack.refresh_extensions(snapshot);
1357 }
1358 }
1359 ViewEvent::OpenTextPager { title, content } => {
1360 open_text_pager(app, title, content);
1361 }
1362 ViewEvent::CopyToClipboard { text, label } => {
1363 if text.is_empty() {
1364 app.status_message = Some(format!("{label} is empty"));
1365 } else if app.clipboard.write_text(&text).is_ok() {
1366 app.status_message = Some(format!("{label} copied"));
1367 } else {
1368 app.status_message = Some(format!("Copy failed ({label})"));
1369 }
1370 }
1371 ViewEvent::ApprovalDecision {
1372 tool_id,
1373 tool_name,
1374 decision,
1375 timed_out,
1376 approval_key,
1377 approval_grouping_key,
1378 persistent_rules,
1379 } => {
1380 apply_approval_decision(
1381 app,
1382 engine_handle,
1383 config,
1384 ApprovalDecisionEvent {
1385 tool_id,
1386 tool_name,
1387 decision,
1388 timed_out,
1389 approval_key,
1390 approval_grouping_key,
1391 persistent_rules,
1392 },
1393 )
1394 .await;
1395
1396 if timed_out {
1397 app.add_message(HistoryCell::System {
1398 content: app.tr(MessageId::ApprovalTimedOutDenied).into_owned(),
1399 });
1400 }
1401 }
1402 ViewEvent::ElevationDecision {
1403 tool_id,
1404 tool_name,
1405 option,
1406 } => {
1407 use crate::tui::approval::ElevationOption;
1408 let result = match option {
1409 ElevationOption::Abort => {
1410 app.add_message(HistoryCell::System {
1411 content: format!("Sandbox elevation aborted for {tool_name}"),
1412 });
1413 engine_handle.deny_tool_call(tool_id.clone()).await
1414 }
1415 ElevationOption::WithNetwork => {
1416 app.add_message(HistoryCell::System {
1417 content: format!("Retrying {tool_name} with network access enabled"),
1418 });
1419 let policy = option.to_policy(&app.workspace);
1420 engine_handle
1421 .retry_tool_with_policy(tool_id.clone(), policy)
1422 .await
1423 }
1424 ElevationOption::WithWriteAccess(_) => {
1425 app.add_message(HistoryCell::System {
1426 content: format!("Retrying {tool_name} with write access enabled"),
1427 });
1428 let policy = option.to_policy(&app.workspace);
1429 engine_handle
1430 .retry_tool_with_policy(tool_id.clone(), policy)
1431 .await
1432 }
1433 ElevationOption::FullAccess => {
1434 app.add_message(HistoryCell::System {
1435 content: format!("Retrying {tool_name} with full access (no sandbox)"),
1436 });
1437 let policy = option.to_policy(&app.workspace);
1438 engine_handle
1439 .retry_tool_with_policy(tool_id.clone(), policy)
1440 .await
1441 }
1442 };
1443 if result.is_ok() {
1444 app.retire_action_notices(Some(&tool_id));
1445 }
1446 }
1447 ViewEvent::UserInputSubmitted { tool_id, response } => {
1448 let result = engine_handle
1449 .submit_user_input(tool_id.clone(), response)
1450 .await;
1451 apply_user_input_submission_result(app, &tool_id, result);
1452 }
1453 ViewEvent::UserInputCancelled { tool_id } => {
1454 if engine_handle
1455 .cancel_user_input(tool_id.clone())
1456 .await
1457 .is_ok()
1458 {
1459 settle_user_input_request(app, &tool_id);
1460 }
1461 app.add_message(HistoryCell::System {
1462 content: "User input cancelled".to_string(),
1463 });
1464 }
1465 ViewEvent::SessionSelected { session_id } => {
1466 let manager = match SessionManager::default_location() {
1467 Ok(manager) => manager,
1468 Err(err) => {
1469 app.status_message =
1470 Some(format!("Failed to open sessions directory: {err}"));
1471 continue;
1472 }
1473 };
1474
1475 match manager.resume_session(&session_id) {
1476 Ok(recovery) => {
1477 let session = recovery.session;
1478 let next_config = config.clone();
1479 let respawn = match apply_loaded_session_config_snapshot(
1480 app,
1481 config,
1482 &session,
1483 next_config,
1484 false,
1485 ) {
1486 Ok(outcome) => outcome,
1487 Err(err) => {
1488 crate::tui::ui::session_state::surface_session_load_failure(
1489 app,
1490 format!("Failed to restore session: {err}"),
1491 );
1492 continue;
1493 }
1494 };
1495 sync_runtime_workspace_state(task_manager, app.workspace.clone()).await;
1496 // #6150 audit: these sends may await a full op channel,
1497 // and that await is load-bearing — the session switch
1498 // is already committed UI-side, so each op must land in
1499 // order (drop = engine/UI desync). A wedge is possible
1500 // only while a saturated engine finishes its turn.
1501 if respawn {
1502 let _ = engine_handle.send(Op::Shutdown).await;
1503 *engine_handle =
1504 spawn_tui_engine(build_engine_config(app, config), config);
1505 } else {
1506 let _ = engine_handle
1507 .send(Op::SetModel {
1508 model: app.model.clone(),
1509 mode: app.mode,
1510 route_limits: app.active_route_limits,
1511 })
1512 .await;
1513 }
1514 let _ = engine_handle
1515 .send(Op::SyncSession {
1516 session_id: app.current_session_id.clone(),
1517 messages: app.api_messages.as_ref().clone(),
1518 system_prompt: app.system_prompt.clone(),
1519 system_prompt_override: false,
1520 model: app.model.clone(),
1521 workspace: app.workspace.clone(),
1522 mode: app.mode,
1523 })
1524 .await;
1525 let _ = engine_handle
1526 .send(Op::SetCompaction {
1527 config: app.compaction_config(),
1528 })
1529 .await;
1530 // Durable receipt, matching `/load`: the status toast
1531 // alone is replaced by the next footer update, leaving
1532 // no findable record that the resume happened.
1533 let loaded_message = format!(
1534 "Session loaded (ID: {}, {} messages)",
1535 crate::session_manager::truncate_id(&session_id),
1536 session.metadata.message_count
1537 );
1538 app.add_message(HistoryCell::System {
1539 content: loaded_message.clone(),
1540 });
1541 app.status_message = Some(loaded_message);
1542 app.launch.dismiss();
1543 app.launch.status = None;
1544 }
1545 Err(err) => {
1546 crate::tui::ui::session_state::surface_session_load_failure(
1547 app,
1548 format!(
1549 "Failed to load session {}: {err}",
1550 crate::session_manager::truncate_id(&session_id)
1551 ),
1552 );
1553 }
1554 }
1555 }
1556 ViewEvent::SessionRenamed { metadata } => {
1557 let session_id = metadata.id.clone();
1558 let title = metadata.title.clone();
1559 let mut work_snapshot_warning = None;
1560 if apply_picker_session_rename_to_active_app(app, *metadata)
1561 && let Ok(manager) = SessionManager::default_location()
1562 {
1563 match build_session_snapshot(app, &manager) {
1564 Ok(session) => {
1565 if let Err(err) = persist_with_pending_work_boundary(
1566 app,
1567 PersistRequest::SessionSnapshot(session),
1568 ) {
1569 tracing::warn!(
1570 session_id = %session_id,
1571 error = %err,
1572 "Could not queue active session rename Work snapshot"
1573 );
1574 work_snapshot_warning = Some(format!(
1575 "Session renamed, but Work snapshot is pending ({err})"
1576 ));
1577 }
1578 }
1579 Err(err) => {
1580 tracing::warn!(
1581 session_id = %session_id,
1582 error = %err,
1583 "Could not queue active session rename snapshot"
1584 );
1585 }
1586 }
1587 }
1588 app.status_message = Some(work_snapshot_warning.unwrap_or_else(|| {
1589 format!(
1590 "Renamed session {} to \"{}\"",
1591 crate::session_manager::truncate_id(&session_id),
1592 title
1593 )
1594 }));
1595 }
1596 ViewEvent::SessionArchived { metadata } => {
1597 // The manager already wrote the flag. Keep the active app's
1598 // cached metadata in step so the next autosave carries the new
1599 // state forward instead of reverting it, and drop the rail
1600 // cache so the row disappears (or returns) immediately.
1601 if let Some(cached) = app.current_session_metadata.as_mut()
1602 && cached.id == metadata.id
1603 {
1604 cached.archived = metadata.archived;
1605 }
1606 app.status_message = Some(format!(
1607 "{} session {} ({})",
1608 if metadata.archived {
1609 "Archived"
1610 } else {
1611 "Restored"
1612 },
1613 crate::session_manager::truncate_id(&metadata.id),
1614 metadata.title
1615 ));
1616 }
1617 ViewEvent::SessionDeleted { session_id, title } => {
1618 app.status_message = Some(format!(
1619 "Deleted session {} ({})",
1620 crate::session_manager::truncate_id(&session_id),
1621 title
1622 ));
1623 }
1624 ViewEvent::ConfigUpdated {
1625 key,
1626 value,
1627 persist,
1628 } => {
1629 if handle_config_updated(
1630 terminal,
1631 app,
1632 config,
1633 task_manager,
1634 engine_handle,
1635 key,
1636 value,
1637 persist,
1638 )
1639 .await?
1640 {
1641 return Ok(true);
1642 }
1643 }
1644 ViewEvent::ThemeSelectionUpdated { theme, persist } => {
1645 if handle_theme_selection_updated(
1646 terminal,
1647 app,
1648 config,
1649 task_manager,
1650 engine_handle,
1651 theme,
1652 persist,
1653 )
1654 .await?
1655 {
1656 return Ok(true);
1657 }
1658 }
1659 ViewEvent::StatusItemsUpdated { items, final_save } => {
1660 // Apply to the live App immediately so the footer reflects
1661 // every keystroke (live preview).
1662 app.status_items = items.clone();
1663 app.needs_redraw = true;
1664 if final_save {
1665 match crate::config_persistence::persist_status_items(&items) {
1666 Ok(path) => {
1667 app.status_message =
1668 Some(format!("Status line saved to {}", path.display()));
1669 }
1670 Err(err) => {
1671 app.add_message(HistoryCell::System {
1672 content: format!("Failed to save status line: {err}"),
1673 });
1674 }
1675 }
1676 }
1677 }
1678 ViewEvent::HotbarSetupSaved { bindings } => {
1679 apply_hotbar_setup_saved(app, config, bindings);
1680 }
1681 ViewEvent::SetupStateCommitRequested { state, message } => match state.save() {
1682 Ok(()) => {
1683 app.status_message = Some(message);
1684 }
1685 Err(err) => {
1686 app.status_message = Some(format!("Setup state could not be saved: {err}"));
1687 }
1688 },
1689 ViewEvent::SetupConstitutionCommitRequested {
1690 constitution,
1691 state,
1692 message,
1693 } => match crate::tui::setup::persist_user_constitution_choice(&constitution, &state) {
1694 Ok(()) => {
1695 app.status_message = Some(message);
1696 }
1697 Err(err) => {
1698 app.status_message =
1699 Some(format!("User constitution could not be saved: {err}"));
1700 }
1701 },
1702 ViewEvent::SetupConstitutionModelDraftRequested {
1703 draft,
1704 freeform_note,
1705 locale,
1706 } => {
1707 handle_setup_constitution_model_draft(app, config, draft, freeform_note, locale)
1708 .await;
1709 }
1710 ViewEvent::FleetProfileModelDraftRequested {
1711 role,
1712 model,
1713 provider,
1714 reasoning_effort,
1715 locale,
1716 } => {
1717 handle_fleet_profile_model_draft(
1718 app,
1719 config,
1720 role,
1721 model,
1722 provider,
1723 reasoning_effort,
1724 locale,
1725 )
1726 .await;
1727 }
1728 ViewEvent::FleetRosterOpenCoordinatorRequested => {
1729 app.view_stack.push(
1730 crate::tui::model_picker::ModelPickerView::new(app, config)
1731 .with_assignment_context("Coordinator", "Current session"),
1732 );
1733 }
1734 ViewEvent::FleetProfileRoutePickRequested { editor_id } => {
1735 if app.view_stack.top_kind() == Some(ModalKind::FleetSetup)
1736 && let Some(mut boxed) = app.view_stack.pop()
1737 {
1738 let selection = boxed
1739 .as_any_mut()
1740 .downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>()
1741 .and_then(|view| {
1742 view.route_selection(editor_id)
1743 .map(|selection| (selection, view.assignment_context()))
1744 });
1745 app.view_stack.push_boxed(boxed);
1746 if let Some((selection, (role, scope))) = selection {
1747 app.view_stack.push(
1748 crate::tui::model_picker::ModelPickerView::new_for_fleet_profile(
1749 app, config, editor_id, selection,
1750 )
1751 .with_assignment_context(role, scope),
1752 );
1753 }
1754 }
1755 }
1756 ViewEvent::FleetProfileRoutePicked {
1757 editor_id,
1758 provider,
1759 provider_id,
1760 model,
1761 reasoning,
1762 } => {
1763 if app.view_stack.top_kind() == Some(ModalKind::FleetSetup)
1764 && let Some(mut boxed) = app.view_stack.pop()
1765 {
1766 if let Some(view) = boxed
1767 .as_any_mut()
1768 .downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>(
1769 ) {
1770 view.accept_route(
1771 editor_id,
1772 provider_id.unwrap_or_else(|| provider.as_str().into()),
1773 model,
1774 reasoning,
1775 );
1776 }
1777 app.view_stack.push_boxed(boxed);
1778 }
1779 }
1780 ViewEvent::FleetProfileRouteCommitRequested { editor_id } => {
1781 if app.view_stack.top_kind() == Some(ModalKind::FleetSetup)
1782 && let Some(mut boxed) = app.view_stack.pop()
1783 {
1784 let result = boxed
1785 .as_any_mut()
1786 .downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>()
1787 .map(|view| view.commit_route_assignment(editor_id, app, config));
1788 match result {
1789 Some(Ok(message)) => {
1790 sync_fleet_roster(app, config, engine_handle);
1791 refresh_parked_fleet_roster(app, config);
1792 app.push_status_toast(message, StatusToastLevel::Success, Some(8_000));
1793 }
1794 Some(Err(reason)) => {
1795 app.view_stack.push_boxed(boxed);
1796 app.set_sticky_status(reason, StatusToastLevel::Error, None);
1797 }
1798 None => app.view_stack.push_boxed(boxed),
1799 }
1800 }
1801 }
1802 ViewEvent::FleetAssignmentPickerDismissed { editor_id } => {
1803 dismiss_fleet_assignment(app, editor_id);
1804 refresh_parked_fleet_roster(app, config);
1805 }
1806 ViewEvent::FleetRosterOpenSetupRequested { member_id } => {
1807 // The shared router opens the selected v2 Fleet's exact editor
1808 // (focused on this member) or the legacy wizard when no named
1809 // Fleet is selected.
1810 open_fleet_setup_target(app, config, Some(&member_id));
1811 }
1812 ViewEvent::FleetListOpenDetailRequested { name, scope } => {
1813 if app.view_stack.top_kind() != Some(ModalKind::FleetDetail) {
1814 if let Some(view) = crate::tui::views::fleet_detail::FleetDetailView::open(
1815 app, config, &name, scope,
1816 ) {
1817 app.view_stack.push(view);
1818 } else {
1819 app.set_sticky_status(
1820 format!(
1821 "Could not open team `{name}` ({}) — the file may have moved or become unreadable.",
1822 scope.label()
1823 ),
1824 crate::tui::app::StatusToastLevel::Error,
1825 None,
1826 );
1827 }
1828 }
1829 }
1830 // Enter on a Fleet editor row: the standard `/model` picker opens
1831 // on top of the editor, and its pick comes back below as
1832 // `FleetRoutePicked` to land on the editor still on the stack.
1833 ViewEvent::FleetDetailRoutePickRequested { target, editor_id } => {
1834 let selection = if app.view_stack.top_kind() == Some(ModalKind::FleetDetail)
1835 && let Some(mut editor) = app.view_stack.pop()
1836 {
1837 let selection = editor
1838 .as_any_mut()
1839 .downcast_mut::<crate::tui::views::fleet_detail::FleetDetailView>()
1840 .and_then(|view| {
1841 view.route_selection(editor_id, target)
1842 .map(|selection| (selection, view.assignment_context()))
1843 });
1844 app.view_stack.push_boxed(editor);
1845 selection
1846 } else {
1847 None
1848 };
1849 if let Some((selection, (role, scope))) = selection {
1850 app.view_stack.push(
1851 crate::tui::model_picker::ModelPickerView::new_for_fleet_route(
1852 app, config, target, editor_id, selection,
1853 )
1854 .with_assignment_context(role, scope),
1855 );
1856 }
1857 }
1858 ViewEvent::FleetRoutePicked {
1859 target,
1860 editor_id,
1861 provider,
1862 provider_id,
1863 model,
1864 reasoning,
1865 } => {
1866 let provider_key = provider_id.unwrap_or_else(|| provider.as_str().to_string());
1867 // The picker's `auto` row is "inherit": the Fleet row follows
1868 // the session route again.
1869 let pin = (model != "auto").then_some((provider_key, model));
1870 if let Some((provider_key, _)) = &pin
1871 && let Some(rejection) =
1872 crate::commands::fleet_provider_rejection(app, config, provider_key)
1873 {
1874 // Same gate as `/fleet add` and ⇧F: an unconfigured route
1875 // never enters a team from the picker.
1876 app.set_sticky_status(rejection, StatusToastLevel::Error, None);
1877 } else if app.view_stack.top_kind() == Some(ModalKind::FleetDetail)
1878 && let Some(mut boxed) = app.view_stack.pop()
1879 {
1880 let outcome = boxed
1881 .as_any_mut()
1882 .downcast_mut::<crate::tui::views::fleet_detail::FleetDetailView>()
1883 .map(|view| {
1884 let (provider, model) = match pin {
1885 Some((provider, model)) => (Some(provider), Some(model)),
1886 None => (None, None),
1887 };
1888 view.apply_picked_route(editor_id, target, provider, model, reasoning)
1889 });
1890 app.view_stack.push_boxed(boxed);
1891 match outcome {
1892 Some(Ok(message)) => {
1893 if let Some(mut editor) = app.view_stack.pop() {
1894 let direct = editor.as_any_mut().downcast_mut::<crate::tui::views::fleet_detail::FleetDetailView>()
1895 .is_some_and(|view| view.is_direct_assignment(editor_id));
1896 if !direct {
1897 app.view_stack.push_boxed(editor);
1898 }
1899 }
1900 app.push_status_toast(message, StatusToastLevel::Success, Some(8_000));
1901 sync_fleet_roster(app, config, engine_handle);
1902 refresh_parked_fleet_roster(app, config);
1903 }
1904 Some(Err(reason)) => {
1905 app.set_sticky_status(reason, StatusToastLevel::Error, None);
1906 }
1907 None => {}
1908 }
1909 } else {
1910 app.set_sticky_status(
1911 codewhale_localization::tr(
1912 app.ui_locale,
1913 codewhale_localization::MessageId::FleetRoutePickUnavailable,
1914 )
1915 .into_owned(),
1916 StatusToastLevel::Error,
1917 None,
1918 );
1919 }
1920 app.needs_redraw = true;
1921 }
1922 ViewEvent::FleetStoreChanged { message } => {
1923 app.status_message = Some(message);
1924 sync_fleet_roster(app, config, engine_handle);
1925 refresh_parked_fleet_roster(app, config);
1926 }
1927 // #5954: the roster emits (rather than emit-and-closes) these, so
1928 // it is still on the stack right underneath. Pushing on top makes
1929 // the three Fleet views one stack: `Esc` pops back to the roster,
1930 // and only closes the window at the root.
1931 ViewEvent::FleetRosterOpenFleetsRequested => {
1932 if app.view_stack.top_kind() != Some(ModalKind::FleetList) {
1933 let over_roster = app.view_stack.top_kind() == Some(ModalKind::FleetRoster);
1934 let mut view = crate::tui::views::fleet_list::FleetListView::new(app, config);
1935 if over_roster {
1936 view = view.over_fleet_roster();
1937 }
1938 app.view_stack.push(view);
1939 }
1940 }
1941 ViewEvent::FleetRosterOpenWorkersRequested => {
1942 if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
1943 let over_roster = app.view_stack.top_kind() == Some(ModalKind::FleetRoster);
1944 let agents = subagent_view_agents(app, &app.subagent_cache);
1945 let mut view = crate::tui::views::SubAgentsView::for_app(app, agents);
1946 if over_roster {
1947 view = view.over_fleet_roster();
1948 }
1949 app.view_stack.push(view);
1950 }
1951 app.status_message =
1952 Some(tr(app.ui_locale, MessageId::SubagentsFetching).to_string());
1953 let _ = engine_handle.try_send(Op::ListSubAgents);
1954 }
1955 ViewEvent::FleetSetupExternalConsentActivationRequested { provider_id, model } => {
1956 // Validate the selected Fleet route by minting the read-only
1957 // external credential capability only for this exact
1958 // provider/source/path. The check is route-scoped: a cloned
1959 // config has the target provider active so credential discovery
1960 // succeeds, but the parent session provider/model are never
1961 // mutated.
1962 let Some(provider) = ApiProvider::parse(&provider_id) else {
1963 app.set_sticky_status(
1964 format!("Team route activation failed: unknown provider `{provider_id}`"),
1965 crate::tui::app::StatusToastLevel::Error,
1966 None,
1967 );
1968 app.needs_redraw = true;
1969 continue;
1970 };
1971 let provider_label = provider.display_name();
1972 let mut scoped = config.clone();
1973 scoped.provider = Some(provider_id.clone());
1974 let validation =
1975 crate::route_runtime::resolve_runtime_route(&scoped, provider, Some(&model))
1976 .and_then(|route| route.validate().map_err(|err| err.to_string()));
1977 match validation {
1978 Ok(validated) => {
1979 app.provider_health
1980 .record_success(&scoped, provider, &validated.model);
1981 app.push_status_toast(
1982 format!(
1983 "{provider_label} route activated for team: {}",
1984 validated.model
1985 ),
1986 crate::tui::app::StatusToastLevel::Success,
1987 Some(5_000),
1988 );
1989 }
1990 Err(error) => {
1991 let envelope = ErrorEnvelope::new(
1992 ErrorCategory::Authentication,
1993 ErrorSeverity::Error,
1994 false,
1995 "route_validation_failed",
1996 &error,
1997 );
1998 app.provider_health
1999 .record_failure(&scoped, provider, &model, &envelope);
2000 app.push_status_toast(
2001 format!("{provider_label} route activation failed: {error}"),
2002 crate::tui::app::StatusToastLevel::Error,
2003 None,
2004 );
2005 }
2006 }
2007 // Refresh the Fleet setup view from a snapshot built against the
2008 // updated health state so the activated row becomes Ready
2009 // without closing the modal.
2010 if app.view_stack.top_kind() == Some(crate::tui::views::ModalKind::FleetSetup)
2011 && let Some(view) = app.view_stack.pop()
2012 {
2013 let mut restored = view;
2014 if let Some(fleet_setup) = restored
2015 .as_any_mut()
2016 .downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>(
2017 ) {
2018 let fresh = crate::tui::views::fleet_setup::FleetSetupSnapshot::from_app(
2019 app, config,
2020 );
2021 fleet_setup.refresh_from_snapshot(fresh);
2022 }
2023 app.view_stack.push_boxed(restored);
2024 }
2025 app.needs_redraw = true;
2026 }
2027 ViewEvent::FleetProfileDraftCommitRequested { draft, scope } => {
2028 // A project-scope save is refused (never silently redirected)
2029 // when project profiles are disabled for this launch: the file
2030 // would be written where nothing loads it.
2031 if scope == crate::fleet::profile::FleetProfileScope::Project
2032 && !crate::fleet::roster::project_agent_profiles_enabled()
2033 {
2034 app.set_sticky_status(
2035 tr(app.ui_locale, MessageId::FleetDestProjectDisabledSave).into_owned(),
2036 StatusToastLevel::Error,
2037 None,
2038 );
2039 app.needs_redraw = true;
2040 continue;
2041 }
2042 // The TOML is rendered deterministically from the validated
2043 // draft and written atomically; the target path is derived
2044 // from the sanitized id, never model-chosen.
2045 let profile_dir =
2046 match crate::fleet::profile::agent_profile_dir_for_scope(scope, &app.workspace)
2047 {
2048 Ok(dir) => dir,
2049 Err(err) => {
2050 app.set_sticky_status(
2051 format!("Team {} scope is unavailable: {err:#}", scope.label()),
2052 StatusToastLevel::Error,
2053 None,
2054 );
2055 app.needs_redraw = true;
2056 continue;
2057 }
2058 };
2059 let target = profile_dir.join(draft.file_name());
2060 // A ratified profile must not silently clobber a differently
2061 // named existing profile that shares this id (which would also
2062 // make the whole agents dir fail to load on the duplicate).
2063 // Overwriting the SAME file is fine — that is an intentional
2064 // re-draft of this profile.
2065 // The collision gate only needs file identities. Accept
2066 // otherwise legacy profile fields here so an old, unrelated
2067 // profile cannot block saving a current one. Malformed TOML,
2068 // unreadable files, and invalid ids still fail closed because
2069 // then we cannot prove there is no collision.
2070 let existing_profiles =
2071 crate::fleet::profile::load_agent_profile_identities_from_dir(&profile_dir);
2072 if let Err(err) = &existing_profiles {
2073 let message = tr(app.ui_locale, MessageId::FleetProfileIdentityVerifyFailed)
2074 .replace("{error}", &format!("{err:#}"));
2075 app.set_sticky_status(message, StatusToastLevel::Error, None);
2076 app.needs_redraw = true;
2077 continue;
2078 }
2079 let id_conflict = existing_profiles
2080 .into_iter()
2081 .flatten()
2082 .find(|p| p.id.eq_ignore_ascii_case(&draft.id) && p.source != target);
2083 if let Some(existing) = id_conflict {
2084 let message = tr(app.ui_locale, MessageId::FleetProfileIdConflict)
2085 .replace("{id}", &draft.id)
2086 .replace("{path}", &existing.source.display().to_string());
2087 app.set_sticky_status(message, StatusToastLevel::Error, None);
2088 app.needs_redraw = true;
2089 continue;
2090 }
2091 // #4093 AC #5: a profile may only pin a provider the operator
2092 // has actually configured/credentialed. The picker already
2093 // offers models only from configured providers, but a
2094 // model-drafted or hand-edited route (or credentials removed
2095 // after the pick) could still name an unconfigured one — which
2096 // would fail loudly at launch. Catch it at save time with a
2097 // clear message, reusing the SAME predicate the picker uses.
2098 if let Some(provider_id) = draft.provider.as_deref()
2099 && let Some(provider) = crate::config::ApiProvider::parse(provider_id)
2100 && !crate::config::provider_is_configured_for_active(
2101 config,
2102 provider,
2103 app.api_provider,
2104 )
2105 {
2106 let message = tr(app.ui_locale, MessageId::FleetProfileProviderUnconfigured)
2107 .replace("{provider}", provider_id)
2108 .replace("{env}", &provider.env_vars_label());
2109 app.set_sticky_status(message, StatusToastLevel::Error, None);
2110 app.needs_redraw = true;
2111 continue;
2112 }
2113 let mut txn = codewhale_config::persistence::SetupTransaction::new();
2114 txn.stage(target.clone(), draft.render_toml().into_bytes());
2115 match txn.commit() {
2116 Ok(()) => {
2117 let roster =
2118 std::sync::Arc::new(crate::fleet::identity::load_effective_roster(
2119 &config.fleet_config(),
2120 &app.workspace,
2121 Some(app.plugin_registry.as_ref()),
2122 ));
2123 let roster_refresh_failed = engine_handle
2124 .try_send(Op::SetFleetRoster { roster })
2125 .is_err();
2126 let zh = app.ui_locale == codewhale_localization::Locale::ZhHans;
2127 app.add_message(HistoryCell::System {
2128 content: if zh {
2129 format!("已保存团队配置:{}", target.display())
2130 } else {
2131 format!(
2132 "Team {} profile saved: {}",
2133 scope.label(),
2134 target.display()
2135 )
2136 },
2137 });
2138 app.status_message = Some(if zh {
2139 format!("已保存团队配置:{}", draft.file_name())
2140 } else if roster_refresh_failed {
2141 format!(
2142 "Team {} profile saved, but the live roster could not refresh; restart before dispatching {}",
2143 scope.label(),
2144 draft.id
2145 )
2146 } else {
2147 format!(
2148 "Team {} profile saved: {}",
2149 scope.label(),
2150 draft.file_name()
2151 )
2152 });
2153 }
2154 Err(err) => {
2155 app.status_message =
2156 Some(if app.ui_locale == codewhale_localization::Locale::ZhHans {
2157 format!("无法保存团队配置:{err:#}")
2158 } else {
2159 format!("Team profile could not be saved: {err:#}")
2160 });
2161 }
2162 }
2163 app.needs_redraw = true;
2164 }
2165 ViewEvent::SetupRuntimePresetApplyRequested {
2166 preset,
2167 state,
2168 message,
2169 } => match apply_setup_runtime_preset(app, config, preset, state) {
2170 Ok(summary) => {
2171 sync_mode_update(app, engine_handle).await;
2172 app.status_message = Some(format!("{message} {summary}"));
2173 }
2174 Err(err) => {
2175 app.status_message =
2176 Some(format!("Runtime preset could not be applied: {err:#}"));
2177 }
2178 },
2179 ViewEvent::SetupOpenProviderRequested => {
2180 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
2181 let runtime_status = query_provider_runtime_status(engine_handle).await;
2182 app.view_stack.push(
2183 crate::tui::provider_picker::ProviderPickerView::new_for_setup(
2184 app.api_provider,
2185 Some(app.api_provider),
2186 config,
2187 runtime_status,
2188 )
2189 .with_locale(app.ui_locale)
2190 .with_provider_health(&app.provider_health),
2191 );
2192 app.status_message =
2193 Some("Provider setup opened from /setup readiness.".to_string());
2194 }
2195 }
2196 ViewEvent::SetupOpenModelRequested => {
2197 if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
2198 open_model_picker_for_provider(app, config, app.api_provider);
2199 app.status_message =
2200 Some("Model route picker opened from /setup readiness.".to_string());
2201 }
2202 }
2203 ViewEvent::SetupOpenFleetRequested => {
2204 open_fleet_setup_target(app, config, None);
2205 }
2206 ViewEvent::SetupOpenHotbarRequested => {
2207 if app.view_stack.top_kind() != Some(ModalKind::HotbarSetup) {
2208 app.view_stack
2209 .push(crate::tui::hotbar::setup::HotbarSetupView::new(app, config));
2210 app.status_message =
2211 Some("Hotbar setup opened from /setup Hotbar readiness.".to_string());
2212 }
2213 }
2214 ViewEvent::SetupOpenModeRequested => {
2215 if app.view_stack.top_kind() != Some(ModalKind::ModePicker) {
2216 app.view_stack
2217 .push(crate::tui::views::mode_picker::ModePickerView::new(
2218 app.mode,
2219 app.ui_locale,
2220 ));
2221 app.status_message =
2222 Some("Work mode picker opened from /setup runtime posture.".to_string());
2223 }
2224 }
2225 ViewEvent::SetupOpenConfigRequested => {
2226 if app.view_stack.top_kind() != Some(ModalKind::Config) {
2227 app.view_stack.push(ConfigView::new_for_app(app));
2228 app.status_message =
2229 Some("Config view opened from /setup runtime posture.".to_string());
2230 }
2231 }
2232 ViewEvent::SetupOpenRemoteControlRequested => {
2233 start_remote_control_session(app, config);
2234 }
2235 ViewEvent::HotbarDisableRequested => {
2236 disable_hotbar(app, config);
2237 }
2238 ViewEvent::SubAgentsRefresh => {
2239 app.status_message = Some("Refreshing sub-agents...".to_string());
2240 // #3802: non-blocking send — refresh op, safe to drop.
2241 let _ = engine_handle.try_send(Op::ListSubAgents);
2242 }
2243 ViewEvent::SidebarAgentCancel { agent_id } => {
2244 app.status_message = Some(format!("Cancelling {agent_id}..."));
2245 // #6150: the input path never awaits a full op channel. The
2246 // cancel is retryable; a rejected send surfaces immediately.
2247 if engine_handle
2248 .try_send(Op::CancelSubAgent {
2249 agent_id: agent_id.clone(),
2250 })
2251 .is_err()
2252 {
2253 app.status_message = Some(format!("Could not cancel {agent_id}"));
2254 }
2255 }
2256 ViewEvent::OpenAgentTranscript { agent_id } => {
2257 // One agent, one destination: focus the worker so its full
2258 // transcript owns the main area and the composer addresses
2259 // its fork. The register modal closes so the focus is visible.
2260 if app.view_stack.top_kind() == Some(ModalKind::SubAgents) {
2261 app.view_stack.pop();
2262 }
2263 crate::tui::agent_focus::focus_agent(app, &agent_id);
2264 app.needs_redraw = true;
2265 }
2266 ViewEvent::AgentDetailsClosed { agent_id } => {
2267 crate::tui::work_surface::agent_details_closed(app, &agent_id);
2268 }
2269 ViewEvent::FilePickerSelected { path } => {
2270 // Insert `@<path>` at the composer's cursor with surrounding
2271 // whitespace so the existing `@`-mention parser picks it up.
2272 let cursor = app.cursor_position;
2273 let needs_leading_space = cursor > 0
2274 && !app
2275 .input
2276 .chars()
2277 .nth(cursor.saturating_sub(1))
2278 .is_some_and(|c| c.is_whitespace());
2279 let mut insertion = String::new();
2280 if needs_leading_space {
2281 insertion.push(' ');
2282 }
2283 insertion.push('@');
2284 insertion.push_str(&path);
2285 insertion.push(' ');
2286 app.insert_str(&insertion);
2287 app.status_message = Some(format!("Attached @{path}"));
2288 }
2289 ViewEvent::ModelPickerApplied {
2290 model,
2291 provider,
2292 provider_id,
2293 effort,
2294 previous_model,
2295 previous_effort,
2296 save_as_startup_default,
2297 } => {
2298 apply_model_picker_choice(
2299 app,
2300 engine_handle,
2301 config,
2302 model,
2303 provider,
2304 provider_id,
2305 effort,
2306 previous_model,
2307 previous_effort,
2308 save_as_startup_default,
2309 )
2310 .await;
2311 refresh_parked_fleet_roster(app, config);
2312 }
2313 ViewEvent::ModelPickerDismissed {
2314 catalog_view,
2315 view,
2316 selected_row_id,
2317 } => {
2318 sync_config_provider_from_app(config, app);
2319 app.model_picker_memory = Some(crate::tui::app::ModelPickerMemory {
2320 catalog_view,
2321 view: Some(view),
2322 selected_row_id,
2323 });
2324 refresh_parked_fleet_roster(app, config);
2325 }
2326 ViewEvent::ModelPickerRefresh => {
2327 // Re-resolve readiness from the live credential state and
2328 // rebuild catalog rows. Non-destructive: never clears the list
2329 // when a refresh fails; just re-project from current config.
2330 sync_config_provider_from_app(config, app);
2331 if app.view_stack.top_kind() == Some(ModalKind::ModelPicker)
2332 && let Some(mut boxed) = app.view_stack.pop()
2333 {
2334 if let Some(picker) = boxed
2335 .as_any_mut()
2336 .downcast_mut::<crate::tui::model_picker::ModelPickerView>(
2337 ) {
2338 picker.re_resolve_from_app(app, config);
2339 app.status_message =
2340 Some("Model readiness refreshed · catalog rows rebuilt".into());
2341 }
2342 app.view_stack.push_boxed(boxed);
2343 } else {
2344 app.status_message =
2345 Some("Open /model to refresh readiness and catalog".into());
2346 }
2347 app.needs_redraw = true;
2348 }
2349 ViewEvent::ModelPickerToggleFleet {
2350 provider,
2351 provider_id,
2352 model,
2353 } => {
2354 use crate::fleet::members::{FleetModelChange, change_receipt, toggle_fleet_model};
2355 let provider_key = provider_id.unwrap_or_else(|| provider.as_str().to_string());
2356 let locale = app.ui_locale;
2357 // Same gate as `/fleet add`, against the live config: a
2358 // locked or unauthenticated provider row never enters the
2359 // fleet from the picker either.
2360 if let Some(rejection) =
2361 crate::commands::fleet_provider_rejection(app, config, &provider_key)
2362 {
2363 app.set_sticky_status(rejection, StatusToastLevel::Error, None);
2364 } else {
2365 match toggle_fleet_model(&app.workspace, &provider_key, &model) {
2366 Ok(change) => {
2367 let level = if matches!(change, FleetModelChange::Unchanged { .. }) {
2368 StatusToastLevel::Info
2369 } else {
2370 app.fleet_roster_stale = true;
2371 StatusToastLevel::Success
2372 };
2373 app.push_status_toast(
2374 change_receipt(locale, &provider_key, &model, &change),
2375 level,
2376 Some(FLEET_TOGGLE_TOAST_TTL_MS),
2377 );
2378 }
2379 Err(error) => app.set_sticky_status(
2380 tr(locale, MessageId::FleetToggleFailed)
2381 .replace("{error}", &error.message(locale)),
2382 StatusToastLevel::Error,
2383 None,
2384 ),
2385 }
2386 }
2387 if let Some(mut boxed) = app.view_stack.pop() {
2388 if let Some(picker) = boxed
2389 .as_any_mut()
2390 .downcast_mut::<crate::tui::model_picker::ModelPickerView>(
2391 ) {
2392 picker.re_resolve_from_app(app, config);
2393 }
2394 app.view_stack.push_boxed(boxed);
2395 }
2396 app.needs_redraw = true;
2397 }
2398 ViewEvent::ModelPickerTogglePin {
2399 provider,
2400 provider_id,
2401 model,
2402 } => {
2403 let provider_key = provider_id.unwrap_or_else(|| provider.as_str().to_string());
2404 match crate::settings::Settings::transact(|settings| {
2405 Ok(settings.toggle_pinned_model(&provider_key, &model))
2406 }) {
2407 Ok(true) => app.status_message = Some(format!("Pinned {provider_key}/{model}")),
2408 Ok(false) => {
2409 app.status_message = Some(format!("Unpinned {provider_key}/{model}"))
2410 }
2411 Err(error) => {
2412 app.status_message = Some(format!("Could not update pin: {error}"))
2413 }
2414 }
2415 if let Ok(settings) = crate::settings::Settings::load_persisted() {
2416 app.pinned_models = settings.pinned_models;
2417 }
2418 if let Some(mut boxed) = app.view_stack.pop() {
2419 if let Some(picker) = boxed
2420 .as_any_mut()
2421 .downcast_mut::<crate::tui::model_picker::ModelPickerView>(
2422 ) {
2423 picker.re_resolve_from_app(app, config);
2424 }
2425 app.view_stack.push_boxed(boxed);
2426 }
2427 app.needs_redraw = true;
2428 }
2429 ViewEvent::ModelPickerMovePin {
2430 provider,
2431 provider_id,
2432 model,
2433 delta,
2434 } => {
2435 let provider_key = provider_id.unwrap_or_else(|| provider.as_str().to_string());
2436 let reordered = crate::settings::Settings::transact_opt(|settings| {
2437 if !settings.move_pinned_model(&provider_key, &model, delta) {
2438 return Ok(None);
2439 }
2440 Ok(Some(settings.pinned_models.clone()))
2441 });
2442 match reordered {
2443 Ok(None) => {}
2444 Ok(Some(pinned_models)) => {
2445 app.pinned_models = pinned_models;
2446 app.status_message = Some("Pinned model order updated".into());
2447 if let Some(mut boxed) = app.view_stack.pop() {
2448 if let Some(picker) = boxed
2449 .as_any_mut()
2450 .downcast_mut::<crate::tui::model_picker::ModelPickerView>(
2451 ) {
2452 picker.re_resolve_from_app(app, config);
2453 }
2454 app.view_stack.push_boxed(boxed);
2455 }
2456 }
2457 Err(error) => {
2458 app.status_message = Some(format!("Could not reorder pin: {error}"));
2459 }
2460 }
2461 app.needs_redraw = true;
2462 }
2463 ViewEvent::ModelPickerNeedsAuth {
2464 provider,
2465 model,
2466 reason,
2467 } => {
2468 app.status_message = Some(reason);
2469 // Close the model picker if it is still open, then hand off to
2470 // the provider auth flow for the locked model's provider.
2471 while app.view_stack.top_kind() == Some(ModalKind::ModelPicker) {
2472 let _ = app.view_stack.pop();
2473 }
2474 if let Some(picker) =
2475 crate::tui::provider_picker::ProviderPickerView::new_for_missing_auth(
2476 app.api_provider,
2477 provider,
2478 config,
2479 None,
2480 )
2481 {
2482 app.view_stack.push(picker);
2483 } else {
2484 app.status_message = Some(format!(
2485 "🔒 {model} needs {provider:?} credentials — open /provider to authenticate."
2486 ));
2487 }
2488 app.needs_redraw = true;
2489 }
2490 ViewEvent::StatusMessage { message } => {
2491 app.status_message = Some(message);
2492 app.needs_redraw = true;
2493 }
2494 ViewEvent::TopbarRoutePickerRequested => {
2495 open_provider_picker(app, config, engine_handle).await;
2496 }
2497 ViewEvent::TopbarModelPickerRequested => {
2498 if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
2499 app.view_stack
2500 .push(crate::tui::model_picker::ModelPickerView::new(app, config));
2501 }
2502 }
2503 ViewEvent::ProviderPickerDismissed {
2504 catalog_view,
2505 selected_provider_id,
2506 } => {
2507 let onboarding_provider_picker = app.onboarding == OnboardingState::Provider;
2508 // A picker preview must never become route authority. During
2509 // onboarding Esc is deliberately non-mutating: it returns to
2510 // Language without touching config or the onboarding marker.
2511 if !onboarding_provider_picker {
2512 sync_config_provider_from_app(config, app);
2513 }
2514 app.provider_picker_memory = Some(crate::tui::app::ProviderPickerMemory {
2515 catalog_view,
2516 selected_provider_id,
2517 });
2518 if onboarding_provider_picker {
2519 back_from_provider_onboarding(app);
2520 }
2521 }
2522 ViewEvent::ProviderPickerApplied {
2523 provider,
2524 provider_id,
2525 } => {
2526 if let Some(provider_id) = provider_id {
2527 set_active_custom_provider_in_memory(config, &provider_id);
2528 }
2529 let model_override = provider_picker_model_override(app, config, provider);
2530 let switched =
2531 switch_provider(app, engine_handle, config, provider, model_override).await;
2532 if switched && app.onboarding == OnboardingState::Provider {
2533 complete_provider_picker_onboarding(app, provider);
2534 }
2535 refresh_config_view_if_open(app, "provider");
2536 }
2537 ViewEvent::ProviderPickerApiKeySubmitted {
2538 provider,
2539 provider_id,
2540 api_key,
2541 base_url,
2542 } => {
2543 let identity = picker_provider_identity(config, provider, provider_id.as_deref())
2544 .map_err(anyhow::Error::msg)?;
2545 apply_provider_picker_api_key(
2546 app,
2547 engine_handle,
2548 config,
2549 identity,
2550 api_key,
2551 base_url,
2552 )
2553 .await;
2554 refresh_config_view_if_open(app, "provider");
2555 }
2556 ViewEvent::ProviderPickerSetupConfirmed {
2557 provider,
2558 provider_id,
2559 api_key,
2560 model,
2561 context_window,
2562 base_url,
2563 } => {
2564 let identity = picker_provider_identity(config, provider, provider_id.as_deref())
2565 .map_err(anyhow::Error::msg)?;
2566 let completed = apply_provider_picker_setup_confirmed(
2567 app,
2568 engine_handle,
2569 config,
2570 identity,
2571 api_key,
2572 model,
2573 context_window,
2574 base_url,
2575 )
2576 .await;
2577 if completed && app.onboarding == OnboardingState::Provider {
2578 complete_provider_picker_onboarding(app, provider);
2579 }
2580 refresh_config_view_if_open(app, "provider");
2581 }
2582 ViewEvent::ProviderPickerCustomProviderSubmitted {
2583 provider_id,
2584 base_url,
2585 model,
2586 api_key_env,
2587 } => {
2588 let switched = apply_provider_picker_custom_provider(
2589 app,
2590 engine_handle,
2591 config,
2592 provider_id,
2593 base_url,
2594 model,
2595 api_key_env,
2596 )
2597 .await;
2598 complete_provider_picker_onboarding_if_switched(app, ApiProvider::Custom, switched);
2599 refresh_config_view_if_open(app, "provider");
2600 }
2601 ViewEvent::ProviderPickerXaiOAuthRequested => {
2602 let switched =
2603 run_xai_device_login_from_tui(terminal, app, engine_handle, config).await?;
2604 complete_provider_picker_onboarding_if_switched(app, ApiProvider::Xai, switched);
2605 }
2606 ViewEvent::ProviderPickerChatgptOAuthRequested => {
2607 let switched =
2608 run_chatgpt_pkce_login_from_tui(terminal, app, engine_handle, config).await?;
2609 complete_provider_picker_onboarding_if_switched(
2610 app,
2611 ApiProvider::OpenaiCodex,
2612 switched,
2613 );
2614 }
2615 ViewEvent::ProviderPickerExternalConsentConfirmed {
2616 provider,
2617 consent_provider,
2618 source,
2619 path,
2620 } => match persist_external_credential_consent_for_at(
2621 app.config_path.as_deref(),
2622 config,
2623 provider,
2624 consent_provider,
2625 source,
2626 &path,
2627 ) {
2628 Ok(_) => {
2629 let toast = app
2630 .tr(MessageId::ProviderExternalGrantedToast)
2631 .replace("{owner}", source.owner_label())
2632 .replace("{provider}", provider.as_str());
2633 app.push_status_toast(toast, StatusToastLevel::Success, Some(8_000));
2634 let model_override = provider_picker_model_override(app, config, provider);
2635 let switched =
2636 switch_provider(app, engine_handle, config, provider, model_override).await;
2637 // #4763: reusing an external CLI grant completes provider
2638 // onboarding exactly like a submitted key or an applied
2639 // route. Without this the picker closes on success and
2640 // the user is returned to the provider step they just
2641 // satisfied — the second half of the reported loop.
2642 if switched && app.onboarding == OnboardingState::Provider {
2643 complete_provider_picker_onboarding(app, provider);
2644 }
2645 refresh_config_view_if_open(app, "provider");
2646 }
2647 Err(error) => app.push_status_toast(
2648 app.tr(MessageId::ProviderExternalSaveFailedToast)
2649 .replace("{error}", &error.to_string()),
2650 StatusToastLevel::Error,
2651 None,
2652 ),
2653 },
2654 ViewEvent::ProviderPickerExternalConsentRevoked { provider } => {
2655 match revoke_external_credential_consent_for_at(
2656 app.config_path.as_deref(),
2657 config,
2658 provider,
2659 ) {
2660 Ok(_) => app.push_status_toast(
2661 app.tr(MessageId::ProviderExternalRevokedToast)
2662 .replace("{provider}", provider.as_str()),
2663 StatusToastLevel::Success,
2664 Some(5_000),
2665 ),
2666 Err(error) => app.push_status_toast(
2667 app.tr(MessageId::ProviderExternalRevokeFailedToast)
2668 .replace("{error}", &error.to_string()),
2669 StatusToastLevel::Error,
2670 None,
2671 ),
2672 }
2673 refresh_config_view_if_open(app, "provider");
2674 }
2675 ViewEvent::ProviderPickerOpenModels {
2676 provider,
2677 provider_id,
2678 } => {
2679 if let Some(provider_id) = provider_id {
2680 set_active_custom_provider_in_memory(config, &provider_id);
2681 }
2682 open_model_picker_for_provider(app, config, provider);
2683 }
2684 ViewEvent::ProviderPickerTestConnection {
2685 provider,
2686 provider_id,
2687 catalog_view,
2688 } => {
2689 match picker_provider_identity(config, provider, provider_id.as_deref()) {
2690 Ok(identity) => {
2691 apply_provider_picker_test_connection(
2692 app,
2693 engine_handle,
2694 config,
2695 identity,
2696 catalog_view,
2697 )
2698 .await;
2699 }
2700 Err(error) => {
2701 app.push_status_toast(error, StatusToastLevel::Error, Some(8_000));
2702 }
2703 }
2704 refresh_config_view_if_open(app, "provider");
2705 }
2706 ViewEvent::ModeSelected { mode } => {
2707 let prior_mode = app.mode;
2708 let msg = commands::switch_mode(app, mode);
2709 if app.mode != prior_mode {
2710 sync_mode_update(app, engine_handle).await;
2711 }
2712 app.add_message(HistoryCell::System { content: msg });
2713 }
2714 ViewEvent::BacktrackStep { direction } => {
2715 app.backtrack.step(direction);
2716 if let Some(idx) = app.backtrack.selected_idx() {
2717 update_backtrack_overlay_selection(app, idx);
2718 }
2719 }
2720 // Apply the accepted choice now, for keyboard and mouse alike.
2721 // Parking it in pending_launch_action left keyboard confirmation
2722 // waiting for an unrelated mouse event to drain that queue.
2723 ViewEvent::LaunchResumeConfirmed { session_id } => {
2724 let result = resume_launch_session(app, &session_id);
2725 if apply_command_result(terminal, app, engine_handle, task_manager, config, result)
2726 .await?
2727 {
2728 return Ok(true);
2729 }
2730 app.needs_redraw = true;
2731 }
2732 ViewEvent::BacktrackConfirm => {
2733 if let Some(depth) = app.backtrack.confirm() {
2734 // Reserve the slot before mutating history (#6150): the
2735 // loop must not await a full op channel, and applying the
2736 // backtrack without delivering SyncSession would desync
2737 // the engine's messages from ours.
2738 match engine_handle.tx_op.clone().try_reserve_owned() {
2739 Ok(permit) => {
2740 apply_backtrack(app, depth);
2741 engine_handle.send_reserved_op(
2742 permit,
2743 Op::SyncSession {
2744 session_id: app.current_session_id.clone(),
2745 messages: app.api_messages.as_ref().clone(),
2746 system_prompt: app.system_prompt.clone(),
2747 system_prompt_override: false,
2748 model: app.model.clone(),
2749 workspace: app.workspace.clone(),
2750 mode: app.mode,
2751 },
2752 );
2753 }
2754 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
2755 app.status_message = Some(
2756 "Engine busy — backtrack not applied; try again in a moment"
2757 .to_string(),
2758 );
2759 app.needs_redraw = true;
2760 }
2761 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
2762 app.status_message =
2763 Some("Engine stopped — backtrack not applied".to_string());
2764 app.needs_redraw = true;
2765 }
2766 }
2767 }
2768 }
2769 ViewEvent::BacktrackCancel => {
2770 app.backtrack.reset();
2771 app.status_message = Some("Backtrack canceled".to_string());
2772 app.needs_redraw = true;
2773 }
2774 ViewEvent::ContextMenuSelected {
2775 action: ContextMenuAction::ExecuteCommand { command },
2776 } => {
2777 if execute_command_input(
2778 terminal,
2779 app,
2780 engine_handle,
2781 task_manager,
2782 config,
2783 &command,
2784 )
2785 .await?
2786 {
2787 return Ok(true);
2788 }
2789 }
2790 ViewEvent::ContextMenuSelected { action } => {
2791 handle_context_menu_action(terminal, app, action)
2792 }
2793 ViewEvent::SkillMutationRequested { request } => {
2794 handle_skill_mutation_requested(app, request).await;
2795 }
2796 ViewEvent::SkillsManagerToggleCompatible => {
2797 if app.view_stack.top_kind() == Some(ModalKind::SkillsManager)
2798 && let Some(mut boxed) = app.view_stack.pop()
2799 {
2800 if let Some(view) = boxed
2801 .as_any_mut()
2802 .downcast_mut::<crate::tui::views::skills_manager::SkillsManagerView>(
2803 ) {
2804 crate::tui::views::skills_manager::apply_toggle_compatible(view, app);
2805 }
2806 app.view_stack.push_boxed(boxed);
2807 }
2808 }
2809 }
2810 }
2811
2812 Ok(false)
2813 }
2814
2815 /// Keep the very large modal-event dispatcher out of the already-large TUI
2816 /// loop future. Config previews take a dedicated small path: polling the full
2817 /// dispatcher on top of the event loop exceeds the macOS main-thread stack in
2818 /// debug builds before a theme preview can reach its next frame.
2819 #[allow(clippy::too_many_arguments)]
2820 pub(crate) fn handle_view_events_boxed<'a>(
2821 terminal: &'a mut AppTerminal,
2822 app: &'a mut App,
2823 config: &'a mut Config,
2824 task_manager: &'a SharedTaskManager,
2825 engine_handle: &'a mut EngineHandle,
2826 events: Vec<ViewEvent>,
2827 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + 'a>> {
2828 Box::pin(async move {
2829 for event in events {
2830 match event {
2831 ViewEvent::ConfigUpdated {
2832 key,
2833 value,
2834 persist,
2835 } => {
2836 if handle_config_updated(
2837 terminal,
2838 app,
2839 config,
2840 task_manager,
2841 engine_handle,
2842 key,
2843 value,
2844 persist,
2845 )
2846 .await?
2847 {
2848 return Ok(true);
2849 }
2850 }
2851 ViewEvent::ThemeSelectionUpdated { theme, persist } => {
2852 if handle_theme_selection_updated(
2853 terminal,
2854 app,
2855 config,
2856 task_manager,
2857 engine_handle,
2858 theme,
2859 persist,
2860 )
2861 .await?
2862 {
2863 return Ok(true);
2864 }
2865 }
2866 other => {
2867 if Box::pin(handle_view_events(
2868 terminal,
2869 app,
2870 config,
2871 task_manager,
2872 engine_handle,
2873 vec![other],
2874 ))
2875 .await?
2876 {
2877 return Ok(true);
2878 }
2879 }
2880 }
2881 }
2882 Ok(false)
2883 })
2884 }
2885
2885 lines RUST