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