返回 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 /// Persist a `# foo` quick-add through the native memory store and surface
9 /// a status note to the user. Errors land in the same status channel so a
10 /// missing memory directory becomes visible without crashing the composer.
11 pub(crate) fn handle_memory_quick_add(app: &mut App, input: &str, config: &Config) {
12 let path = config.memory_path();
13 let note = input.trim_start_matches('#').trim();
14 let result = crate::native_memory::NativeMemoryStore::from_global_path(&path)
15 .ok_or_else(|| format!("{} is not a native memory path", path.display()))
16 .and_then(|store| {
17 store
18 .remember(crate::native_memory::MemoryScope::Global, None, note)
19 .map(|hit| hit.source)
20 .map_err(|err| err.to_string())
21 });
22 match result {
23 Ok(source) => {
24 app.status_message = Some(format!("memory: appended to {}", source.display()));
25 }
26 Err(err) => {
27 app.status_message = Some(format!(
28 "memory: failed to write {}: {}",
29 path.display(),
30 err
31 ));
32 }
33 }
34 }
35
36 /// Route one terminal bracketed-paste event without exposing its contents.
37 ///
38 /// Keeping the routing in one function makes the credential and ordinary
39 /// composer paths exercise the same observability boundary.
40 pub(crate) fn handle_bracketed_paste(app: &mut App, text: &str) {
41 tracing::debug!(
42 paste_bytes = text.len(),
43 paste_chars = text.chars().count(),
44 "Received bracketed paste event"
45 );
46 // Once a real bracketed-paste event has been observed in this session,
47 // the rapid-keystroke heuristic in paste_burst is redundant — disable it
48 // so fast typing / IME commits / autocomplete bursts don't get
49 // mis-classified as a paste.
50 app.bracketed_paste_seen = true;
51 if app.is_history_search_active() {
52 app.history_search_insert_str(text);
53 } else if paste_text_into_provider_picker(app, text) || app.view_stack.handle_paste(text) {
54 // Modal consumed the paste (e.g. provider picker key entry).
55 } else if !app.view_stack.is_empty() {
56 // A non-consumed modal is open — don't leak paste into composer.
57 } else {
58 // Paste into main input.
59 app.insert_paste_text(text);
60 }
61 }
62
63 /// Voice input toggle via Option+V (⌥V) — matches Muse Spark UX:
64 /// "Recording (⌥V to finish)" with a transient voice indicator, no slash
65 /// command needed. Handles both Alt+V and the macOS ⌥V glyph.
66 pub(crate) fn handle_voice_key(app: &mut App, key: &event::KeyEvent) -> bool {
67 let is_alt_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'))
68 && key.modifiers.contains(KeyModifiers::ALT)
69 && !key.modifiers.contains(KeyModifiers::CONTROL)
70 && !key.modifiers.contains(KeyModifiers::SUPER);
71 // Some terminals emit the literal "√" (Option+V on macOS) instead of Alt+V.
72 let is_glyph = matches!(key.code, KeyCode::Char('√') | KeyCode::Char('∫'));
73 if !is_alt_v && !is_glyph {
74 return false;
75 }
76 // Toggle voice capture — same path as /voice but via hotkey.
77 let result = crate::commands::voice::voice(app);
78 // Surface a Spark-style transient hint; the capture itself is async.
79 if app.voice_enabled {
80 app.status_message = Some("● Recording (⌥V to finish)".to_string());
81 }
82 // Suppress the default char insertion for this combo.
83 let _ = result;
84 true
85 }
86
87 /// The event-loop seam for Ctrl+T. Keeping the `KeyEvent` predicate and App
88 /// mutation together makes the real terminal route directly testable rather
89 /// than testing `cycle_effort` in isolation.
90 pub(crate) fn handle_reasoning_effort_key(app: &mut App, key: &event::KeyEvent) -> bool {
91 if !matches!(key.code, KeyCode::Char('t') | KeyCode::Char('T'))
92 || key.modifiers != KeyModifiers::CONTROL
93 {
94 return false;
95 }
96 let _ = app.cycle_effort();
97 true
98 }
99
100 /// Let the transcript remain reviewable while an approval card owns focus.
101 pub(crate) fn handle_approval_transcript_key(app: &mut App, key: &event::KeyEvent) -> bool {
102 if app.view_stack.top_kind() != Some(ModalKind::Approval) {
103 return false;
104 }
105
106 let page = app.viewport.last_transcript_visible.max(1);
107 match key.code {
108 KeyCode::PageUp => app.scroll_up(page),
109 KeyCode::PageDown => app.scroll_down(page),
110 KeyCode::Up
111 if key
112 .modifiers
113 .intersects(KeyModifiers::ALT | KeyModifiers::SHIFT | KeyModifiers::CONTROL) =>
114 {
115 app.scroll_up(3);
116 }
117 KeyCode::Down
118 if key
119 .modifiers
120 .intersects(KeyModifiers::ALT | KeyModifiers::SHIFT | KeyModifiers::CONTROL) =>
121 {
122 app.scroll_down(3);
123 }
124 KeyCode::Home => app.scroll_up(usize::MAX),
125 KeyCode::End => app.scroll_to_bottom(),
126 _ => return false,
127 }
128 true
129 }
130
131 /// Route only non-text controls to a focused workflow panel.
132 ///
133 /// Returning `false` for every character is deliberate: the caller then lets
134 /// the normal composer path insert it. A prior bare-letter contract used
135 /// t/c/j/k here, which made the first matching letter of a new chat disappear
136 /// after the user clicked the workflow card.
137 pub(crate) fn handle_workflow_panel_key(app: &mut App, key: &event::KeyEvent) -> bool {
138 if !app
139 .workflow_panel
140 .as_ref()
141 .is_some_and(|panel| panel.keyboard_focus)
142 {
143 return false;
144 }
145
146 if matches!(key.code, KeyCode::Char(_)) {
147 if let Some(panel) = app.workflow_panel.as_mut() {
148 panel.keyboard_focus = false;
149 }
150 app.needs_redraw = true;
151 return false;
152 }
153
154 if !key.modifiers.is_empty() && key.code != KeyCode::Esc {
155 return false;
156 }
157
158 match key.code {
159 KeyCode::Esc => {
160 if let Some(panel) = app.workflow_panel.as_mut() {
161 panel.keyboard_focus = false;
162 }
163 app.needs_redraw = true;
164 true
165 }
166 KeyCode::Enter => {
167 if let Some(panel) = app.workflow_panel.as_mut() {
168 let _ = panel.toggle_expanded();
169 }
170 app.needs_redraw = true;
171 true
172 }
173 KeyCode::Down => {
174 if let Some(panel) = app.workflow_panel.as_mut() {
175 panel.select_next_phase();
176 }
177 app.needs_redraw = true;
178 true
179 }
180 KeyCode::Up => {
181 if let Some(panel) = app.workflow_panel.as_mut() {
182 panel.select_prev_phase();
183 }
184 app.needs_redraw = true;
185 true
186 }
187 KeyCode::Delete => {
188 let Some(run_id) = app
189 .workflow_panel
190 .as_ref()
191 .and_then(|panel| panel.lifecycle.is_running().then(|| panel.run_id.clone()))
192 else {
193 return false;
194 };
195 app.input = format!("/workflow cancel {run_id}");
196 app.cursor_position = app.input.chars().count();
197 app.status_message = Some(app.tr(MessageId::SidebarDestructiveArmed).into_owned());
198 if let Some(panel) = app.workflow_panel.as_mut() {
199 panel.keyboard_focus = false;
200 }
201 app.needs_redraw = true;
202 true
203 }
204 _ => false,
205 }
206 }
207
208 /// One-shot "draft my constitution" call against the user's first configured
209 /// model, requested by `A` on the setup Constitution card. Runs inline in the
210 /// event loop like [`fetch_available_models`] (the wizard modal stays open
211 /// underneath) with a hard timeout so a slow provider cannot wedge setup.
212 ///
213 /// On success the sanitized, bounded draft is installed into the open wizard
214 /// and its ratification preview opens on top — nothing persists until the
215 /// user ratifies with `G`. Every failure (no client, timeout, request error,
216 /// invalid or empty JSON) is a status line, never an error state: the
217 /// deterministic guided draft remains the standing fallback.
218 pub(crate) async fn handle_setup_constitution_model_draft(
219 app: &mut App,
220 config: &Config,
221 draft: crate::tui::setup::GuidedConstitutionDraft,
222 freeform_note: Option<String>,
223 locale: crate::localization::Locale,
224 ) {
225 // Spawn the draft off the event loop (same pattern as the fleet drafter,
226 // #3757 review): awaiting it inline parked the whole TUI for up to the
227 // timeout. The loop polls constitution_draft_cell and delivers the result.
228 const DRAFT_TIMEOUT: Duration = Duration::from_secs(20);
229 let model_label = app.model_display_label();
230 let client = match DeepSeekClient::new(config) {
231 Ok(client) => client,
232 Err(err) => {
233 deliver_constitution_draft_result(
234 app,
235 model_label.clone(),
236 locale,
237 Err(format!("provider not ready: {err:#}")),
238 );
239 return;
240 }
241 };
242 let request_model = app.model.clone();
243 let cell = app.constitution_draft_cell.clone();
244 let spawn_label = model_label.clone();
245 let request_gen = app.next_draft_gen();
246 app.status_message = Some(match locale {
247 crate::localization::Locale::ZhHans => {
248 format!(
249 "{model_label} 正在生成协作准则草案……(最多 {}s)",
250 DRAFT_TIMEOUT.as_secs()
251 )
252 }
253 _ => format!(
254 "{model_label} is drafting your constitution… (up to {}s)",
255 DRAFT_TIMEOUT.as_secs()
256 ),
257 });
258 app.needs_redraw = true;
259 tokio::spawn(async move {
260 let outcome = match tokio::time::timeout(
261 DRAFT_TIMEOUT,
262 crate::tui::setup::draft_constitution_with_model(
263 &client,
264 &request_model,
265 draft,
266 freeform_note,
267 locale,
268 ),
269 )
270 .await
271 {
272 Err(_) => Err(format!("timed out after {}s", DRAFT_TIMEOUT.as_secs())),
273 Ok(result) => result,
274 };
275 if let Ok(mut guard) = cell.lock() {
276 *guard = Some((request_gen, spawn_label, locale, outcome));
277 }
278 });
279 }
280
281 /// One-shot fleet-profile draft: same contract as the constitution drafter —
282 /// minimal payload out, untrusted gate in, preview before ratify, degrade to
283 /// the manual authoring flow on any failure.
284 pub(crate) async fn handle_fleet_profile_model_draft(
285 app: &mut App,
286 config: &Config,
287 role: String,
288 model: String,
289 provider: Option<String>,
290 reasoning_effort: Option<String>,
291 locale: crate::localization::Locale,
292 ) {
293 // The route the operator actually picked at `m`-press time (#4093). A
294 // model draft always comes back `provider: None` (the untrusted gate
295 // strips any provider), so this captured `(provider, model)` is what the
296 // ratified profile is pinned to — immune to the model omitting/altering
297 // the route AND to the selection changing while the draft is in flight.
298 // `None` for an `inherit` pick (no concrete route to keep).
299 let picked_route = provider.map(|provider| (provider, model.clone()));
300 // Do NOT await the network call on the event loop — that parks the whole
301 // TUI for up to the timeout (#3757 review). Spawn it into the shared
302 // fleet_draft_cell and let the loop poll + deliver the result, keeping
303 // the wizard interactive with a drafting status.
304 const DRAFT_TIMEOUT: Duration = Duration::from_secs(20);
305 let model_label = app.model_display_label();
306 let client = match DeepSeekClient::new(config) {
307 Ok(client) => client,
308 Err(err) => {
309 deliver_fleet_draft_result(
310 app,
311 model_label.clone(),
312 picked_route.clone(),
313 reasoning_effort.clone(),
314 Err(format!("provider not ready: {err:#}")),
315 locale,
316 );
317 return;
318 }
319 };
320 let request_model = app.model.clone();
321 let cell = app.fleet_draft_cell.clone();
322 let spawn_label = model_label.clone();
323 let request_gen = app.next_draft_gen();
324 let workspace = app.workspace.clone();
325 app.status_message = Some(match locale {
326 crate::localization::Locale::ZhHans => {
327 format!(
328 "{model_label} 正在起草配置……(最多 {}s)",
329 DRAFT_TIMEOUT.as_secs()
330 )
331 }
332 _ => format!(
333 "{model_label} is drafting the profile… (up to {}s)",
334 DRAFT_TIMEOUT.as_secs()
335 ),
336 });
337 app.needs_redraw = true;
338 tokio::spawn(async move {
339 // Redacted, bounded workspace fingerprint (manifest names, test
340 // commands, branch + dirty count — no contents, secrets, or absolute
341 // paths). Computed off the event loop; the untrusted-output gate on
342 // the reply is unchanged.
343 let fingerprint = tokio::task::spawn_blocking(move || {
344 crate::tui::setup::workspace_fingerprint(&workspace)
345 })
346 .await
347 .unwrap_or_default();
348 let outcome = match tokio::time::timeout(
349 DRAFT_TIMEOUT,
350 crate::tui::setup::draft_fleet_profile_with_model(
351 &client,
352 &request_model,
353 &role,
354 &model,
355 locale,
356 &fingerprint,
357 ),
358 )
359 .await
360 {
361 Err(_) => Err(format!("timed out after {}s", DRAFT_TIMEOUT.as_secs())),
362 Ok(result) => result,
363 };
364 if let Ok(mut guard) = cell.lock() {
365 *guard = Some((
366 request_gen,
367 spawn_label,
368 picked_route,
369 reasoning_effort,
370 outcome,
371 ));
372 }
373 });
374 }
375
376 pub(crate) async fn handle_bang_shell_input(
377 app: &mut App,
378 engine_handle: &EngineHandle,
379 input: &str,
380 ) -> Result<bool> {
381 let command = match shell_command_from_bang_input(input) {
382 Ok(Some(command)) => command,
383 Ok(None) => return Ok(false),
384 Err(message) => {
385 app.status_message = Some(format!("Error: {message}"));
386 return Ok(true);
387 }
388 };
389
390 engine_handle
391 .send(Op::RunShellCommand {
392 command: command.to_string(),
393 mode: app.mode,
394 allow_shell: app.allow_shell,
395 trust_mode: app.trust_mode,
396 auto_approve: app_auto_approve_enabled(app),
397 approval_mode: app.approval_mode,
398 })
399 .await?;
400 app.status_message = Some(format!("Shell command submitted: {command}"));
401 Ok(true)
402 }
403
404 pub(crate) async fn handle_mcp_ui_action(
405 app: &mut App,
406 engine_handle: &EngineHandle,
407 config: &Config,
408 action: crate::tui::app::McpUiAction,
409 ) {
410 use crate::mcp::{self, McpWriteStatus};
411
412 let path = app.mcp_config_path.clone();
413 let mut changed = false;
414 let mut message = None;
415 let is_reload = matches!(&action, crate::tui::app::McpUiAction::Reload);
416 let discover = mcp_ui_action_refreshes_discovery(&action);
417
418 let action_result = match action {
419 crate::tui::app::McpUiAction::Show => Ok(()),
420 crate::tui::app::McpUiAction::Init { force } => {
421 changed = true;
422 match mcp::init_config(&path, force) {
423 Ok(McpWriteStatus::Created) => {
424 message = Some(format!("Created MCP config at {}", path.display()));
425 Ok(())
426 }
427 Ok(McpWriteStatus::Overwritten) => {
428 message = Some(format!("Overwrote MCP config at {}", path.display()));
429 Ok(())
430 }
431 Ok(McpWriteStatus::SkippedExists) => {
432 changed = false;
433 message = Some(format!(
434 "MCP config already exists at {} (use /mcp init --force to overwrite)",
435 path.display()
436 ));
437 Ok(())
438 }
439 Err(err) => Err(err),
440 }
441 }
442 crate::tui::app::McpUiAction::AddStdio {
443 name,
444 command,
445 args,
446 } => {
447 changed = true;
448 mcp::add_server_config(&path, name.clone(), Some(command), None, args, None)
449 .map(|()| message = Some(format!("Added MCP stdio server '{name}'")))
450 }
451 crate::tui::app::McpUiAction::AddHttp {
452 name,
453 url,
454 transport,
455 } => {
456 changed = true;
457 mcp::add_server_config(&path, name.clone(), None, Some(url), Vec::new(), transport)
458 .map(|()| message = Some(format!("Added MCP HTTP/SSE server '{name}'")))
459 }
460 crate::tui::app::McpUiAction::Enable { name } => {
461 changed = true;
462 mcp::set_server_enabled(&path, &name, true)
463 .map(|()| message = Some(format!("Enabled MCP server '{name}'")))
464 }
465 crate::tui::app::McpUiAction::Disable { name } => {
466 changed = true;
467 mcp::set_server_enabled(&path, &name, false)
468 .map(|()| message = Some(format!("Disabled MCP server '{name}'")))
469 }
470 crate::tui::app::McpUiAction::Remove { name } => {
471 changed = true;
472 mcp::remove_server_config(&path, &name)
473 .map(|()| message = Some(format!("Removed MCP server '{name}'")))
474 }
475 crate::tui::app::McpUiAction::Login { name, scopes } => {
476 let result = async {
477 let cfg = mcp::load_config_with_workspace_and_plugins(
478 &path,
479 &app.workspace,
480 app.plugin_registry.as_ref(),
481 )?;
482 let server = cfg
483 .servers
484 .get(&name)
485 .ok_or_else(|| anyhow::anyhow!("MCP server '{name}' not found"))?;
486 let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
487 mcp::oauth::perform_oauth_login_for_server(
488 &name,
489 server,
490 explicit_scopes,
491 config.mcp_oauth_callback_port,
492 config.mcp_oauth_callback_url.as_deref(),
493 )
494 .await
495 }
496 .await;
497 result.map(|()| {
498 changed = true;
499 message = Some(format!(
500 "Stored OAuth credentials for MCP server '{name}'. Run /mcp reload to reconnect it."
501 ));
502 })
503 }
504 crate::tui::app::McpUiAction::Logout { name } => {
505 let result = (|| {
506 let cfg = mcp::load_config_with_workspace_and_plugins(
507 &path,
508 &app.workspace,
509 app.plugin_registry.as_ref(),
510 )?;
511 let server = cfg
512 .servers
513 .get(&name)
514 .ok_or_else(|| anyhow::anyhow!("MCP server '{name}' not found"))?;
515 mcp::oauth::delete_oauth_tokens_for_server(&name, server)
516 })();
517 result.map(|deleted| {
518 changed = deleted;
519 message = Some(if deleted {
520 format!(
521 "Deleted stored OAuth credentials for MCP server '{name}'. Run /mcp reload to reconnect it."
522 )
523 } else {
524 format!("No stored OAuth credentials found for MCP server '{name}'.")
525 });
526 })
527 }
528 crate::tui::app::McpUiAction::ImportList => {
529 let text = mcp_external_import_status_text(&app.workspace);
530 message = Some(text);
531 Ok(())
532 }
533 crate::tui::app::McpUiAction::ImportApprove { name } => {
534 match mcp_import_apply(&app.workspace, &path, &name, true) {
535 Ok(msg) => {
536 changed = msg.contains("Imported");
537 message = Some(msg);
538 Ok(())
539 }
540 Err(err) => Err(err),
541 }
542 }
543 crate::tui::app::McpUiAction::ImportDecline { name } => {
544 match mcp_import_apply(&app.workspace, &path, &name, false) {
545 Ok(msg) => {
546 message = Some(msg);
547 Ok(())
548 }
549 Err(err) => Err(err),
550 }
551 }
552 crate::tui::app::McpUiAction::Validate | crate::tui::app::McpUiAction::Reload => Ok(()),
553 };
554
555 if let Err(err) = action_result {
556 add_mcp_message(app, format!("MCP action failed: {err}"));
557 return;
558 }
559
560 if changed {
561 app.mcp_reload_required = true;
562 }
563 if let Some(message) = message {
564 add_mcp_message(app, message);
565 }
566
567 let snapshot_result = if is_reload {
568 match engine_handle.reload_mcp(path.clone()).await {
569 Ok(snapshot) => {
570 app.mcp_reload_required = false;
571 add_mcp_message(app, mcp_reload_summary(&snapshot));
572 Ok(snapshot)
573 }
574 Err(error) => {
575 app.mcp_reload_required = true;
576 Err(error)
577 }
578 }
579 } else if discover {
580 let network_policy = config.network.clone().map(|toml_cfg| {
581 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
582 });
583 mcp::discover_manager_snapshot_with_workspace_and_plugins(
584 &path,
585 &app.workspace,
586 network_policy,
587 app.mcp_reload_required,
588 std::sync::Arc::clone(&app.plugin_registry),
589 )
590 .await
591 } else {
592 mcp::manager_snapshot_from_config_with_workspace_and_plugins(
593 &path,
594 &app.workspace,
595 app.mcp_reload_required,
596 app.plugin_registry.as_ref(),
597 )
598 };
599
600 match snapshot_result {
601 Ok(snapshot) => {
602 if discover {
603 add_mcp_message(
604 app,
605 "MCP discovery refreshed for the UI. Run /mcp reload after config or credential edits to rebuild the live model-visible tool pool.".to_string(),
606 );
607 }
608 // Keep the boot-time MCP-count chip in sync with the live
609 // snapshot so footers and panels reflect post-/mcp edits
610 // (#502).
611 app.mcp_configured_count = snapshot.servers.len();
612 app.mcp_snapshot = Some(snapshot.clone());
613 // #2068: keep the hotbar's MCP-tool actions in sync with the tools
614 // that are actually loaded; the hotbar never connects on its own.
615 app.hotbar_actions.replace_mcp_tools(Some(&snapshot));
616 open_mcp_manager_pager(app, &snapshot);
617 }
618 Err(err) if is_reload => add_mcp_message(
619 app,
620 format!("MCP reload failed; the live tool pool is unchanged: {err}"),
621 ),
622 Err(err) => add_mcp_message(app, format!("MCP snapshot failed: {err}")),
623 }
624 }
625
626 pub(crate) fn handle_shell_job_action(app: &mut App, action: crate::tui::app::ShellJobAction) {
627 let Some(shell_manager) = app.runtime_services.shell_manager.clone() else {
628 add_shell_job_message(app, "No shell session is active.".to_string());
629 return;
630 };
631
632 let mut manager = match shell_manager.lock() {
633 Ok(manager) => manager,
634 Err(_) => {
635 add_shell_job_message(
636 app,
637 "Shell tracking hit an internal error — restart Codewhale to recover.".to_string(),
638 );
639 return;
640 }
641 };
642
643 match action {
644 crate::tui::app::ShellJobAction::List => {
645 let jobs = manager.list_jobs();
646 add_shell_job_message(app, format_shell_job_list(&jobs));
647 }
648 crate::tui::app::ShellJobAction::Show { id } => match manager.inspect_job(&id) {
649 Ok(detail) => open_shell_job_pager(app, &detail),
650 Err(err) => add_shell_job_message(app, format!("Command lookup failed: {err}")),
651 },
652 crate::tui::app::ShellJobAction::Poll { id, wait } => {
653 match manager.poll_delta(&id, wait, if wait { 5_000 } else { 1_000 }) {
654 Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)),
655 Err(err) => add_shell_job_message(app, format!("Command poll failed: {err}")),
656 }
657 }
658 crate::tui::app::ShellJobAction::SendStdin { id, input, close } => {
659 match manager.write_stdin(&id, &input, close) {
660 Ok(()) => match manager.poll_delta(&id, false, 1_000) {
661 Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)),
662 Err(err) => {
663 add_shell_job_message(
664 app,
665 format!("Command input sent; poll failed: {err}"),
666 );
667 }
668 },
669 Err(err) => add_shell_job_message(app, format!("Command input failed: {err}")),
670 }
671 }
672 crate::tui::app::ShellJobAction::Cancel { id } => match manager.kill(&id) {
673 Ok(result) => add_shell_job_message(app, format_shell_poll(&result)),
674 Err(err) => add_shell_job_message(app, format!("Command cancel failed: {err}")),
675 },
676 crate::tui::app::ShellJobAction::CancelAll => match manager.kill_running() {
677 Ok(results) => {
678 let count = results.len();
679 if count == 0 {
680 add_shell_job_message(app, "No running commands to cancel.".to_string());
681 } else {
682 let tasks: Vec<String> = results
683 .iter()
684 .filter_map(|result| result.task_id.clone())
685 .collect();
686 add_shell_job_message(
687 app,
688 format!("Canceled {count} command(s): {}", tasks.join(", ")),
689 );
690 }
691 }
692 Err(err) => add_shell_job_message(app, format!("Command cancel-all failed: {err}")),
693 },
694 }
695 }
696
697 pub(crate) async fn handle_skill_mutation_requested(
698 app: &mut App,
699 request: crate::skills::mutation::SkillMutationRequest,
700 ) {
701 use crate::skills::install::{DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL};
702 use crate::skills::mutation::{MutationContext, SkillMutationOutcome, SkillMutationRequest};
703
704 let focus = match &request {
705 SkillMutationRequest::ImportExternal { source_id, .. } => Some(source_id.clone()),
706 SkillMutationRequest::Update { skill_id, .. }
707 | SkillMutationRequest::Remove { skill_id, .. }
708 | SkillMutationRequest::Trust { skill_id, .. } => Some(skill_id.clone()),
709 SkillMutationRequest::InstallRemote { .. }
710 | SkillMutationRequest::UpdateByName { .. }
711 | SkillMutationRequest::RemoveByName { .. }
712 | SkillMutationRequest::TrustByName { .. } => None,
713 };
714
715 let workspace = app.workspace.clone();
716 let home = crate::config::effective_home_dir();
717 let cfg = crate::config::Config::load(None, None).unwrap_or_default();
718 let network = cfg
719 .network
720 .clone()
721 .map(|policy| policy.into_runtime())
722 .unwrap_or_default();
723 let skills_cfg = cfg.skills.as_ref();
724 let max_size = skills_cfg
725 .and_then(|s| s.max_install_size_bytes)
726 .unwrap_or(DEFAULT_MAX_SIZE_BYTES);
727 let registry_url = skills_cfg
728 .and_then(|s| s.registry_url.clone())
729 .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
730
731 let skills_dir = app.skills_dir.clone();
732 let result = {
733 let ctx = MutationContext {
734 workspace: &workspace,
735 home: home.as_deref(),
736 configured_skills_dir: Some(skills_dir.as_path()),
737 network: &network,
738 max_size,
739 registry_url: &registry_url,
740 };
741 crate::skills::mutation::execute(request, &ctx).await
742 };
743
744 let (status, refresh_skills) = match result {
745 Ok(receipt) => {
746 let msg = match &receipt.outcome {
747 SkillMutationOutcome::Installed => {
748 format!(
749 "Installed '{}' → {}",
750 receipt.name, receipt.safe_target_path
751 )
752 }
753 SkillMutationOutcome::Updated => format!("Updated '{}'", receipt.name),
754 SkillMutationOutcome::NoChange => {
755 format!("'{}': no upstream change", receipt.name)
756 }
757 SkillMutationOutcome::Removed => format!("Removed '{}'", receipt.name),
758 SkillMutationOutcome::Trusted => format!("Trusted '{}'", receipt.name),
759 SkillMutationOutcome::Imported => {
760 format!("Imported '{}' → {}", receipt.name, receipt.safe_target_path)
761 }
762 SkillMutationOutcome::AlreadyPresent => {
763 format!("'{}' already present (exact duplicate)", receipt.name)
764 }
765 SkillMutationOutcome::NeedsApproval(host) => {
766 format!("Needs network approval for {host}")
767 }
768 SkillMutationOutcome::NetworkDenied(host) => {
769 format!("Network denied for {host}")
770 }
771 };
772 let refresh = !matches!(
773 receipt.outcome,
774 SkillMutationOutcome::NeedsApproval(_) | SkillMutationOutcome::NetworkDenied(_)
775 );
776 (msg, refresh)
777 }
778 Err(err) => (format!("Skill mutation failed: {err:#}"), false),
779 };
780
781 app.status_message = Some(status.clone());
782 if refresh_skills {
783 app.refresh_skill_cache();
784 }
785 refresh_skills_manager_if_open(app, Some(status), focus.as_ref());
786 app.needs_redraw = true;
787 }
788
789 #[allow(clippy::too_many_arguments)]
790 pub(crate) async fn handle_config_updated(
791 terminal: &mut AppTerminal,
792 app: &mut App,
793 config: &mut Config,
794 task_manager: &SharedTaskManager,
795 engine_handle: &mut EngineHandle,
796 web_config_session: &mut Option<WebConfigSession>,
797 key: String,
798 value: String,
799 persist: bool,
800 ) -> Result<bool> {
801 let result = prepare_config_update_result(
802 commands::set_config_value(app, &key, &value, persist),
803 persist,
804 );
805 let normalized_value = value.trim().to_ascii_lowercase().replace([' ', '_'], "-");
806 let cleared_root_approval = !result.is_error
807 && persist
808 && key == "approval_policy"
809 && matches!(
810 normalized_value.as_str(),
811 "default" | "tui-default" | "use-tui-default"
812 );
813 // Theme / background changes require a full terminal repaint because
814 // ratatui's incremental diff cannot see colors remapped by the backend.
815 if matches!(
816 key.as_str(),
817 "theme" | "ui_theme" | "background_color" | "background" | "bg"
818 ) {
819 app.force_next_full_repaint = true;
820 }
821 if apply_command_result(
822 terminal,
823 app,
824 engine_handle,
825 task_manager,
826 config,
827 web_config_session,
828 result,
829 )
830 .await?
831 {
832 return Ok(true);
833 }
834
835 let focus_key = if cleared_root_approval {
836 "permission_posture"
837 } else {
838 &key
839 };
840 refresh_config_view_if_open(app, focus_key);
841 Ok(false)
842 }
843
844 #[allow(clippy::too_many_arguments)]
845 pub(crate) async fn handle_view_events(
846 terminal: &mut AppTerminal,
847 app: &mut App,
848 config: &mut Config,
849 task_manager: &SharedTaskManager,
850 engine_handle: &mut EngineHandle,
851 web_config_session: &mut Option<WebConfigSession>,
852 events: Vec<ViewEvent>,
853 ) -> Result<bool> {
854 for event in events {
855 match event {
856 ViewEvent::CommandPaletteSelected { action } => match action {
857 crate::tui::views::CommandPaletteAction::ExecuteCommand { command } => {
858 if execute_command_input(
859 terminal,
860 app,
861 engine_handle,
862 task_manager,
863 config,
864 &mut *web_config_session,
865 &command,
866 )
867 .await?
868 {
869 return Ok(true);
870 }
871 }
872 crate::tui::views::CommandPaletteAction::InsertText { text } => {
873 app.input = text;
874 app.cursor_position = app.input.chars().count();
875 app.status_message = Some(
876 "Inserted into composer. Finish the input or press Enter.".to_string(),
877 );
878 }
879 crate::tui::views::CommandPaletteAction::OpenTextPager { title, content } => {
880 open_text_pager(app, title, content);
881 }
882 },
883 ViewEvent::OpenTextPager { title, content } => {
884 open_text_pager(app, title, content);
885 }
886 ViewEvent::CopyToClipboard { text, label } => {
887 if text.is_empty() {
888 app.status_message = Some(format!("{label} is empty"));
889 } else if app.clipboard.write_text(&text).is_ok() {
890 app.status_message = Some(format!("{label} copied"));
891 } else {
892 app.status_message = Some(format!("Copy failed ({label})"));
893 }
894 }
895 ViewEvent::ApprovalDecision {
896 tool_id,
897 tool_name,
898 decision,
899 timed_out,
900 approval_key,
901 approval_grouping_key,
902 persistent_rules,
903 } => {
904 apply_approval_decision(
905 app,
906 engine_handle,
907 config,
908 ApprovalDecisionEvent {
909 tool_id,
910 tool_name,
911 decision,
912 timed_out,
913 approval_key,
914 approval_grouping_key,
915 persistent_rules,
916 },
917 )
918 .await;
919
920 if timed_out {
921 app.add_message(HistoryCell::System {
922 content: "Approval request timed out - denied".to_string(),
923 });
924 }
925 }
926 ViewEvent::ElevationDecision {
927 tool_id,
928 tool_name,
929 option,
930 } => {
931 use crate::tui::approval::ElevationOption;
932 match option {
933 ElevationOption::Abort => {
934 let _ = engine_handle.deny_tool_call(tool_id).await;
935 app.add_message(HistoryCell::System {
936 content: format!("Sandbox elevation aborted for {tool_name}"),
937 });
938 }
939 ElevationOption::WithNetwork => {
940 app.add_message(HistoryCell::System {
941 content: format!("Retrying {tool_name} with network access enabled"),
942 });
943 let policy = option.to_policy(&app.workspace);
944 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
945 }
946 ElevationOption::WithWriteAccess(_) => {
947 app.add_message(HistoryCell::System {
948 content: format!("Retrying {tool_name} with write access enabled"),
949 });
950 let policy = option.to_policy(&app.workspace);
951 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
952 }
953 ElevationOption::FullAccess => {
954 app.add_message(HistoryCell::System {
955 content: format!("Retrying {tool_name} with full access (no sandbox)"),
956 });
957 let policy = option.to_policy(&app.workspace);
958 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
959 }
960 }
961 }
962 ViewEvent::UserInputSubmitted { tool_id, response } => {
963 match engine_handle
964 .submit_user_input(tool_id.clone(), response)
965 .await
966 {
967 Ok(()) => {
968 app.pending_user_input_prompt = None;
969 }
970 Err(err) => {
971 tracing::warn!(tool_id = %tool_id, error = %err, "user input submit failed");
972 if let Some((id, request)) = app.pending_user_input_prompt.clone() {
973 app.view_stack.push(UserInputView::new(id, request));
974 }
975 app.push_status_toast(
976 format!("Failed to submit response: {err}"),
977 StatusToastLevel::Error,
978 None,
979 );
980 app.status_message =
981 Some(format!("Failed to submit response: {err} — try again"));
982 }
983 }
984 }
985 ViewEvent::UserInputCancelled { tool_id } => {
986 let _ = engine_handle.cancel_user_input(tool_id).await;
987 app.add_message(HistoryCell::System {
988 content: "User input cancelled".to_string(),
989 });
990 }
991 ViewEvent::SessionSelected { session_id } => {
992 let manager = match SessionManager::default_location() {
993 Ok(manager) => manager,
994 Err(err) => {
995 app.status_message =
996 Some(format!("Failed to open sessions directory: {err}"));
997 continue;
998 }
999 };
1000
1001 match manager.load_session(&session_id) {
1002 Ok(session) => {
1003 let next_config = config.clone();
1004 let respawn = match apply_loaded_session_config_snapshot(
1005 app,
1006 config,
1007 &session,
1008 next_config,
1009 false,
1010 ) {
1011 Ok(outcome) => outcome,
1012 Err(err) => {
1013 app.status_message =
1014 Some(format!("Failed to restore session: {err}"));
1015 continue;
1016 }
1017 };
1018 sync_runtime_workspace_state(task_manager, app.workspace.clone()).await;
1019 if respawn {
1020 let _ = engine_handle.send(Op::Shutdown).await;
1021 *engine_handle =
1022 spawn_tui_engine(build_engine_config(app, config), config);
1023 } else {
1024 let _ = engine_handle
1025 .send(Op::SetModel {
1026 model: app.model.clone(),
1027 mode: app.mode,
1028 route_limits: app.active_route_limits,
1029 })
1030 .await;
1031 }
1032 let _ = engine_handle
1033 .send(Op::SyncSession {
1034 session_id: app.current_session_id.clone(),
1035 messages: app.api_messages.clone(),
1036 system_prompt: app.system_prompt.clone(),
1037 system_prompt_override: false,
1038 model: app.model.clone(),
1039 workspace: app.workspace.clone(),
1040 mode: app.mode,
1041 })
1042 .await;
1043 let _ = engine_handle
1044 .send(Op::SetCompaction {
1045 config: app.compaction_config(),
1046 })
1047 .await;
1048 app.status_message = Some(format!(
1049 "Session loaded (ID: {})",
1050 crate::session_manager::truncate_id(&session_id)
1051 ));
1052 app.launch.visible = false;
1053 app.launch.status = None;
1054 }
1055 Err(err) => {
1056 app.status_message = Some(format!(
1057 "Failed to load session {}: {err}",
1058 crate::session_manager::truncate_id(&session_id)
1059 ));
1060 }
1061 }
1062 }
1063 ViewEvent::SessionRenamed { metadata } => {
1064 let session_id = metadata.id.clone();
1065 let title = metadata.title.clone();
1066 let mut work_snapshot_warning = None;
1067 if apply_picker_session_rename_to_active_app(app, *metadata)
1068 && let Ok(manager) = SessionManager::default_location()
1069 {
1070 match build_session_snapshot(app, &manager) {
1071 Ok(session) => {
1072 if let Err(err) = persist_with_pending_work_boundary(
1073 app,
1074 PersistRequest::SessionSnapshot(session),
1075 ) {
1076 tracing::warn!(
1077 session_id = %session_id,
1078 error = %err,
1079 "Could not queue active session rename Work snapshot"
1080 );
1081 work_snapshot_warning = Some(format!(
1082 "Session renamed, but Work snapshot is pending ({err})"
1083 ));
1084 }
1085 }
1086 Err(err) => {
1087 tracing::warn!(
1088 session_id = %session_id,
1089 error = %err,
1090 "Could not queue active session rename snapshot"
1091 );
1092 }
1093 }
1094 }
1095 app.status_message = Some(work_snapshot_warning.unwrap_or_else(|| {
1096 format!(
1097 "Renamed session {} to \"{}\"",
1098 crate::session_manager::truncate_id(&session_id),
1099 title
1100 )
1101 }));
1102 }
1103 ViewEvent::SessionArchived { metadata } => {
1104 // The manager already wrote the flag. Keep the active app's
1105 // cached metadata in step so the next autosave carries the new
1106 // state forward instead of reverting it, and drop the rail
1107 // cache so the row disappears (or returns) immediately.
1108 if let Some(cached) = app.current_session_metadata.as_mut()
1109 && cached.id == metadata.id
1110 {
1111 cached.archived = metadata.archived;
1112 }
1113 app.status_message = Some(format!(
1114 "{} session {} ({})",
1115 if metadata.archived {
1116 "Archived"
1117 } else {
1118 "Restored"
1119 },
1120 crate::session_manager::truncate_id(&metadata.id),
1121 metadata.title
1122 ));
1123 }
1124 ViewEvent::SessionDeleted { session_id, title } => {
1125 app.status_message = Some(format!(
1126 "Deleted session {} ({})",
1127 crate::session_manager::truncate_id(&session_id),
1128 title
1129 ));
1130 }
1131 ViewEvent::ConfigUpdated {
1132 key,
1133 value,
1134 persist,
1135 } => {
1136 if handle_config_updated(
1137 terminal,
1138 app,
1139 config,
1140 task_manager,
1141 engine_handle,
1142 web_config_session,
1143 key,
1144 value,
1145 persist,
1146 )
1147 .await?
1148 {
1149 return Ok(true);
1150 }
1151 }
1152 ViewEvent::StatusItemsUpdated { items, final_save } => {
1153 // Apply to the live App immediately so the footer reflects
1154 // every keystroke (live preview).
1155 app.status_items = items.clone();
1156 app.needs_redraw = true;
1157 if final_save {
1158 match crate::config_persistence::persist_status_items(&items) {
1159 Ok(path) => {
1160 app.status_message =
1161 Some(format!("Status line saved to {}", path.display()));
1162 }
1163 Err(err) => {
1164 app.add_message(HistoryCell::System {
1165 content: format!("Failed to save status line: {err}"),
1166 });
1167 }
1168 }
1169 }
1170 }
1171 ViewEvent::HotbarSetupSaved { bindings } => {
1172 apply_hotbar_setup_saved(app, config, bindings);
1173 }
1174 ViewEvent::SetupStateCommitRequested { state, message } => match state.save() {
1175 Ok(()) => {
1176 app.status_message = Some(message);
1177 }
1178 Err(err) => {
1179 app.status_message = Some(format!("Setup state could not be saved: {err}"));
1180 }
1181 },
1182 ViewEvent::SetupConstitutionCommitRequested {
1183 constitution,
1184 state,
1185 message,
1186 } => match crate::tui::setup::persist_user_constitution_choice(&constitution, &state) {
1187 Ok(()) => {
1188 app.status_message = Some(message);
1189 }
1190 Err(err) => {
1191 app.status_message =
1192 Some(format!("User constitution could not be saved: {err}"));
1193 }
1194 },
1195 ViewEvent::SetupConstitutionModelDraftRequested {
1196 draft,
1197 freeform_note,
1198 locale,
1199 } => {
1200 handle_setup_constitution_model_draft(app, config, draft, freeform_note, locale)
1201 .await;
1202 }
1203 ViewEvent::FleetProfileModelDraftRequested {
1204 role,
1205 model,
1206 provider,
1207 reasoning_effort,
1208 locale,
1209 } => {
1210 handle_fleet_profile_model_draft(
1211 app,
1212 config,
1213 role,
1214 model,
1215 provider,
1216 reasoning_effort,
1217 locale,
1218 )
1219 .await;
1220 }
1221 ViewEvent::FleetRosterOpenSetupRequested { role } => {
1222 // The roster view hands off to the authoring wizard (same
1223 // path as AppAction::OpenFleetSetup), carrying the member role
1224 // already selected so the wizard can begin at Model.
1225 if app.view_stack.top_kind() != Some(ModalKind::FleetSetup) {
1226 let _ = app.next_draft_gen();
1227 app.view_stack.push(
1228 crate::tui::views::fleet_setup::FleetSetupView::new_for_role(
1229 app, config, &role,
1230 ),
1231 );
1232 }
1233 }
1234 ViewEvent::FleetListOpenDetailRequested { name, scope } => {
1235 if app.view_stack.top_kind() != Some(ModalKind::FleetDetail) {
1236 if let Some(view) = crate::tui::views::fleet_detail::FleetDetailView::open(
1237 app, config, &name, scope,
1238 ) {
1239 app.view_stack.push(view);
1240 } else {
1241 app.set_sticky_status(
1242 format!(
1243 "Could not open Fleet `{name}` ({}) — the file may have moved or become unreadable.",
1244 scope.label()
1245 ),
1246 crate::tui::app::StatusToastLevel::Error,
1247 None,
1248 );
1249 }
1250 }
1251 }
1252 ViewEvent::FleetStoreChanged { message } => {
1253 app.status_message = Some(message);
1254 // Refresh the dispatch roster from the fleet-aware source so
1255 // selection changes take effect for the next spawn.
1256 let roster =
1257 crate::fleet::roster::FleetRoster::load(&config.fleet_config(), &app.workspace);
1258 let _ = engine_handle.try_send(Op::SetFleetRoster {
1259 roster: std::sync::Arc::new(roster),
1260 });
1261 }
1262 ViewEvent::FleetRosterOpenFleetsRequested => {
1263 if app.view_stack.top_kind() != Some(ModalKind::FleetList) {
1264 app.view_stack
1265 .push(crate::tui::views::fleet_list::FleetListView::new(
1266 app, config,
1267 ));
1268 }
1269 }
1270 ViewEvent::FleetRosterOpenWorkersRequested => {
1271 if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
1272 let agents = subagent_view_agents(app, &app.subagent_cache);
1273 app.view_stack
1274 .push(crate::tui::views::SubAgentsView::new(agents));
1275 }
1276 app.status_message =
1277 Some(tr(app.ui_locale, MessageId::SubagentsFetching).to_string());
1278 let _ = engine_handle.try_send(Op::ListSubAgents);
1279 }
1280 ViewEvent::FleetSetupExternalConsentActivationRequested { provider_id, model } => {
1281 // Validate the selected Fleet route by minting the read-only
1282 // external credential capability only for this exact
1283 // provider/source/path. The check is route-scoped: a cloned
1284 // config has the target provider active so credential discovery
1285 // succeeds, but the parent session provider/model are never
1286 // mutated.
1287 let Some(provider) = ApiProvider::parse(&provider_id) else {
1288 app.set_sticky_status(
1289 format!("Fleet route activation failed: unknown provider `{provider_id}`"),
1290 crate::tui::app::StatusToastLevel::Error,
1291 None,
1292 );
1293 app.needs_redraw = true;
1294 continue;
1295 };
1296 let provider_label = provider.display_name();
1297 let mut scoped = config.clone();
1298 scoped.provider = Some(provider_id.clone());
1299 let validation =
1300 crate::route_runtime::resolve_runtime_route(&scoped, provider, Some(&model))
1301 .and_then(|route| route.validate().map_err(|err| err.to_string()));
1302 match validation {
1303 Ok(validated) => {
1304 app.provider_health
1305 .record_success(&scoped, provider, &validated.model);
1306 app.push_status_toast(
1307 format!(
1308 "{provider_label} route activated for Fleet: {}",
1309 validated.model
1310 ),
1311 crate::tui::app::StatusToastLevel::Success,
1312 Some(5_000),
1313 );
1314 }
1315 Err(error) => {
1316 let envelope = ErrorEnvelope::new(
1317 ErrorCategory::Authentication,
1318 ErrorSeverity::Error,
1319 false,
1320 "route_validation_failed",
1321 &error,
1322 );
1323 app.provider_health
1324 .record_failure(&scoped, provider, &model, &envelope);
1325 app.push_status_toast(
1326 format!("{provider_label} route activation failed: {error}"),
1327 crate::tui::app::StatusToastLevel::Error,
1328 None,
1329 );
1330 }
1331 }
1332 // Refresh the Fleet setup view from a snapshot built against the
1333 // updated health state so the activated row becomes Ready
1334 // without closing the modal.
1335 if app.view_stack.top_kind() == Some(crate::tui::views::ModalKind::FleetSetup)
1336 && let Some(view) = app.view_stack.pop()
1337 {
1338 let mut restored = view;
1339 if let Some(fleet_setup) = restored
1340 .as_any_mut()
1341 .downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>(
1342 ) {
1343 let fresh = crate::tui::views::fleet_setup::FleetSetupSnapshot::from_app(
1344 app, config,
1345 );
1346 fleet_setup.refresh_from_snapshot(fresh);
1347 }
1348 app.view_stack.push_boxed(restored);
1349 }
1350 app.needs_redraw = true;
1351 }
1352 ViewEvent::FleetProfileDraftCommitRequested { draft, scope } => {
1353 // The TOML is rendered deterministically from the validated
1354 // draft and written atomically; the target path is derived
1355 // from the sanitized id, never model-chosen.
1356 let profile_dir =
1357 match crate::fleet::profile::agent_profile_dir_for_scope(scope, &app.workspace)
1358 {
1359 Ok(dir) => dir,
1360 Err(err) => {
1361 app.set_sticky_status(
1362 format!("Fleet {} scope is unavailable: {err:#}", scope.label()),
1363 StatusToastLevel::Error,
1364 None,
1365 );
1366 app.needs_redraw = true;
1367 continue;
1368 }
1369 };
1370 let target = profile_dir.join(draft.file_name());
1371 // A ratified profile must not silently clobber a differently
1372 // named existing profile that shares this id (which would also
1373 // make the whole agents dir fail to load on the duplicate).
1374 // Overwriting the SAME file is fine — that is an intentional
1375 // re-draft of this profile.
1376 // The collision gate only needs file identities. Accept
1377 // otherwise legacy profile fields here so an old, unrelated
1378 // profile cannot block saving a current one. Malformed TOML,
1379 // unreadable files, and invalid ids still fail closed because
1380 // then we cannot prove there is no collision.
1381 let existing_profiles =
1382 crate::fleet::profile::load_agent_profile_identities_from_dir(&profile_dir);
1383 if let Err(err) = &existing_profiles {
1384 let message = tr(app.ui_locale, MessageId::FleetProfileIdentityVerifyFailed)
1385 .replace("{error}", &format!("{err:#}"));
1386 app.set_sticky_status(message, StatusToastLevel::Error, None);
1387 app.needs_redraw = true;
1388 continue;
1389 }
1390 let id_conflict = existing_profiles
1391 .into_iter()
1392 .flatten()
1393 .find(|p| p.id.eq_ignore_ascii_case(&draft.id) && p.source != target);
1394 if let Some(existing) = id_conflict {
1395 let message = tr(app.ui_locale, MessageId::FleetProfileIdConflict)
1396 .replace("{id}", &draft.id)
1397 .replace("{path}", &existing.source.display().to_string());
1398 app.set_sticky_status(message, StatusToastLevel::Error, None);
1399 app.needs_redraw = true;
1400 continue;
1401 }
1402 // #4093 AC #5: a profile may only pin a provider the operator
1403 // has actually configured/credentialed. The picker already
1404 // offers models only from configured providers, but a
1405 // model-drafted or hand-edited route (or credentials removed
1406 // after the pick) could still name an unconfigured one — which
1407 // would fail loudly at launch. Catch it at save time with a
1408 // clear message, reusing the SAME predicate the picker uses.
1409 if let Some(provider_id) = draft.provider.as_deref()
1410 && let Some(provider) = crate::config::ApiProvider::parse(provider_id)
1411 && !crate::config::provider_is_configured_for_active(
1412 config,
1413 provider,
1414 app.api_provider,
1415 )
1416 {
1417 let message = tr(app.ui_locale, MessageId::FleetProfileProviderUnconfigured)
1418 .replace("{provider}", provider_id)
1419 .replace("{env}", &provider.env_vars_label());
1420 app.set_sticky_status(message, StatusToastLevel::Error, None);
1421 app.needs_redraw = true;
1422 continue;
1423 }
1424 let mut txn = codewhale_config::persistence::SetupTransaction::new();
1425 txn.stage(target.clone(), draft.render_toml().into_bytes());
1426 match txn.commit() {
1427 Ok(()) => {
1428 let roster = std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
1429 &config.fleet_config(),
1430 &app.workspace,
1431 ));
1432 let roster_refresh_failed = engine_handle
1433 .try_send(Op::SetFleetRoster { roster })
1434 .is_err();
1435 let zh = app.ui_locale == crate::localization::Locale::ZhHans;
1436 app.add_message(HistoryCell::System {
1437 content: if zh {
1438 format!("已保存 Fleet 配置:{}", target.display())
1439 } else {
1440 format!(
1441 "Fleet {} profile saved: {}",
1442 scope.label(),
1443 target.display()
1444 )
1445 },
1446 });
1447 app.status_message = Some(if zh {
1448 format!("已保存 Fleet 配置:{}", draft.file_name())
1449 } else if roster_refresh_failed {
1450 format!(
1451 "Fleet {} profile saved, but the live roster could not refresh; restart before dispatching {}",
1452 scope.label(),
1453 draft.id
1454 )
1455 } else {
1456 format!(
1457 "Fleet {} profile saved: {}",
1458 scope.label(),
1459 draft.file_name()
1460 )
1461 });
1462 }
1463 Err(err) => {
1464 app.status_message =
1465 Some(if app.ui_locale == crate::localization::Locale::ZhHans {
1466 format!("无法保存 Fleet 配置:{err:#}")
1467 } else {
1468 format!("Fleet profile could not be saved: {err:#}")
1469 });
1470 }
1471 }
1472 app.needs_redraw = true;
1473 }
1474 ViewEvent::SetupRuntimePresetApplyRequested {
1475 preset,
1476 state,
1477 message,
1478 } => match apply_setup_runtime_preset(app, config, preset, state) {
1479 Ok(summary) => {
1480 sync_mode_update(app, engine_handle).await;
1481 app.status_message = Some(format!("{message} {summary}"));
1482 }
1483 Err(err) => {
1484 app.status_message =
1485 Some(format!("Runtime preset could not be applied: {err:#}"));
1486 }
1487 },
1488 ViewEvent::SetupOpenProviderRequested => {
1489 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
1490 let runtime_status = query_provider_runtime_status(engine_handle).await;
1491 app.view_stack.push(
1492 crate::tui::provider_picker::ProviderPickerView::new_for_setup(
1493 app.api_provider,
1494 Some(app.api_provider),
1495 config,
1496 runtime_status,
1497 )
1498 .with_locale(app.ui_locale)
1499 .with_provider_health(&app.provider_health),
1500 );
1501 app.status_message =
1502 Some("Provider setup opened from /setup readiness.".to_string());
1503 }
1504 }
1505 ViewEvent::SetupOpenModelRequested => {
1506 if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
1507 open_model_picker_for_provider(app, config, app.api_provider);
1508 app.status_message =
1509 Some("Model route picker opened from /setup readiness.".to_string());
1510 }
1511 }
1512 ViewEvent::SetupOpenFleetRequested => {
1513 if app.view_stack.top_kind() != Some(ModalKind::FleetSetup) {
1514 let _ = app.next_draft_gen();
1515 app.view_stack
1516 .push(crate::tui::views::fleet_setup::FleetSetupView::new(
1517 app, config,
1518 ));
1519 app.status_message =
1520 Some("Fleet setup opened from /setup Operate/Fleet readiness.".to_string());
1521 }
1522 }
1523 ViewEvent::SetupOpenHotbarRequested => {
1524 if app.view_stack.top_kind() != Some(ModalKind::HotbarSetup) {
1525 app.view_stack
1526 .push(crate::tui::hotbar::setup::HotbarSetupView::new(app, config));
1527 app.status_message =
1528 Some("Hotbar setup opened from /setup Hotbar readiness.".to_string());
1529 }
1530 }
1531 ViewEvent::SetupOpenModeRequested => {
1532 if app.view_stack.top_kind() != Some(ModalKind::ModePicker) {
1533 app.view_stack
1534 .push(crate::tui::views::mode_picker::ModePickerView::new(
1535 app.mode,
1536 app.ui_locale,
1537 ));
1538 app.status_message =
1539 Some("Work mode picker opened from /setup runtime posture.".to_string());
1540 }
1541 }
1542 ViewEvent::SetupOpenConfigRequested => {
1543 if app.view_stack.top_kind() != Some(ModalKind::Config) {
1544 app.view_stack.push(ConfigView::new_for_app(app));
1545 app.status_message =
1546 Some("Config view opened from /setup runtime posture.".to_string());
1547 }
1548 }
1549 ViewEvent::HotbarDisableRequested => {
1550 disable_hotbar(app, config);
1551 }
1552 ViewEvent::SubAgentsRefresh => {
1553 app.status_message = Some("Refreshing sub-agents...".to_string());
1554 // #3802: non-blocking send — refresh op, safe to drop.
1555 let _ = engine_handle.try_send(Op::ListSubAgents);
1556 }
1557 ViewEvent::SidebarAgentCancel { agent_id } => {
1558 app.status_message = Some(format!("Cancelling {agent_id}..."));
1559 if engine_handle
1560 .send(Op::CancelSubAgent {
1561 agent_id: agent_id.clone(),
1562 })
1563 .await
1564 .is_err()
1565 {
1566 app.status_message = Some(format!("Could not cancel {agent_id}"));
1567 }
1568 }
1569 ViewEvent::OpenAgentTranscript { agent_id } => {
1570 if !crate::tui::mouse_ui::open_agent_chat_pager(app, &agent_id) {
1571 app.status_message = Some("Exact agent transcript is unavailable".to_string());
1572 }
1573 app.needs_redraw = true;
1574 }
1575 ViewEvent::AgentDetailsClosed { agent_id } => {
1576 crate::tui::work_surface::agent_details_closed(app, &agent_id);
1577 }
1578 ViewEvent::FilePickerSelected { path } => {
1579 // Insert `@<path>` at the composer's cursor with surrounding
1580 // whitespace so the existing `@`-mention parser picks it up.
1581 let cursor = app.cursor_position;
1582 let needs_leading_space = cursor > 0
1583 && !app
1584 .input
1585 .chars()
1586 .nth(cursor.saturating_sub(1))
1587 .is_some_and(|c| c.is_whitespace());
1588 let mut insertion = String::new();
1589 if needs_leading_space {
1590 insertion.push(' ');
1591 }
1592 insertion.push('@');
1593 insertion.push_str(&path);
1594 insertion.push(' ');
1595 app.insert_str(&insertion);
1596 app.status_message = Some(format!("Attached @{path}"));
1597 }
1598 ViewEvent::ModelPickerApplied {
1599 model,
1600 provider,
1601 provider_id,
1602 effort,
1603 previous_model,
1604 previous_effort,
1605 save_as_startup_default,
1606 } => {
1607 apply_model_picker_choice(
1608 app,
1609 engine_handle,
1610 config,
1611 model,
1612 provider,
1613 provider_id,
1614 effort,
1615 previous_model,
1616 previous_effort,
1617 save_as_startup_default,
1618 )
1619 .await;
1620 }
1621 ViewEvent::ModelPickerDismissed {
1622 catalog_view,
1623 view,
1624 selected_row_id,
1625 } => {
1626 sync_config_provider_from_app(config, app);
1627 app.model_picker_memory = Some(crate::tui::app::ModelPickerMemory {
1628 catalog_view,
1629 view: Some(view),
1630 selected_row_id,
1631 });
1632 }
1633 ViewEvent::ModelPickerRefresh => {
1634 // Re-resolve readiness from the live credential state and
1635 // rebuild catalog rows. Non-destructive: never clears the list
1636 // when a refresh fails; just re-project from current config.
1637 sync_config_provider_from_app(config, app);
1638 if app.view_stack.top_kind() == Some(ModalKind::ModelPicker)
1639 && let Some(mut boxed) = app.view_stack.pop()
1640 {
1641 if let Some(picker) = boxed
1642 .as_any_mut()
1643 .downcast_mut::<crate::tui::model_picker::ModelPickerView>(
1644 ) {
1645 picker.re_resolve_from_app(app, config);
1646 app.status_message =
1647 Some("Model readiness refreshed · catalog rows rebuilt".into());
1648 }
1649 app.view_stack.push_boxed(boxed);
1650 } else {
1651 app.status_message =
1652 Some("Open /model to refresh readiness and catalog".into());
1653 }
1654 app.needs_redraw = true;
1655 }
1656 ViewEvent::ModelPickerTogglePin {
1657 provider,
1658 provider_id,
1659 model,
1660 } => {
1661 let provider_key = provider_id.unwrap_or_else(|| provider.as_str().to_string());
1662 match crate::settings::Settings::transact(|settings| {
1663 Ok(settings.toggle_pinned_model(&provider_key, &model))
1664 }) {
1665 Ok(true) => app.status_message = Some(format!("Pinned {provider_key}/{model}")),
1666 Ok(false) => {
1667 app.status_message = Some(format!("Unpinned {provider_key}/{model}"))
1668 }
1669 Err(error) => {
1670 app.status_message = Some(format!("Could not update pin: {error}"))
1671 }
1672 }
1673 if let Ok(settings) = crate::settings::Settings::load_persisted() {
1674 app.pinned_models = settings.pinned_models;
1675 }
1676 if let Some(mut boxed) = app.view_stack.pop() {
1677 if let Some(picker) = boxed
1678 .as_any_mut()
1679 .downcast_mut::<crate::tui::model_picker::ModelPickerView>(
1680 ) {
1681 picker.re_resolve_from_app(app, config);
1682 }
1683 app.view_stack.push_boxed(boxed);
1684 }
1685 app.needs_redraw = true;
1686 }
1687 ViewEvent::ModelPickerMovePin {
1688 provider,
1689 provider_id,
1690 model,
1691 delta,
1692 } => {
1693 let provider_key = provider_id.unwrap_or_else(|| provider.as_str().to_string());
1694 let reordered = crate::settings::Settings::transact_opt(|settings| {
1695 if !settings.move_pinned_model(&provider_key, &model, delta) {
1696 return Ok(None);
1697 }
1698 Ok(Some(settings.pinned_models.clone()))
1699 });
1700 match reordered {
1701 Ok(None) => {}
1702 Ok(Some(pinned_models)) => {
1703 app.pinned_models = pinned_models;
1704 app.status_message = Some("Pinned model order updated".into());
1705 if let Some(mut boxed) = app.view_stack.pop() {
1706 if let Some(picker) = boxed
1707 .as_any_mut()
1708 .downcast_mut::<crate::tui::model_picker::ModelPickerView>(
1709 ) {
1710 picker.re_resolve_from_app(app, config);
1711 }
1712 app.view_stack.push_boxed(boxed);
1713 }
1714 }
1715 Err(error) => {
1716 app.status_message = Some(format!("Could not reorder pin: {error}"));
1717 }
1718 }
1719 app.needs_redraw = true;
1720 }
1721 ViewEvent::ModelPickerNeedsAuth {
1722 provider,
1723 model,
1724 reason,
1725 } => {
1726 app.status_message = Some(reason);
1727 // Close the model picker if it is still open, then hand off to
1728 // the provider auth flow for the locked model's provider.
1729 while app.view_stack.top_kind() == Some(ModalKind::ModelPicker) {
1730 let _ = app.view_stack.pop();
1731 }
1732 if let Some(picker) =
1733 crate::tui::provider_picker::ProviderPickerView::new_for_missing_auth(
1734 app.api_provider,
1735 provider,
1736 config,
1737 None,
1738 )
1739 {
1740 app.view_stack.push(picker);
1741 } else {
1742 app.status_message = Some(format!(
1743 "🔒 {model} needs {provider:?} credentials — open /provider to authenticate."
1744 ));
1745 }
1746 app.needs_redraw = true;
1747 }
1748 ViewEvent::StatusMessage { message } => {
1749 app.status_message = Some(message);
1750 app.needs_redraw = true;
1751 }
1752 ViewEvent::ProviderPickerDismissed {
1753 catalog_view,
1754 selected_provider_id,
1755 } => {
1756 let onboarding_provider_picker = app.onboarding == OnboardingState::Provider;
1757 // A picker preview must never become route authority. During
1758 // onboarding Esc is deliberately non-mutating: it returns to
1759 // Language without touching config or the onboarding marker.
1760 if !onboarding_provider_picker {
1761 sync_config_provider_from_app(config, app);
1762 }
1763 app.provider_picker_memory = Some(crate::tui::app::ProviderPickerMemory {
1764 catalog_view,
1765 selected_provider_id,
1766 });
1767 if onboarding_provider_picker {
1768 back_from_provider_onboarding(app);
1769 }
1770 }
1771 ViewEvent::ProviderPickerApplied {
1772 provider,
1773 provider_id,
1774 } => {
1775 if let Some(provider_id) = provider_id {
1776 set_active_custom_provider_in_memory(config, &provider_id);
1777 }
1778 let model_override = provider_picker_model_override(app, config, provider);
1779 let switched =
1780 switch_provider(app, engine_handle, config, provider, model_override).await;
1781 if switched && app.onboarding == OnboardingState::Provider {
1782 complete_provider_picker_onboarding(app, provider);
1783 }
1784 refresh_config_view_if_open(app, "provider");
1785 }
1786 ViewEvent::ProviderPickerApiKeySubmitted {
1787 provider,
1788 provider_id,
1789 api_key,
1790 base_url,
1791 } => {
1792 let identity = picker_provider_identity(config, provider, provider_id.as_deref())
1793 .map_err(anyhow::Error::msg)?;
1794 apply_provider_picker_api_key(
1795 app,
1796 engine_handle,
1797 config,
1798 identity,
1799 api_key,
1800 base_url,
1801 )
1802 .await;
1803 refresh_config_view_if_open(app, "provider");
1804 }
1805 ViewEvent::ProviderPickerSetupConfirmed {
1806 provider,
1807 provider_id,
1808 api_key,
1809 model,
1810 context_window,
1811 base_url,
1812 } => {
1813 let identity = picker_provider_identity(config, provider, provider_id.as_deref())
1814 .map_err(anyhow::Error::msg)?;
1815 let completed = apply_provider_picker_setup_confirmed(
1816 app,
1817 engine_handle,
1818 config,
1819 identity,
1820 api_key,
1821 model,
1822 context_window,
1823 base_url,
1824 )
1825 .await;
1826 if completed && app.onboarding == OnboardingState::Provider {
1827 complete_provider_picker_onboarding(app, provider);
1828 }
1829 refresh_config_view_if_open(app, "provider");
1830 }
1831 ViewEvent::ProviderPickerCustomProviderSubmitted {
1832 provider_id,
1833 base_url,
1834 model,
1835 api_key_env,
1836 } => {
1837 let switched = apply_provider_picker_custom_provider(
1838 app,
1839 engine_handle,
1840 config,
1841 provider_id,
1842 base_url,
1843 model,
1844 api_key_env,
1845 )
1846 .await;
1847 complete_provider_picker_onboarding_if_switched(app, ApiProvider::Custom, switched);
1848 refresh_config_view_if_open(app, "provider");
1849 }
1850 ViewEvent::ProviderPickerXaiOAuthRequested => {
1851 let switched =
1852 run_xai_device_login_from_tui(terminal, app, engine_handle, config).await?;
1853 complete_provider_picker_onboarding_if_switched(app, ApiProvider::Xai, switched);
1854 }
1855 ViewEvent::ProviderPickerExternalConsentConfirmed {
1856 provider,
1857 consent_provider,
1858 source,
1859 path,
1860 } => match persist_external_credential_consent_for_at(
1861 app.config_path.as_deref(),
1862 config,
1863 provider,
1864 consent_provider,
1865 source,
1866 &path,
1867 ) {
1868 Ok(_) => {
1869 let toast = app
1870 .tr(MessageId::ProviderExternalGrantedToast)
1871 .replace("{owner}", source.owner_label())
1872 .replace("{provider}", provider.as_str());
1873 app.push_status_toast(toast, StatusToastLevel::Success, Some(8_000));
1874 let model_override = provider_picker_model_override(app, config, provider);
1875 let switched =
1876 switch_provider(app, engine_handle, config, provider, model_override).await;
1877 // #4763: reusing an external CLI grant completes provider
1878 // onboarding exactly like a submitted key or an applied
1879 // route. Without this the picker closes on success and
1880 // the user is returned to the provider step they just
1881 // satisfied — the second half of the reported loop.
1882 if switched && app.onboarding == OnboardingState::Provider {
1883 complete_provider_picker_onboarding(app, provider);
1884 }
1885 refresh_config_view_if_open(app, "provider");
1886 }
1887 Err(error) => app.push_status_toast(
1888 app.tr(MessageId::ProviderExternalSaveFailedToast)
1889 .replace("{error}", &error.to_string()),
1890 StatusToastLevel::Error,
1891 None,
1892 ),
1893 },
1894 ViewEvent::ProviderPickerExternalConsentRevoked { provider } => {
1895 match revoke_external_credential_consent_for_at(
1896 app.config_path.as_deref(),
1897 config,
1898 provider,
1899 ) {
1900 Ok(_) => app.push_status_toast(
1901 app.tr(MessageId::ProviderExternalRevokedToast)
1902 .replace("{provider}", provider.as_str()),
1903 StatusToastLevel::Success,
1904 Some(5_000),
1905 ),
1906 Err(error) => app.push_status_toast(
1907 app.tr(MessageId::ProviderExternalRevokeFailedToast)
1908 .replace("{error}", &error.to_string()),
1909 StatusToastLevel::Error,
1910 None,
1911 ),
1912 }
1913 refresh_config_view_if_open(app, "provider");
1914 }
1915 ViewEvent::ProviderPickerOpenModels {
1916 provider,
1917 provider_id,
1918 } => {
1919 if let Some(provider_id) = provider_id {
1920 set_active_custom_provider_in_memory(config, &provider_id);
1921 }
1922 open_model_picker_for_provider(app, config, provider);
1923 }
1924 ViewEvent::ModeSelected { mode } => {
1925 let prior_mode = app.mode;
1926 let msg = commands::switch_mode(app, mode);
1927 if app.mode != prior_mode {
1928 sync_mode_update(app, engine_handle).await;
1929 }
1930 app.add_message(HistoryCell::System { content: msg });
1931 }
1932 ViewEvent::BacktrackStep { direction } => {
1933 app.backtrack.step(direction);
1934 if let Some(idx) = app.backtrack.selected_idx() {
1935 update_backtrack_overlay_selection(app, idx);
1936 }
1937 }
1938 ViewEvent::BacktrackConfirm => {
1939 if let Some(depth) = app.backtrack.confirm() {
1940 apply_backtrack(app, depth);
1941 let _ = engine_handle
1942 .send(Op::SyncSession {
1943 session_id: app.current_session_id.clone(),
1944 messages: app.api_messages.clone(),
1945 system_prompt: app.system_prompt.clone(),
1946 system_prompt_override: false,
1947 model: app.model.clone(),
1948 workspace: app.workspace.clone(),
1949 mode: app.mode,
1950 })
1951 .await;
1952 }
1953 }
1954 ViewEvent::BacktrackCancel => {
1955 app.backtrack.reset();
1956 app.status_message = Some("Backtrack canceled".to_string());
1957 app.needs_redraw = true;
1958 }
1959 ViewEvent::ContextMenuSelected {
1960 action: ContextMenuAction::ExecuteCommand { command },
1961 } => {
1962 if execute_command_input(
1963 terminal,
1964 app,
1965 engine_handle,
1966 task_manager,
1967 config,
1968 &mut *web_config_session,
1969 &command,
1970 )
1971 .await?
1972 {
1973 return Ok(true);
1974 }
1975 }
1976 ViewEvent::ContextMenuSelected { action } => handle_context_menu_action(app, action),
1977 ViewEvent::SkillMutationRequested { request } => {
1978 handle_skill_mutation_requested(app, request).await;
1979 }
1980 ViewEvent::SkillsManagerToggleCompatible => {
1981 if app.view_stack.top_kind() == Some(ModalKind::SkillsManager)
1982 && let Some(mut boxed) = app.view_stack.pop()
1983 {
1984 if let Some(view) = boxed
1985 .as_any_mut()
1986 .downcast_mut::<crate::tui::views::skills_manager::SkillsManagerView>(
1987 ) {
1988 crate::tui::views::skills_manager::apply_toggle_compatible(view, app);
1989 }
1990 app.view_stack.push_boxed(boxed);
1991 }
1992 }
1993 }
1994 }
1995
1996 Ok(false)
1997 }
1998
1999 /// Keep the very large modal-event dispatcher out of the already-large TUI
2000 /// loop future. Config previews take a dedicated small path: polling the full
2001 /// dispatcher on top of the event loop exceeds the macOS main-thread stack in
2002 /// debug builds before a theme preview can reach its next frame.
2003 #[allow(clippy::too_many_arguments)]
2004 pub(crate) fn handle_view_events_boxed<'a>(
2005 terminal: &'a mut AppTerminal,
2006 app: &'a mut App,
2007 config: &'a mut Config,
2008 task_manager: &'a SharedTaskManager,
2009 engine_handle: &'a mut EngineHandle,
2010 web_config_session: &'a mut Option<WebConfigSession>,
2011 events: Vec<ViewEvent>,
2012 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + 'a>> {
2013 Box::pin(async move {
2014 for event in events {
2015 match event {
2016 ViewEvent::ConfigUpdated {
2017 key,
2018 value,
2019 persist,
2020 } => {
2021 if handle_config_updated(
2022 terminal,
2023 app,
2024 config,
2025 task_manager,
2026 engine_handle,
2027 web_config_session,
2028 key,
2029 value,
2030 persist,
2031 )
2032 .await?
2033 {
2034 return Ok(true);
2035 }
2036 }
2037 other => {
2038 if Box::pin(handle_view_events(
2039 terminal,
2040 app,
2041 config,
2042 task_manager,
2043 engine_handle,
2044 web_config_session,
2045 vec![other],
2046 ))
2047 .await?
2048 {
2049 return Ok(true);
2050 }
2051 }
2052 }
2053 }
2054 Ok(false)
2055 })
2056 }
2057
2057 lines RUST