返回 CodeWhale
ui.rs
根目录 / crates / tui / src / tui / ui.rs
1 //! TUI event loop and rendering logic for `DeepSeek` CLI.
2
3 use std::collections::{HashSet, VecDeque};
4 use std::fmt::Write as _;
5 use std::future::Future;
6 use std::io::{self, IsTerminal, Stdout, Write};
7 use std::path::{Path, PathBuf};
8 use std::pin::Pin;
9 use std::sync::{
10 Arc, LazyLock,
11 atomic::{AtomicBool, Ordering},
12 };
13 use std::time::{Duration, Instant};
14
15 use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, ErrorSeverity};
16 use crate::resource_telemetry::estimate_output_tokens_from_text;
17 use anyhow::{Context, Result};
18 use codewhale_config::AppMode;
19 use codewhale_core::ContextReference;
20 use codewhale_execpolicy::ApprovalMode;
21 use codewhale_release::InstallMethod;
22 // On Windows the push/pop helpers write the escapes directly; crossterm's
23 // PushKeyboardEnhancementFlags / PopKeyboardEnhancementFlags commands are
24 // never referenced, so the imports are gated to avoid -D warnings failures.
25 #[cfg(not(windows))]
26 use crossterm::event::{
27 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
28 };
29 use crossterm::{
30 event::{
31 self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
32 EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
33 KeyModifiers,
34 },
35 execute,
36 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
37 };
38 use ratatui::{
39 Frame, Terminal,
40 layout::{Constraint, Direction, Layout, Rect, Size},
41 prelude::Widget,
42 style::Style,
43 widgets::Block,
44 };
45 use tracing;
46 #[cfg(target_os = "windows")]
47 use windows::Win32::System::Console::{GetConsoleMode, GetStdHandle, SetConsoleMode};
48
49 use crate::audit::log_sensitive_event;
50 use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler};
51 use crate::client::{
52 CACHE_WARMUP_MAX_TOKENS, CacheWarmupKey, CodewhaleClient, PromptInspection,
53 build_cache_warmup_request, inspect_prompt_for_request,
54 };
55 use crate::commands;
56 use crate::compaction::CompactionConfig;
57 use crate::compaction::{estimate_input_tokens_conservative, estimate_tokens};
58 use crate::config::{
59 ApiProvider, Config, ProviderConfig, ProviderIdentity, ProvidersConfig, StatusItem,
60 UpdateConfig, persist_external_credential_consent_for_at,
61 revoke_external_credential_consent_for_at,
62 };
63 use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
64 use crate::core::events::Event as EngineEvent;
65 use crate::core::ops::{Op, ProviderRuntimeStatus, USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance};
66 use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals};
67 use crate::llm_client::LlmClient;
68 use crate::prompts;
69 use crate::route_runtime::{resolve_runtime_route, resolve_runtime_route_for_identity};
70 #[cfg(test)]
71 use crate::session_manager::create_saved_session_with_id_and_mode;
72 use crate::session_manager::{
73 OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager,
74 };
75 use crate::settings::Settings;
76 use crate::task_manager::{
77 NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, TaskSummary,
78 };
79 use crate::tools::goal::{GoalSnapshot, GoalStatus};
80 use crate::tools::shell::{ShellJobSnapshot, ShellStatus};
81 use crate::tools::spec::{RuntimeToolServices, ToolResult};
82 use crate::tools::subagent::{MailboxMessage, SubAgentStatus, subagent_progress_tool_display_name};
83 use crate::tui::auto_router;
84 use crate::tui::clipboard::ClipboardContent;
85 use crate::tui::color_compat::ColorCompatBackend;
86 use crate::tui::command_palette::{
87 CommandPaletteView, build_entries_with_plugins as build_command_palette_entries,
88 };
89 use crate::tui::composer_ui::*;
90 use crate::tui::context_inspector::ContextInspectorView;
91 use crate::tui::event_broker::EventBroker;
92 use crate::tui::file_picker_relevance;
93 use crate::tui::footer_ui::friendly_subagent_progress;
94 use crate::tui::format_helpers;
95 use crate::tui::hotbar::actions::HotbarDispatch;
96 use crate::tui::key_shortcuts;
97 use crate::tui::live_transcript::LiveTranscriptOverlay;
98 use crate::tui::mcp_routing::{add_mcp_message, open_mcp_extensions};
99 use crate::tui::mouse_ui::*;
100 use crate::tui::notifications;
101 use crate::tui::onboarding;
102 use crate::tui::pager::PagerView;
103 use crate::tui::persistence_actor::{self, PersistRequest};
104 use crate::tui::scrolling::TranscriptScroll;
105 use crate::turn_route_plan::{PlannedTurnRoute, TurnRoutePlanRequest, plan_turn_route};
106 use crate::work_graph::task_owner_snapshot;
107 use codewhale_localization::{MessageId, tr};
108 use codewhale_models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
109 use codewhale_palette as palette;
110 // SelectionAutoscroll unused
111 use crate::tui::motion::{FrameRequester, MotionMode};
112 use crate::tui::session_picker::SessionPickerView;
113 use crate::tui::shell_job_routing::{
114 add_shell_job_message, format_shell_job_list, format_shell_poll, open_shell_job_pager,
115 };
116 use crate::tui::streaming::StreamDisplayClock;
117 use crate::tui::streaming_thinking;
118 use crate::tui::subagent_routing::{
119 apply_subagent_terminal_projection, format_task_list, handle_subagent_mailbox_for_turn,
120 open_task_pager, parent_stop_status, reconcile_subagent_activity_state, running_agent_count,
121 sort_subagents_in_place, subagent_message_refreshes_workspace_context, task_mode_label,
122 task_summary_to_panel_entry,
123 };
124 #[cfg(test)]
125 use crate::tui::subagent_routing::{handle_subagent_mailbox, reconcile_subagent_activity_state_at};
126 #[cfg(test)]
127 use crate::tui::tool_routing::exploring_label;
128 use crate::tui::tool_routing::{
129 apply_owned_workflow_ui_event, handle_tool_call_complete, handle_tool_call_started,
130 };
131 use crate::tui::ui_text::history_cell_to_text;
132 use crate::tui::user_input::UserInputView;
133 use crate::tui::views::subagent_view_agents;
134 use crate::tui::vim_mode;
135 use crate::tui::workspace_context;
136
137 use crate::reasoning_preference::{EffectiveReasoningEffort, ReasoningEffort};
138
139 use super::key_actions;
140
141 use super::app::{
142 ActiveCompaction, ActiveTurnMetadata, AgentCurrentActivity, App, AppAction,
143 ComposerSubmitAction, ComposerSubmitChord, GoalControlIntent, OnboardingState,
144 PendingGoalControl, PendingProviderSwitch, QueuedMessage, RedactionGateNotice, ScreenMode,
145 StatusToast, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind,
146 ToolEvidence, TuiOptions, bound_agent_activity_text, is_stop_word,
147 looks_like_slash_command_input, shell_command_from_bang_input,
148 };
149 use super::approval::{
150 ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
151 };
152 use super::history::{
153 ExecCell, HistoryCell, ReasoningAction, ToolCell, ToolStatus, history_cells_from_message,
154 summarize_tool_output,
155 };
156 use super::slash_menu::{
157 apply_slash_menu_selection, partial_inline_skill_mention_at_cursor,
158 try_autocomplete_slash_command, visible_slash_menu_entries,
159 };
160 use super::views::{ConfigView, ContextMenuAction, HelpView, ModalKind, ViewAction, ViewEvent};
161 use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview};
162 use super::widgets::{ChatWidget, ComposerWidget, Renderable};
163
164 // Activity Detail / raw-detail / pager-text helpers extracted into `activity_detail`
165 // (issue #4103). Re-export the cross-module entry points so existing
166 // `crate::tui::ui::{...}` importers (mouse_ui, footer_ui) keep resolving, and
167 // import the ui-internal entry points used from this file's own body.
168 pub(crate) use self::activity_detail::{
169 completed_assistant_answer_text, copy_cell_to_clipboard, detail_target_label,
170 open_details_pager_for_cell, open_focused_cell_pager, turn_handoff_markdown,
171 };
172 use self::activity_detail::{
173 copy_focused_cell, detail_target_cell_index, extract_reasoning_header,
174 open_reasoning_detail_pager, open_tool_details_pager, open_turn_inspector_pager,
175 };
176 // Ctrl+O now opens the full recorded Reasoning Detail for the selected or
177 // current reasoning block. The whole-turn Turn Inspector moved to Ctrl+Alt+O
178 // and `/turn inspect`. (`v` raw leaf detail keeps using `open_tool_details_pager`.)
179
180 // === Constants ===
181
182 /// Upper bound on slash-menu entries returned to the renderer. The composer's
183 /// render path already paginates with center-tracking (see
184 /// `widgets::ComposerWidget::render`), so this only needs to be high enough to
185 /// encompass the full filtered command list — never the visible-row budget.
186 /// Bumped from 6 to 128 to fix #64 (selection couldn't reach commands beyond
187 /// the visible window because the source list itself was capped).
188 const SLASH_MENU_LIMIT: usize = 128;
189 const MIN_CHAT_HEIGHT: u16 = 3;
190 const MIN_COMPOSER_HEIGHT: u16 = 2;
191 const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
192 const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
193 const CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT: f64 = 60.0;
194 const UI_IDLE_POLL_MS: u64 = 48;
195 const UI_ACTIVE_POLL_MS: u64 = 24;
196 const SUBAGENT_HOOK_PREVIEW_LIMIT: usize = 2_048;
197 const DISPATCH_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(30);
198 /// Minimum wall-clock time a turn may stay in `"in_progress"` before the UI
199 /// assumes the engine stalled (e.g. sub-agent hang, lost completion event,
200 /// engine panic). The effective watchdog also respects the configured stream
201 /// idle timeout so legitimate long model-reasoning pauses are not interrupted
202 /// prematurely.
203 const TURN_STALL_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(300);
204 const TURN_STALL_WATCHDOG_GRACE: Duration = Duration::from_secs(30);
205 /// Running tools can legitimately exceed the silent-turn timeout, but a tool
206 /// with no progress heartbeat or output beyond this ceiling is treated as hung.
207 // Must stay comfortably above `turn_stall_watchdog_timeout` so a running tool
208 // gets extra grace beyond the turn-stall threshold (#1862 trimmed 15m → 10m).
209 const TOOL_HANG_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(600);
210 // Forced repaint cadence while a turn is live (model loading, compacting,
211 // sub-agents running). Drives the footer water-spout animation as well as
212 // the per-tool spinner pulse — keep this fast enough that the whale-spout
213 // braille pattern reads as continuous motion instead of teleport-frames.
214 const UI_STATUS_ANIMATION_MS: u64 = crate::tui::spinner::BRAILLE_SPINNER_FRAME_MS;
215 /// Ambient fish, the idle-mark caustic, and the completion wake use a modest
216 /// ~12.5fps clock by default. On measured high-Hz displays the adaptive probe
217 /// may raise this (still bounded); low_motion always freezes the cadence.
218 /// Active markers run at 8fps; atmosphere stays subordinate.
219 pub(crate) const UI_UNDERWATER_ANIMATION_MS: u64 = 80;
220 /// Full-motion compatibility cadence for VTE, tmux, and other terminals that
221 /// explicitly request the 30 FPS safety cap.
222 pub(crate) const UI_CONSTRAINED_UNDERWATER_ANIMATION_MS: u64 = 34;
223 /// 30 FPS Ghostty atmosphere clock. Input, streaming, and other interactive
224 /// state still request immediate frames up to the separate 60 FPS draw cap;
225 /// idle water no longer forces a full-screen repaint at that rate.
226 pub(crate) const UI_GHOSTTY_UNDERWATER_ANIMATION_MS: u64 = 34;
227 // Minimum chat-host width at which the file-tree pane renders. At an
228 // 80-column terminal the file tree owns 20 columns, leaving a 60-column chat
229 // host; below this floor the tree is hidden rather than squeezing the
230 // transcript under 40 columns. (Named for the file tree — the legacy sidebar
231 // this constant once described no longer gates on it.)
232 pub(crate) const FILE_TREE_MIN_HOST_WIDTH: u16 = 60;
233 const SESSION_TITLE_MAX_CHARS: usize = 32;
234 const VERSION_HINT_TOAST_TTL_MS: u64 = 12_000;
235
236 const REQUIRED_RELEASE_ASSETS: &[&str] = &[
237 "codewhale-linux-x64",
238 "codew-linux-x64",
239 "codewhale-linux-arm64",
240 "codew-linux-arm64",
241 "codewhale-android-arm64",
242 "codew-android-arm64",
243 "codewhale-macos-x64",
244 "codew-macos-x64",
245 "codewhale-macos-arm64",
246 "codew-macos-arm64",
247 "codewhale-windows-x64.exe",
248 "codew-windows-x64.exe",
249 "codewhale.bat",
250 "codewhale-windows-arm64.exe",
251 "codew-windows-arm64.exe",
252 "codewhale-linux-x64.tar.gz",
253 "codewhale-linux-arm64.tar.gz",
254 "codewhale-android-arm64.tar.gz",
255 "codewhale-macos-x64.tar.gz",
256 "codewhale-macos-arm64.tar.gz",
257 "codewhale-windows-x64.zip",
258 "codewhale-windows-x64-portable.zip",
259 "codewhale-windows-arm64.zip",
260 "codewhale-windows-arm64-portable.zip",
261 "CodeWhaleSetup.exe",
262 "codewhale-bundles-sha256.txt",
263 "codewhale-artifacts-sha256.txt",
264 ];
265
266 type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
267
268 type PendingToolUses = Vec<(String, String, serde_json::Value)>;
269
270 #[derive(Debug)]
271 enum TranslationEvent {
272 AssistantMessage {
273 origin_session_fingerprint: Option<String>,
274 origin_turn_fingerprint: Option<String>,
275 history_index: Option<usize>,
276 original_text: String,
277 translated: anyhow::Result<String>,
278 usage: Option<codewhale_models::Usage>,
279 thinking: Option<String>,
280 tool_uses: PendingToolUses,
281 },
282 Thinking {
283 origin_session_fingerprint: Option<String>,
284 origin_turn_fingerprint: Option<String>,
285 placeholder: String,
286 translated: anyhow::Result<String>,
287 usage: Option<codewhale_models::Usage>,
288 },
289 }
290
291 // Reset scroll region (`\x1b[r`), origin mode (`\x1b[?6l`), and home the cursor
292 // (`\x1b[H`) before letting ratatui's diff renderer repaint. The destructive
293 // `\x1b[2J\x1b[3J` pair was previously appended here to also wipe the visible
294 // screen and saved scrollback, but combined with the immediately-following
295 // `terminal.clear()` it produced a double-clear that several terminals
296 // (Ghostty, VSCode terminal, Win10 conhost) render as visible flicker on every
297 // TurnComplete / focus-gain / resize. The alt-screen buffer's double-buffering
298 // plus ratatui's `terminal.clear()` are sufficient to repaint cleanly.
299 const TERMINAL_ORIGIN_RESET: &[u8] = b"\x1b[r\x1b[?6l\x1b[H";
300 // Xterm alternate-scroll mode (DECSET 1007) converts wheel input into arrow
301 // keys. It is only meaningful when mouse reporting is unavailable; while
302 // mouse capture is active the terminal must deliver wheel events as mouse
303 // events, so 1007 stays off (iTerm2 converts anyway, breaking transcript
304 // wheel-scroll — #5223). `--no-mouse-capture` also keeps it off so the host
305 // terminal owns raw mouse selection behavior end-to-end (#4026).
306 const ENABLE_ALT_SCROLL_MODE: &[u8] = b"\x1b[?1007h";
307 const DISABLE_ALT_SCROLL_MODE: &[u8] = b"\x1b[?1007l";
308 /// Begin synchronized update (DEC 2026): tell the terminal to defer
309 /// rendering until END_SYNC_UPDATE is received. Best-effort —
310 /// terminals that don't support this silently ignore the sequence.
311 /// Reduces flicker on GPU-accelerated terminals (Ghostty, VSCode
312 /// Terminal, Kitty, WezTerm) by batching ratatui's incremental
313 /// diff writes into a single frame.
314 const BEGIN_SYNC_UPDATE: &[u8] = b"\x1b[?2026h";
315 /// End synchronized update (DEC 2026): tell the terminal to render
316 /// the complete frame now.
317 const END_SYNC_UPDATE: &[u8] = b"\x1b[?2026l";
318 /// Throttled in-progress checkpoint while a turn is live (#1830 progress loss).
319 const RECOVERY_SNAPSHOT_INTERVAL: Duration = Duration::from_secs(45);
320
321 /// Where a key goes while onboarding owns the screen (#4763).
322 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
323 pub(crate) enum OnboardingKeyRoute {
324 /// Terminate the session. Ctrl+C is unconditional during onboarding.
325 Quit,
326 /// Hand the key to the provider picker on the view stack.
327 ProviderPicker,
328 /// Take the advertised offline exit (#3927). Reachable from Provider
329 /// setup even while the provider picker owns the screen, so the choice is
330 /// never hidden behind a modal the user cannot satisfy.
331 ExploreOffline,
332 /// Fall through to the legacy onboarding key switch.
333 Legacy,
334 }
335
336 fn surface_prompt_override_notices(app: &mut App) {
337 for notice in prompts::take_prompt_override_notices() {
338 app.add_message(HistoryCell::System {
339 content: format!("Warning: {notice}"),
340 });
341 app.push_status_toast(notice, StatusToastLevel::Warning, Some(12_000));
342 }
343 }
344
345 #[cfg(test)]
346 #[test]
347 fn tui_launch_preflight_explains_non_tty_failure() {
348 assert!(require_interactive_terminal(true, true).is_ok());
349 for (stdin_is_tty, stdout_is_tty) in [(false, true), (true, false), (false, false)] {
350 let err = require_interactive_terminal(stdin_is_tty, stdout_is_tty)
351 .expect_err("a missing TTY must fail before raw mode");
352 let message = err.to_string();
353 assert!(message.contains("interactive terminal"), "{message}");
354 assert!(message.contains("codewhale exec"), "{message}");
355 }
356 }
357
358 #[cfg(unix)]
359 #[test]
360 fn tui_launch_preflight_rejects_background_process_group() {
361 assert!(validate_foreground_process_group(41, 41).is_ok());
362 let err = validate_foreground_process_group(41, 42)
363 .expect_err("a background process group must fail before raw mode");
364 let message = err.to_string();
365 assert!(message.contains("background or suspended"), "{message}");
366 assert!(message.contains("Run `fg`"), "{message}");
367 assert!(message.contains("codewhale exec"), "{message}");
368 }
369
370 fn resume_hint_text(
371 locale: codewhale_localization::Locale,
372 session_id: Option<&str>,
373 terminal_output: bool,
374 ) -> Option<String> {
375 use codewhale_localization::{MessageId, tr};
376 if !terminal_output {
377 return None;
378 }
379 let session_id = session_id.filter(|id| !id.trim().is_empty())?;
380 // Reconstruct a canonical UUID rather than interpolating a stored string
381 // into a shell command or terminal output. Legacy/noncanonical identities
382 // get the existing picker, never an ambiguous "most recent" shortcut.
383 let canonical = uuid::Uuid::parse_str(session_id)
384 .ok()
385 .map(|id| id.hyphenated().to_string())
386 .filter(|id| id == session_id);
387 let (message, command) = match canonical {
388 Some(id) => (
389 MessageId::ResumeExactSessionHint,
390 format!("codewhale resume {id}"),
391 ),
392 None => (
393 MessageId::ResumeSavedSessionHint,
394 "codewhale resume".to_string(),
395 ),
396 };
397 Some(tr(locale, message).replace("{command}", &command))
398 }
399
400 struct TerminalCleanupGuard {
401 use_bracketed_paste: bool,
402 defused: bool,
403 }
404
405 impl Drop for TerminalCleanupGuard {
406 fn drop(&mut self) {
407 if self.defused {
408 return;
409 }
410
411 let mut stdout = io::stdout();
412 pop_keyboard_enhancement_flags(&mut stdout);
413 disable_alternate_scroll_mode(&mut stdout);
414 let _ = execute!(stdout, DisableFocusChange);
415 let _ = disable_raw_mode();
416 // The live screen, not the one startup chose: `/inline` moves it.
417 if live_alt_screen() {
418 let _ = leave_alt_screen(&mut stdout);
419 }
420 // Capture follows the screen at runtime too; disabling it when it was
421 // never on is harmless (the emergency path already does).
422 let _ = execute!(stdout, DisableMouseCapture);
423 if self.use_bracketed_paste {
424 disable_bracketed_paste_mode(&mut stdout);
425 }
426 let _ = execute!(stdout, crossterm::cursor::Show);
427 }
428 }
429
430 /// Recognise composer input that is a `# foo` memory quick-add (#492).
431 ///
432 /// Returns `true` for inputs that:
433 /// - start with `#`,
434 /// - have at least one non-whitespace character after the leading `#`,
435 /// - are a single line (no embedded `\n`), and
436 /// - are not a shebang (`#!`) or Markdown heading (`## …`, `### …`).
437 ///
438 /// Multi-`#` prefixes are deliberately rejected so users can paste
439 /// Markdown headings into the composer without triggering the quick-add.
440 #[must_use]
441 fn is_memory_quick_add(input: &str) -> bool {
442 let trimmed = input.trim_start();
443 if !trimmed.starts_with('#') {
444 return false;
445 }
446 if trimmed.starts_with("##") || trimmed.starts_with("#!") {
447 return false;
448 }
449 if input.contains('\n') {
450 return false;
451 }
452 // Require something after the `#`.
453 !trimmed.trim_start_matches('#').trim().is_empty()
454 }
455
456 fn should_intercept_memory_quick_add(config: &Config, input: &str) -> bool {
457 config.memory_enabled() && is_memory_quick_add(input)
458 }
459
460 #[cfg(test)]
461 mod memory_quick_add_tests {
462 use super::should_intercept_memory_quick_add;
463 use crate::config::Config;
464
465 #[test]
466 fn memory_quick_add_interception_requires_memory_opt_in() {
467 let enabled: Config = toml::from_str(
468 r#"
469 [memory]
470 enabled = true
471 "#,
472 )
473 .expect("parse enabled memory config");
474 assert!(should_intercept_memory_quick_add(
475 &enabled,
476 "# remember this"
477 ));
478
479 let disabled: Config = Config::default();
480 assert!(!should_intercept_memory_quick_add(
481 &disabled,
482 "# remember this"
483 ));
484 assert!(!should_intercept_memory_quick_add(
485 &enabled,
486 "## Markdown heading"
487 ));
488 }
489 }
490
491 fn spawn_tui_engine(config: EngineConfig, api_config: &Config) -> EngineHandle {
492 let handle = spawn_engine(config, api_config);
493 // Prime durable agent + coordination state through the same engine event
494 // used by later refreshes. All TUI engine replacements use this wrapper,
495 // so workspace switches and provider recovery cannot retain stale Work.
496 let _ = handle.try_send(Op::ListSubAgents);
497 handle
498 }
499
500 /// Startup and consent-triggered replacement restore the same conversation
501 /// before admitting any pending input. The existing engine remains the sole
502 /// owner of model-facing history and the frozen system prefix.
503 async fn spawn_tui_engine_with_session(app: &mut App, config: &Config) -> Result<EngineHandle> {
504 let handle = spawn_tui_engine(build_engine_config(app, config), config);
505 let restored = async {
506 if !app.api_messages.is_empty() {
507 handle
508 .send(Op::SyncSession {
509 session_id: app.current_session_id.clone(),
510 messages: app.api_messages.as_ref().clone(),
511 system_prompt: app.system_prompt.clone(),
512 system_prompt_override: false,
513 model: app.model.clone(),
514 workspace: app.workspace.clone(),
515 mode: app.mode,
516 })
517 .await?;
518 }
519 // FIFO snapshot acknowledgement also proves the restore was processed.
520 let snapshot = handle.get_session_snapshot().await?;
521 app.system_prompt = snapshot.system_prompt;
522 Ok::<_, anyhow::Error>(())
523 }
524 .await;
525 if let Err(error) = restored {
526 let _ = handle.send(Op::Shutdown).await;
527 return Err(error);
528 }
529 Ok(handle)
530 }
531
532 fn configured_instruction_sources(config: &Config) -> Vec<prompts::InstructionSource> {
533 config
534 .instructions_paths()
535 .into_iter()
536 .map(Into::into)
537 .collect()
538 }
539
540 /// Open the exact effective base-prompt preview (#3928).
541 ///
542 /// Assembles the prompt through [`build_app_system_prompt_with_goal`] — the same
543 /// function the dispatch path calls — so the preview is the next turn's bytes,
544 /// not a reconstruction of them. Nothing is sent and no tool catalog is
545 /// expanded; the preview is a pure read.
546 fn preview_effective_base_prompt(app: &mut App, config: &Config) {
547 use crate::prompts::base_preview;
548
549 let prompt = build_app_system_prompt_with_goal(app, config, app.goal.objective.as_deref());
550 let home = codewhale_config::codewhale_home().ok();
551 let constitution_path = codewhale_config::UserConstitution::path().ok();
552 let sources = base_preview::PreviewSources {
553 base_prompt: Some(crate::prompts::effective_base_prompt_source(
554 home.as_deref(),
555 )),
556 user_constitution_path: constitution_path.as_deref(),
557 workspace: Some(app.workspace.as_path()),
558 home: home.as_deref(),
559 };
560 let report = base_preview::render_report(&base_preview::preview(&prompt, &sources));
561 let width = app
562 .viewport
563 .last_transcript_area
564 .map(|area| area.width)
565 .unwrap_or(80);
566 app.view_stack.push(crate::tui::pager::PagerView::from_text(
567 crate::prompts::base_preview::PREVIEW_TITLE,
568 &report,
569 width.saturating_sub(2),
570 ));
571 }
572
573 /// Minimum interval between balance API fetches to avoid flooding.
574 const BALANCE_FETCH_COOLDOWN: Duration = Duration::from_secs(60);
575
576 /// Shared `reqwest::Client` for balance fetches so connection pools are
577 /// reused across successive background polls.
578 static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| {
579 crate::tls::reqwest_client_builder()
580 .timeout(Duration::from_secs(10))
581 .build()
582 .unwrap_or_default()
583 });
584
585 #[derive(Debug)]
586 pub(crate) struct CacheWarmupOutcome {
587 usage: Usage,
588 provider_identity: String,
589 model: String,
590 base_url: String,
591 inspection: PromptInspection,
592 }
593
594 /// Install a completed constitution draft into the setup wizard (if still on
595 /// top) and open its ratification preview, or surface a failure. Called from
596 /// the event loop when the background draft lands, and directly on the
597 /// pre-spawn provider-construction failure.
598 fn deliver_constitution_draft_result(
599 app: &mut App,
600 model_label: String,
601 locale: codewhale_localization::Locale,
602 outcome: Result<Box<codewhale_config::UserConstitution>, String>,
603 ) {
604 match outcome {
605 Ok(constitution) => {
606 if app.view_stack.top_kind() == Some(ModalKind::SetupWizard)
607 && let Some(mut boxed) = app.view_stack.pop()
608 {
609 let preview = boxed
610 .as_any_mut()
611 .downcast_mut::<crate::tui::setup::SetupWizardView>()
612 .map(|wizard| wizard.install_model_draft(constitution, model_label.clone()));
613 app.view_stack.push_boxed(boxed);
614 if let Some((title, content)) = preview {
615 open_text_pager(app, title, content);
616 app.status_message = Some(crate::tui::setup::model_draft_ready_message(
617 locale,
618 &model_label,
619 ));
620 }
621 }
622 }
623 Err(reason) => {
624 app.status_message = Some(crate::tui::setup::model_draft_failed_message(
625 locale,
626 &model_label,
627 &reason,
628 ));
629 }
630 }
631 app.needs_redraw = true;
632 }
633
634 /// Install a completed fleet-profile draft into the wizard (if it is still on
635 /// top), or surface a failure. Called from the event loop when the
636 /// background draft lands, and directly on the pre-spawn
637 /// provider-construction failure.
638 ///
639 /// The preview renders inline on the wizard's own Review step — deliberately
640 /// NOT in a separate pager (#4093): a standalone pager view owns its own
641 /// `g`/`G` scroll bindings and would swallow the ratify keypress, forcing an
642 /// Esc-then-g round trip before the user could actually save.
643 fn deliver_fleet_draft_result(
644 app: &mut App,
645 model_label: String,
646 picked_route: Option<(String, String)>,
647 reasoning_effort: Option<String>,
648 outcome: Result<Box<crate::fleet::profile::FleetProfileDraft>, String>,
649 locale: codewhale_localization::Locale,
650 ) {
651 match outcome {
652 Ok(draft) => {
653 if app.view_stack.top_kind() == Some(ModalKind::FleetSetup)
654 && let Some(mut boxed) = app.view_stack.pop()
655 {
656 let installed = boxed
657 .as_any_mut()
658 .downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>()
659 .map(|wizard| {
660 wizard.install_model_draft(
661 draft,
662 model_label.clone(),
663 picked_route.clone(),
664 reasoning_effort.clone(),
665 )
666 })
667 .is_some();
668 app.view_stack.push_boxed(boxed);
669 if installed {
670 app.status_message = Some(match locale {
671 codewhale_localization::Locale::ZhHans => {
672 format!("{model_label} 已起草配置。请查看下方 TOML,然后按 g 保存。")
673 }
674 _ => format!(
675 "{model_label} drafted the profile. Review the TOML below, then press g to save."
676 ),
677 });
678 }
679 }
680 }
681 Err(reason) => {
682 app.status_message = Some(match locale {
683 codewhale_localization::Locale::ZhHans => {
684 format!("{model_label} 未能起草配置({reason})。按 Enter 仍会插入编写提示。")
685 }
686 _ => format!(
687 "{model_label} could not draft the profile ({reason}). Enter still inserts the authoring prompt."
688 ),
689 });
690 }
691 }
692 app.needs_redraw = true;
693 }
694
695 // `format_*` chip/message builders moved to `tui/format_helpers.rs`.
696
697 fn is_work_graph_mutation_tool(name: &str) -> bool {
698 matches!(
699 name,
700 "update_plan"
701 | "work_update"
702 | "checklist_write"
703 | "todo_write"
704 | "checklist_add"
705 | "todo_add"
706 | "checklist_update"
707 | "todo_update"
708 | "task_create"
709 | "task_cancel"
710 // Unified durable-task tool (piagent phase B): covers the
711 // create/cancel actions the legacy names above carried.
712 | "tasks"
713 | "exec_shell"
714 | "exec_shell_wait"
715 | "exec_shell_cancel"
716 | "agent"
717 | "workflow"
718 )
719 }
720
721 fn turn_stall_watchdog_timeout(app: &App) -> Duration {
722 let stream_budget = Duration::from_secs(app.stream_chunk_timeout_secs)
723 .saturating_add(TURN_STALL_WATCHDOG_GRACE);
724 TURN_STALL_WATCHDOG_TIMEOUT.max(stream_budget)
725 }
726
727 fn active_turn_has_running_tool(app: &App) -> bool {
728 app.active_cell.as_ref().is_some_and(|active| {
729 active.entries().iter().any(|cell| match cell {
730 HistoryCell::Tool(tool) => tool.is_running(),
731 _ => false,
732 })
733 })
734 }
735
736 // Per-turn notification composition (settings, message body, summary)
737 // moved to `tui/notifications.rs` alongside the dispatch primitives.
738
739 async fn tool_result_content_for_api_message(
740 app: &App,
741 id: &str,
742 name: &str,
743 output: &ToolResult,
744 ) -> String {
745 let raw = output.content.trim();
746 if raw.is_empty() {
747 return String::new();
748 }
749
750 if matches!(
751 name,
752 "run_tests" | "run_verifiers" | "task_gate_run" | "tasks"
753 ) {
754 return crate::core::engine::compact_tool_result_for_route(
755 app.api_provider,
756 &app.model,
757 app.active_route_limits,
758 name,
759 output,
760 );
761 }
762
763 if raw.chars().count() > crate::tool_output_receipts::RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS {
764 let messages = live_tool_receipt_messages(app, id, raw, output.success);
765 let artifacts = app.session_artifacts.clone();
766 let raw = raw.to_string();
767 match tokio::task::spawn_blocking(move || {
768 compact_live_tool_receipt(messages, artifacts, raw)
769 })
770 .await
771 {
772 Ok(Some(receipt)) => return receipt,
773 Ok(None) => {}
774 Err(err) => {
775 crate::logging::warn(format!("live tool-output receipt compaction failed: {err}"));
776 }
777 }
778 }
779
780 crate::core::engine::compact_tool_result_for_route(
781 app.api_provider,
782 &app.model,
783 app.active_route_limits,
784 name,
785 output,
786 )
787 }
788
789 // Streaming-thinking lifecycle helpers moved to `tui/streaming_thinking.rs`.
790
791 /// Data produced by the async dispatch phase that is needed to apply the
792 /// post-acceptance mutations to `App`.
793 #[derive(Debug, Clone)]
794 pub(crate) struct UserDispatchOutcome {
795 turn_compaction: CompactionConfig,
796 effective_provider: ApiProvider,
797 effective_model: String,
798 effective_provider_identity: String,
799 effective_provider_label: String,
800 effective_reasoning_effort: EffectiveReasoningEffort,
801 auto_selection: Option<crate::model_routing::AutoRouteSelection>,
802 }
803
804 fn is_model_visible_tool_call(id: &str) -> bool {
805 !id.starts_with(USER_SHELL_TOOL_ID_PREFIX)
806 }
807
808 /// Tell the operator that an explicit "make this my default" request did not
809 /// take effect, instead of leaving a normal apply summary that reads like
810 /// success. Silence here is what made the sticky-default bug so confusing.
811 fn note_startup_default_not_saved(app: &mut App, save_as_startup_default: bool) {
812 if !save_as_startup_default {
813 return;
814 }
815 let existing = app.status_message.take();
816 let note = "Startup default unchanged — the route was not applied.";
817 app.status_message = Some(match existing {
818 Some(message) if !message.trim().is_empty() => format!("{message} · {note}"),
819 _ => note.to_string(),
820 });
821 }
822
823 /// Route every Fleet-setup entry point to the storage surface that actually
824 /// controls the effective roster. A selected v2 Fleet always opens its exact
825 /// named editor; the legacy profile wizard is reachable only with no selected
826 /// Fleet. Selection resolution deliberately does not consult project trust.
827 fn open_fleet_setup_target(app: &mut App, config: &Config, member_id: Option<&str>) {
828 use crate::tui::views::fleet_setup::{FleetSetupEditTarget, resolve_fleet_setup_edit_target};
829
830 match resolve_fleet_setup_edit_target(&app.workspace) {
831 Ok(FleetSetupEditTarget::SelectedFleet { name, scope }) => {
832 if app.view_stack.top_kind() == Some(ModalKind::FleetDetail) {
833 return;
834 }
835 let Some(mut view) = crate::tui::views::fleet_detail::FleetDetailView::open_for_member(
836 app, config, &name, scope, member_id,
837 ) else {
838 app.set_sticky_status(
839 "Selected team is invalid or unreadable; open /fleet teams to repair or clear the selection. Legacy profiles were not opened."
840 .to_string(),
841 StatusToastLevel::Error,
842 None,
843 );
844 return;
845 };
846 let fleet_name = crate::safe_label::SafeLabel::phrase(&name);
847 let picker = if member_id.is_some() {
848 let (editor_id, target) = view.direct_assignment();
849 let (role, scope) = view.assignment_context();
850 view.route_selection(editor_id, target).map(|selection| {
851 crate::tui::model_picker::ModelPickerView::new_for_fleet_route(
852 app, config, target, editor_id, selection,
853 )
854 .with_assignment_context(role, scope)
855 })
856 } else {
857 None
858 };
859 app.view_stack.push(view);
860 if let Some(picker) = picker {
861 app.view_stack.push(picker);
862 }
863 app.status_message = Some(format!(
864 "Editing selected team `{fleet_name}` ({}) — legacy profiles will not be changed.",
865 scope.label()
866 ));
867 }
868 Ok(FleetSetupEditTarget::LegacyProfiles) => {
869 if app.view_stack.top_kind() == Some(ModalKind::FleetSetup) {
870 return;
871 }
872 if let Some(member_id) = member_id {
873 match crate::tui::views::fleet_setup::FleetSetupView::new_for_route_assignment(
874 app, config, member_id,
875 ) {
876 Ok(view) => {
877 if let ViewAction::Emit(ViewEvent::FleetProfileRoutePickRequested {
878 editor_id,
879 }) = view.route_pick_request()
880 && let Some(selection) = view.route_selection(editor_id)
881 {
882 let (role, scope) = view.assignment_context();
883 let picker =
884 crate::tui::model_picker::ModelPickerView::new_for_fleet_profile(
885 app, config, editor_id, selection,
886 )
887 .with_assignment_context(role, scope);
888 app.view_stack.push(view);
889 app.view_stack.push(picker);
890 }
891 }
892 Err(reason) => app.set_sticky_status(reason, StatusToastLevel::Error, None),
893 }
894 return;
895 }
896 let _ = app.next_draft_gen();
897 let view = match member_id {
898 Some(member_id) => crate::tui::views::fleet_setup::FleetSetupView::new_for_role(
899 app, config, member_id,
900 ),
901 None => crate::tui::views::fleet_setup::FleetSetupView::new(app, config),
902 };
903 app.view_stack.push(view);
904 }
905 Err(message) => {
906 app.set_sticky_status(message, StatusToastLevel::Error, None);
907 }
908 }
909 }
910
911 pub(crate) struct ProviderFallbackRollback {
912 identity: ProviderIdentity,
913 chain: Option<codewhale_config::ProviderChain>,
914 }
915
916 // File-picker relevance scoring moved to `tui/file_picker_relevance.rs`.
917
918 #[cfg(test)]
919 use std::process::{Command, Stdio};
920
921 // `ui.rs` had grown past 19k lines. These three modules hold the same code,
922 // moved verbatim, and are re-exported so every existing path still resolves.
923 mod apply;
924 mod approval_routing;
925 use approval_routing::*;
926 mod event_loop;
927 mod handlers;
928
929 pub(crate) use apply::*;
930 pub(crate) use event_loop::*;
931 pub(crate) use handlers::*;
932 // The crate-wide glob would otherwise narrow this to `pub(crate)`; `tui/mod.rs`
933 // re-exports it as the binary's entry point.
934 pub use event_loop::run_tui;
935
936 mod compaction_flow;
937 pub(crate) use compaction_flow::*;
938 pub(crate) use provider_setup::*;
939 mod dispatch;
940 mod dispatch_prepare;
941 pub(crate) use dispatch_prepare::*;
942 pub(crate) mod fatal_signal_guard;
943 // #6169: runtime half of the foreground-ownership contract — restore on stop,
944 // rebuild on continue. Sits next to the fatal guard because both write the same
945 // teardown table.
946 pub(crate) mod job_control_guard;
947 mod motion;
948 mod observer_hooks;
949 mod provider_setup;
950 mod release_check;
951 mod remote_control_bridge;
952 mod task_projection;
953 mod terminal;
954 mod terminal_input;
955 use remote_control_bridge::*;
956 use terminal_input::*;
957 // #6165: `external_editor` is a sibling of `ui`, and the pump pause now lives
958 // inside its `with_suspended_tui` so no editor entry point can forget it.
959 pub(crate) use terminal_input::pause_terminal_input_for_child;
960
961 pub(crate) use dispatch::*;
962 pub(crate) use motion::*;
963 pub(crate) use release_check::*;
964 pub(crate) use terminal::*;
965
966 // `frame` is `pub(crate)` so sibling modules (e.g. the widgets ASCII-safety
967 // test) can reach the topbar builders that project `App` state.
968 pub(crate) mod frame;
969 mod overlays;
970 mod provider_routes;
971 mod session_state;
972
973 pub(crate) use frame::*;
974 pub(crate) use overlays::*;
975 pub(crate) use provider_routes::*;
976 pub(crate) use session_state::*;
977
978 #[cfg(test)]
979 fn spawn_external_url_command(mut command: Command) -> Result<()> {
980 command
981 .stdin(Stdio::null())
982 .stdout(Stdio::null())
983 .stderr(Stdio::null())
984 .spawn()
985 .map(|_| ())
986 .map_err(|err| anyhow::anyhow!("failed to launch browser command: {err}"))
987 }
988
989 async fn execute_command_input(
990 terminal: &mut AppTerminal,
991 app: &mut App,
992 engine_handle: &mut EngineHandle,
993 task_manager: &SharedTaskManager,
994 config: &mut Config,
995 input: &str,
996 ) -> Result<bool> {
997 let _ = app.note_manual_command_for_tip(input);
998 if let Some(parsed_index) = parse_queue_send_command(input) {
999 match parsed_index {
1000 Ok(index) => {
1001 send_queued_message_at_index_now(app, config, engine_handle, index).await?;
1002 }
1003 Err(message) => {
1004 app.status_message = Some(message);
1005 }
1006 }
1007 return Ok(false);
1008 }
1009
1010 let result = commands::execute(input, app);
1011 // After /logout: clear the in-memory api_key fields so the next
1012 // onboarding round entering a new key doesn't see the stale value
1013 // (#343). The on-disk side is handled by clear_api_key() inside
1014 // commands::config::logout.
1015 if input.trim().eq_ignore_ascii_case("/logout") {
1016 // Only clear the active provider's in-memory API key, not every
1017 // provider. The on-disk clear_api_key() inside commands::config::logout
1018 // already removes all saved keys; clearing only the active slot here
1019 // prevents surprising side-effects when the user has multiple providers
1020 // configured.
1021 clear_active_provider_api_key_from_memory(app, config);
1022 app.api_key_env_only = crate::config::active_provider_uses_env_only_api_key(config);
1023 }
1024 apply_command_result(terminal, app, engine_handle, task_manager, config, result).await
1025 }
1026
1027 #[derive(Debug, Clone)]
1028 pub(crate) struct SteerPausedSnapshot {
1029 paused: bool,
1030 pausable: bool,
1031 paused_goal_objective: Option<String>,
1032 objective: Option<String>,
1033 tokens_used: u64,
1034 time_used_seconds: u64,
1035 continuation_count: u32,
1036 }
1037
1038 fn use_bundled_constitution(app: &mut App, config: &Config) {
1039 let mut state = crate::tui::setup::load_setup_state_for_app(app, config);
1040 state.complete_constitution_checkpoint(
1041 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
1042 codewhale_config::ConstitutionChoice::Bundled,
1043 );
1044 state.constitution_source = codewhale_config::ConstitutionSource::Bundled;
1045 state.constitution_validity = codewhale_config::ConstitutionValidity::Unknown;
1046 state.constitution_preview_hash = None;
1047 state.set_step(
1048 codewhale_config::SetupStep::Constitution,
1049 codewhale_config::StepEntry::new(
1050 codewhale_config::StepStatus::Verified,
1051 true,
1052 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
1053 )
1054 .with_result("bundled/default constitution"),
1055 );
1056
1057 match state.save() {
1058 Ok(()) => {
1059 app.status_message = Some(
1060 "Using the bundled/default constitution; custom user-global law is inactive."
1061 .to_string(),
1062 );
1063 }
1064 Err(err) => {
1065 app.status_message = Some(format!("Failed to save constitution choice: {err}"));
1066 app.add_message(HistoryCell::System {
1067 content: format!("Failed to save constitution choice: {err}"),
1068 });
1069 }
1070 }
1071 app.needs_redraw = true;
1072 }
1073
1074 fn prepare_config_update_result(
1075 mut result: commands::CommandResult,
1076 persist: bool,
1077 ) -> commands::CommandResult {
1078 // Live previews can fire on every navigation tick. Suppress routine
1079 // confirmations, but preserve errors and AppAction so one canonical path
1080 // remains responsible for both user-visible output and side effects.
1081 if !persist && !result.is_error {
1082 result.message = None;
1083 }
1084 result
1085 }
1086
1087 pub(crate) struct ApprovalDecisionEvent {
1088 tool_id: String,
1089 tool_name: String,
1090 decision: ReviewDecision,
1091 timed_out: bool,
1092 approval_key: String,
1093 approval_grouping_key: String,
1094 persistent_rules: Vec<codewhale_config::ToolAskRule>,
1095 }
1096
1097 fn mark_active_turn_cancelled_locally(app: &mut App) {
1098 app.retire_action_notices(None);
1099 // #2739: every local cancel surface (Esc, Ctrl+C, approval abort, paused
1100 // command abort) must snapshot before it clears turn state. Otherwise
1101 // --continue reloads the previous save and the interrupted turn vanishes.
1102 app.streaming_state.reset();
1103 app.finalize_active_cell_as_interrupted();
1104 app.finalize_streaming_assistant_as_interrupted();
1105 persist_recovery_snapshot(app);
1106 app.is_loading = false;
1107 app.dispatch_started_at = None;
1108 app.turn_started_at = None;
1109 app.turn_last_activity_at = None;
1110 app.runtime_turn_id = None;
1111 app.runtime_turn_status = None;
1112 app.suppress_stream_events_until_turn_complete = true;
1113 crate::retry_status::clear();
1114 crate::tui::notifications::clear_taskbar_progress();
1115 crate::tui::notifications::stop_title_animation_quietly();
1116 }
1117
1118 /// The Esc-shaped "cancel the active turn" body, extracted verbatim from the
1119 /// event loop's `EscapeAction::CancelRequest` arm so the session control
1120 /// socket's `interrupt` verb and the Esc key cannot drift apart. Returns
1121 /// `true` when the caller should stop handling the event (compaction cancel
1122 /// or goal-continuation stop consumed it), `false` otherwise. The caller
1123 /// keeps its own Esc-specific state (backtrack reset) outside this body.
1124 pub(crate) fn escape_cancel_request(
1125 app: &mut App,
1126 engine_handle: &EngineHandle,
1127 current_streaming_text: &mut String,
1128 stream_display_clock: &mut StreamDisplayClock,
1129 ) -> bool {
1130 let compacting = app.is_compacting || app.manual_compaction_queued;
1131 if compacting {
1132 try_cancel_compaction(app, engine_handle);
1133 if !compact_interrupt_should_stop_turn(app) {
1134 return true;
1135 }
1136 // Mid-turn compact is collateral. Esc/interrupt stops the turn
1137 // (Codex/GrokBuild): cancel_compaction alone continues the loop.
1138 }
1139 if app.paused || app.paused_goal_objective.is_some() {
1140 clear_paused_command_state(app, engine_handle);
1141 if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
1142 engine_handle.cancel();
1143 mark_active_turn_cancelled_locally(app);
1144 current_streaming_text.clear();
1145 stream_display_clock.reset();
1146 }
1147 app.active_allowed_tools = None;
1148 app.goal.objective = None;
1149 app.goal.tokens_used = 0;
1150 app.goal.time_used_seconds = 0;
1151 app.goal.continuation_count = 0;
1152 app.status_message = Some(parent_stop_status(app, "Paused command cancelled"));
1153 false
1154 } else {
1155 let was_waiting = app.goal_continuation_waiting;
1156 engine_handle.cancel();
1157 if was_waiting {
1158 app.goal_continuation_waiting = false;
1159 app.status_message = Some(app.tr(MessageId::GoalContinuationStopped).to_string());
1160 return true;
1161 }
1162 mark_active_turn_cancelled_locally(app);
1163 current_streaming_text.clear();
1164 stream_display_clock.reset();
1165 app.status_message = Some(parent_stop_status(app, "Request cancelled"));
1166 false
1167 }
1168 }
1169
1170 fn suppress_engine_event_after_local_cancel(event: &EngineEvent) -> bool {
1171 matches!(
1172 event,
1173 EngineEvent::MessageStarted { .. }
1174 | EngineEvent::MessageDelta { .. }
1175 | EngineEvent::MessageComplete { .. }
1176 | EngineEvent::ThinkingStarted { .. }
1177 | EngineEvent::ThinkingDelta { .. }
1178 | EngineEvent::ThinkingComplete { .. }
1179 | EngineEvent::ToolCallStarted { .. }
1180 | EngineEvent::ToolCallHeartbeat
1181 | EngineEvent::ToolCallComplete { .. }
1182 | EngineEvent::ApprovalRequired { .. }
1183 | EngineEvent::UserInputRequired { .. }
1184 | EngineEvent::ElevationRequired { .. }
1185 | EngineEvent::SessionUpdated { .. }
1186 )
1187 }
1188
1189 fn ignore_stale_stream_event_while_idle(event: &EngineEvent) -> bool {
1190 matches!(
1191 event,
1192 EngineEvent::MessageStarted { .. }
1193 | EngineEvent::MessageDelta { .. }
1194 | EngineEvent::MessageComplete { .. }
1195 | EngineEvent::ThinkingStarted { .. }
1196 | EngineEvent::ThinkingDelta { .. }
1197 | EngineEvent::ThinkingComplete { .. }
1198 | EngineEvent::ToolCallStarted { .. }
1199 | EngineEvent::ToolCallHeartbeat
1200 | EngineEvent::ToolCallComplete { .. }
1201 | EngineEvent::ApprovalRequired { .. }
1202 | EngineEvent::UserInputRequired { .. }
1203 | EngineEvent::ElevationRequired { .. }
1204 )
1205 }
1206
1207 type ProviderKeyVerification<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
1208
1209 pub(crate) fn request_foreground_shell_background(app: &mut App) {
1210 if !app.is_loading {
1211 app.status_message = Some("No foreground shell wait to move to /jobs".to_string());
1212 return;
1213 }
1214 if !active_foreground_shell_running(app) {
1215 // #3032 AC3: name the reason backgrounding is unavailable —
1216 // interactive execs and non-shell blocking tools are visibly running
1217 // but cannot be detached, and a generic shrug reads like a bug.
1218 let reason = if terminal_pause_has_live_owner(app) {
1219 "the running command is interactive"
1220 } else if app
1221 .active_cell
1222 .as_ref()
1223 .is_some_and(|active| !active.is_empty())
1224 {
1225 "the running tool is not a foreground shell command"
1226 } else {
1227 "no foreground shell command is running"
1228 };
1229 app.status_message = Some(format!(
1230 "Cannot move to /jobs: {reason}. Press Ctrl+C to cancel the turn, or wait for completion."
1231 ));
1232 return;
1233 }
1234
1235 match request_active_foreground_shell_background(app) {
1236 Ok(()) => {
1237 app.status_message = Some("Moving current shell command to /jobs...".to_string());
1238 }
1239 Err(err) => {
1240 app.status_message = Some(err.to_string());
1241 }
1242 }
1243 }
1244
1245 fn request_active_foreground_shell_background(app: &App) -> Result<()> {
1246 let shell_manager = app
1247 .runtime_services
1248 .shell_manager
1249 .clone()
1250 .context("No shell session is active.")?;
1251 let mut manager = shell_manager.lock().map_err(|_| {
1252 anyhow::anyhow!("Shell tracking hit an internal error — restart Codewhale to recover.")
1253 })?;
1254 manager.request_foreground_background();
1255 Ok(())
1256 }
1257
1258 pub(crate) fn prefill_jobs_cancel_all_if_tasks_sidebar(app: &mut App) -> bool {
1259 if !app.view_stack.is_empty()
1260 || app.work_surface.panel != crate::tui::work_surface::RailPanel::Tasks
1261 || app.work_surface.last_area.is_none()
1262 || !app
1263 .task_panel
1264 .iter()
1265 .any(|task| task.id.starts_with("shell_") && task.status == "running")
1266 {
1267 return false;
1268 }
1269
1270 app.input = "/jobs cancel-all".to_string();
1271 app.cursor_position = app.input.len();
1272 app.status_message = Some("Press Enter to cancel all running commands".to_string());
1273 true
1274 }
1275
1276 pub(crate) fn active_foreground_shell_running(app: &App) -> bool {
1277 app.active_cell.as_ref().is_some_and(|active| {
1278 active.entries().iter().any(|cell| {
1279 matches!(
1280 cell,
1281 HistoryCell::Tool(ToolCell::Exec(exec))
1282 if exec.status == ToolStatus::Running
1283 && exec.interaction.is_none()
1284 && exec.shell_task_id.is_none()
1285 )
1286 })
1287 })
1288 }
1289
1290 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1291 pub(crate) enum SearchDirection {
1292 Forward,
1293 Backward,
1294 }
1295
1296 pub(crate) fn clamp_event_poll_timeout(timeout: Duration) -> Duration {
1297 const MIN_EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(1);
1298 timeout.max(MIN_EVENT_POLL_TIMEOUT)
1299 }
1300
1301 /// Decide whether an `AgentComplete` event should fire a subagent-completion
1302 /// desktop notification, per the `[notifications].subagent_completion` mode.
1303 /// `settings()` still has the final say (method=off / condition=never).
1304 fn should_notify_subagent_completion(
1305 mode: crate::config::SubagentCompletionNotification,
1306 has_other_running_subagents: bool,
1307 workflow_tool_running: bool,
1308 ) -> bool {
1309 use crate::config::SubagentCompletionNotification as Mode;
1310 match mode {
1311 Mode::Off => false,
1312 Mode::Always => true,
1313 Mode::FinalOnly => !has_other_running_subagents && !workflow_tool_running,
1314 }
1315 }
1316
1317 // Keyboard-shortcut predicates moved to `tui/key_shortcuts.rs`.
1318
1319 #[derive(Debug, Clone, PartialEq, Eq)]
1320 pub(crate) enum StartupVersionCheckSource {
1321 Disabled,
1322 ConfiguredUrl(String),
1323 ReleaseResolver,
1324 }
1325
1326 /// A newer-stable-release notice, carrying enough context to render both the
1327 /// short transient toast and the durable in-transcript update prompt (#3961).
1328 #[derive(Debug, Clone, PartialEq, Eq)]
1329 pub(crate) struct UpdateNotice {
1330 current: String,
1331 latest: String,
1332 }
1333
1334 impl UpdateNotice {
1335 /// Short line for the transient status toast, naming the command that
1336 /// actually updates *this* install.
1337 fn toast_line(&self, install: InstallMethod) -> String {
1338 format!(
1339 "v{latest} available - run `{command}` and restart",
1340 latest = self.latest,
1341 command = install.update_command()
1342 )
1343 }
1344
1345 /// Compact header chip label shown once the check has landed. Quiet by
1346 /// design: no action verb, no repetition — the toast and transcript
1347 /// notice carry the update instructions (#14).
1348 fn chip_label(&self) -> String {
1349 format!("↑ v{latest}", latest = self.latest)
1350 }
1351
1352 /// Durable, actionable notice pushed into the transcript so it survives the
1353 /// toast TTL. Includes current/latest versions, release notes, the exact
1354 /// update command, and restart guidance.
1355 ///
1356 /// Package-managed installs get their manager's command instead of
1357 /// `codewhale update`, plus an explicit warning: self-updating a binary
1358 /// Homebrew or npm owns leaves the manager's metadata lying about what is
1359 /// on disk, and the next upgrade silently reverts the user.
1360 fn notice_block(&self, install: InstallMethod) -> String {
1361 let action = if install.supports_self_update() {
1362 "Run `/update install` here (preview it with a bare `/update`), or `codewhale update` in a shell, then restart Codewhale."
1363 .to_string()
1364 } else {
1365 format!(
1366 "Installed via {label}. Run `{command}`, then restart Codewhale.\n\
1367 Do not use `codewhale update` here — it would replace a binary {label} manages.",
1368 label = install.label(),
1369 command = install.update_command()
1370 )
1371 };
1372 format!(
1373 "Update available: v{current} -> v{latest}\n\
1374 Release notes: https://github.com/Hmbown/CodeWhale/releases/tag/v{latest}\n\
1375 {action}",
1376 current = self.current,
1377 latest = self.latest
1378 )
1379 }
1380 }
1381
1382 mod activity_detail;
1383
1384 #[cfg(test)]
1385 mod provider_key_validation_tests {
1386 use super::*;
1387 use crate::core::engine::mock_engine_handle;
1388 use ratatui::{buffer::Buffer, layout::Rect};
1389 use tempfile::TempDir;
1390
1391 struct ConfigPathEnvGuard {
1392 _tmp: TempDir,
1393 // Onboarding completion runs the setup transaction (setup_state.json,
1394 // settings.toml) against `CODEWHALE_HOME`; without this guard the
1395 // fixture provider landed in the developer's real ~/.codewhale (#5932).
1396 _codewhale_home: crate::test_support::EnvVarGuard,
1397 _codewhale_config_path: crate::test_support::EnvVarGuard,
1398 _deepseek_config_path: crate::test_support::EnvVarGuard,
1399 _lock: crate::test_support::TestEnvLock,
1400 }
1401
1402 impl ConfigPathEnvGuard {
1403 fn new() -> Self {
1404 let lock = crate::test_support::lock_test_env();
1405 let tmp = TempDir::new().expect("config tempdir");
1406 let home = tmp.path().join(".codewhale");
1407 let config_path = home.join("config.toml");
1408 std::fs::create_dir_all(config_path.parent().expect("config parent"))
1409 .expect("config dir");
1410 Self {
1411 _tmp: tmp,
1412 _codewhale_home: crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home),
1413 _codewhale_config_path: crate::test_support::EnvVarGuard::set(
1414 "CODEWHALE_CONFIG_PATH",
1415 &config_path,
1416 ),
1417 _deepseek_config_path: crate::test_support::EnvVarGuard::set(
1418 "DEEPSEEK_CONFIG_PATH",
1419 &config_path,
1420 ),
1421 _lock: lock,
1422 }
1423 }
1424
1425 fn config_path(&self) -> PathBuf {
1426 std::env::var_os("CODEWHALE_CONFIG_PATH")
1427 .map(PathBuf::from)
1428 .expect("config path set")
1429 }
1430 }
1431
1432 fn create_test_app() -> App {
1433 let options = TuiOptions {
1434 start_in_agent_mode: true,
1435 skip_onboarding: false,
1436 ..crate::test_support::test_tui_options(PathBuf::from("."))
1437 };
1438 let mut app = App::new(options, &Config::default());
1439 // These suites assert legacy strip geometry (work surface above the
1440 // transcript). The Bottom default (round 3, 2026-09-01) has its own
1441 // coverage in work_surface::rail_panels_render_in_all_placements.
1442 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
1443 app.api_provider = ApiProvider::Deepseek;
1444 app.model = "deepseek-v4-pro".to_string();
1445 app.auto_model = false;
1446 app
1447 }
1448
1449 #[test]
1450 fn api_key_live_mirror_revokes_stale_external_credential_consent() {
1451 let external_path = if cfg!(windows) {
1452 PathBuf::from(r"C:\Users\test\grok-auth.json")
1453 } else {
1454 PathBuf::from("/tmp/grok-auth.json")
1455 };
1456 let mut config = Config {
1457 providers: Some(ProvidersConfig {
1458 xai: ProviderConfig {
1459 auth_mode: Some("oauth".to_string()),
1460 external_credentials: Some(
1461 codewhale_config::ExternalCredentialConsentToml::read_only(
1462 codewhale_config::ProviderKind::Xai,
1463 codewhale_config::ExternalCredentialSource::GrokCli,
1464 external_path,
1465 ),
1466 ),
1467 ..Default::default()
1468 },
1469 ..Default::default()
1470 }),
1471 ..Default::default()
1472 };
1473
1474 mirror_saved_api_key_in_config(
1475 &mut config,
1476 ApiProvider::Xai,
1477 "codewhale-owned-api-key".to_string(),
1478 );
1479
1480 let xai = config
1481 .provider_config_for(ApiProvider::Xai)
1482 .expect("xAI live config");
1483 assert_eq!(xai.auth_mode.as_deref(), Some("api_key"));
1484 assert_eq!(xai.api_key.as_deref(), Some("codewhale-owned-api-key"));
1485 assert!(xai.external_credentials.is_none());
1486 }
1487
1488 struct MockProviderKeyVerifier {
1489 result: Result<(), String>,
1490 calls: std::sync::Mutex<Vec<(ApiProvider, String, String)>>,
1491 }
1492
1493 impl MockProviderKeyVerifier {
1494 fn new(result: Result<(), String>) -> Self {
1495 Self {
1496 result,
1497 calls: std::sync::Mutex::new(Vec::new()),
1498 }
1499 }
1500
1501 fn calls(&self) -> Vec<(ApiProvider, String, String)> {
1502 self.calls.lock().expect("calls lock").clone()
1503 }
1504 }
1505
1506 impl ProviderKeyVerifier for MockProviderKeyVerifier {
1507 fn verify<'a>(
1508 &'a self,
1509 provider: ApiProvider,
1510 api_key: &'a str,
1511 base_url: &'a str,
1512 ) -> ProviderKeyVerification<'a> {
1513 self.calls.lock().expect("calls lock").push((
1514 provider,
1515 api_key.to_string(),
1516 base_url.to_string(),
1517 ));
1518 Box::pin(std::future::ready(self.result.clone()))
1519 }
1520 }
1521
1522 fn openrouter_config(base_url: &str) -> Config {
1523 Config {
1524 providers: Some(ProvidersConfig {
1525 openrouter: ProviderConfig {
1526 base_url: Some(base_url.to_string()),
1527 ..ProviderConfig::default()
1528 },
1529 ..ProvidersConfig::default()
1530 }),
1531 ..Config::default()
1532 }
1533 }
1534
1535 fn two_named_custom_routes() -> Config {
1536 Config {
1537 provider: Some("custom-a".to_string()),
1538 providers: Some(ProvidersConfig {
1539 custom: std::collections::HashMap::from([
1540 (
1541 "custom-a".to_string(),
1542 ProviderConfig {
1543 kind: Some("openai-compatible".to_string()),
1544 base_url: Some("http://127.0.0.1:18181/v1".to_string()),
1545 model: Some("model-a".to_string()),
1546 api_key: Some("key-a".to_string()),
1547 ..Default::default()
1548 },
1549 ),
1550 (
1551 "custom-b".to_string(),
1552 ProviderConfig {
1553 kind: Some("openai-compatible".to_string()),
1554 base_url: Some("http://127.0.0.1:18182/v1".to_string()),
1555 model: Some("model-b".to_string()),
1556 ..Default::default()
1557 },
1558 ),
1559 ]),
1560 ..Default::default()
1561 }),
1562 ..Default::default()
1563 }
1564 }
1565
1566 #[test]
1567 fn provider_key_check_classifies_transport_failures_truthfully() {
1568 assert_eq!(
1569 provider_verification_error_category("connection refused"),
1570 crate::error_taxonomy::ErrorCategory::Network
1571 );
1572 assert_eq!(
1573 provider_verification_error_category("request timed out"),
1574 crate::error_taxonomy::ErrorCategory::Timeout
1575 );
1576 assert_eq!(
1577 provider_verification_error_category("HTTP 429 rate limit"),
1578 crate::error_taxonomy::ErrorCategory::RateLimit
1579 );
1580 assert_eq!(
1581 provider_verification_error_category("HTTP 401 unauthorized"),
1582 crate::error_taxonomy::ErrorCategory::Authentication
1583 );
1584 assert_eq!(
1585 provider_verification_error_category("HTTP 403 forbidden"),
1586 crate::error_taxonomy::ErrorCategory::Authorization
1587 );
1588 assert_eq!(
1589 provider_verification_error_category("HTTP 500 upstream failure"),
1590 crate::error_taxonomy::ErrorCategory::Network
1591 );
1592 }
1593
1594 #[tokio::test]
1595 async fn provider_key_submit_opens_model_pick_without_persisting_on_validation_success() {
1596 let config_env = ConfigPathEnvGuard::new();
1597 let mut app = create_test_app();
1598 let mut engine = mock_engine_handle();
1599 let mut config = openrouter_config("https://mock.openrouter.test/v1");
1600 let verifier = MockProviderKeyVerifier::new(Ok(()));
1601 let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None)
1602 .expect("OpenRouter identity");
1603
1604 apply_provider_picker_api_key_with_verifier(
1605 &mut app,
1606 &mut engine.handle,
1607 &mut config,
1608 identity,
1609 "sk-verified".to_string(),
1610 None,
1611 &verifier,
1612 )
1613 .await;
1614
1615 assert_eq!(
1616 verifier.calls(),
1617 vec![(
1618 ApiProvider::Openrouter,
1619 "sk-verified".to_string(),
1620 "https://mock.openrouter.test/v1".to_string()
1621 )]
1622 );
1623 // Validation success must not persist or switch yet (#3875 residual):
1624 // the guided flow continues at model pick first.
1625 assert_eq!(app.api_provider, ApiProvider::Deepseek);
1626 assert_eq!(config.provider.as_deref(), None);
1627 assert_eq!(
1628 config
1629 .providers
1630 .as_ref()
1631 .and_then(|providers| providers.openrouter.api_key.as_deref()),
1632 None
1633 );
1634 let saved = std::fs::read_to_string(config_env.config_path()).unwrap_or_default();
1635 assert!(!saved.contains("sk-verified"));
1636 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
1637 assert!(
1638 app.status_message.as_deref().is_some_and(|status| {
1639 status.contains("Connection checked (/models returned 2xx)")
1640 }),
1641 "status names connection-probe success: {:?}",
1642 app.status_message
1643 );
1644 let verified_route = crate::provider_readiness::route_identity_for_model(
1645 &config,
1646 ApiProvider::Openrouter,
1647 crate::config::DEFAULT_OPENROUTER_MODEL,
1648 );
1649 assert_eq!(
1650 crate::provider_readiness::resolve_with_identity(
1651 &verified_route,
1652 crate::provider_readiness::CredentialState::Saved,
1653 true,
1654 &app.provider_health,
1655 ),
1656 crate::provider_readiness::ResolvedProviderReadiness::ConnectionCheckedModelUnchecked,
1657 "the live connection probe must not be reported as model ready",
1658 );
1659
1660 let picker = app.view_stack.pop().expect("provider picker reopened");
1661 let area = Rect::new(0, 0, 90, 16);
1662 let mut buf = Buffer::empty(area);
1663 picker.render(area, &mut buf);
1664 let rendered = (0..area.height)
1665 .map(|y| {
1666 (0..area.width)
1667 .map(|x| buf[(x, y)].symbol())
1668 .collect::<String>()
1669 })
1670 .collect::<Vec<_>>()
1671 .join("\n");
1672 assert!(
1673 rendered.contains("Connection checked (/models returned 2xx)")
1674 && rendered.contains("Pick a default model"),
1675 "expected model-pick stage UI, got:\n{rendered}"
1676 );
1677 }
1678
1679 #[tokio::test]
1680 async fn test_connection_records_models_probe_not_ready() {
1681 let config_env = ConfigPathEnvGuard::new();
1682 let mut app = create_test_app();
1683 let mut engine = mock_engine_handle();
1684 let mut config = openrouter_config("https://mock.openrouter.test/v1");
1685 config.provider = Some("openrouter".to_string());
1686 if let Some(providers) = config.providers.as_mut() {
1687 providers.openrouter.api_key = Some("sk-saved".to_string());
1688 }
1689 let verifier = MockProviderKeyVerifier::new(Ok(()));
1690 let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None)
1691 .expect("OpenRouter identity");
1692
1693 apply_provider_picker_test_connection_with_verifier(
1694 &mut app,
1695 &mut engine.handle,
1696 &mut config,
1697 identity,
1698 false,
1699 &verifier,
1700 )
1701 .await;
1702
1703 assert_eq!(
1704 verifier.calls(),
1705 vec![(
1706 ApiProvider::Openrouter,
1707 "sk-saved".to_string(),
1708 "https://mock.openrouter.test/v1".to_string()
1709 )]
1710 );
1711 let _ = config_env;
1712 assert_eq!(config.provider.as_deref(), Some("openrouter"));
1713 assert!(
1714 app.status_toasts.iter().any(|toast| {
1715 toast
1716 .text
1717 .contains("Connection checked (/models returned 2xx)")
1718 && !toast.text.contains("Pick a default model")
1719 }),
1720 "test connection names reachability only: {:?}",
1721 app.status_toasts
1722 );
1723 let verified_route = crate::provider_readiness::route_identity_for_model(
1724 &config,
1725 ApiProvider::Openrouter,
1726 crate::config::DEFAULT_OPENROUTER_MODEL,
1727 );
1728 assert_eq!(
1729 crate::provider_readiness::resolve_with_identity(
1730 &verified_route,
1731 crate::provider_readiness::CredentialState::Saved,
1732 true,
1733 &app.provider_health,
1734 ),
1735 crate::provider_readiness::ResolvedProviderReadiness::ConnectionCheckedModelUnchecked,
1736 );
1737 assert_ne!(
1738 crate::provider_readiness::resolve_with_identity(
1739 &verified_route,
1740 crate::provider_readiness::CredentialState::Saved,
1741 true,
1742 &app.provider_health,
1743 ),
1744 crate::provider_readiness::ResolvedProviderReadiness::Ready,
1745 );
1746 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
1747 }
1748
1749 #[tokio::test]
1750 async fn test_connection_without_key_does_not_mark_ready() {
1751 let config_env = ConfigPathEnvGuard::new();
1752 let mut app = create_test_app();
1753 let mut engine = mock_engine_handle();
1754 let mut config = openrouter_config("https://mock.openrouter.test/v1");
1755 let verifier = MockProviderKeyVerifier::new(Ok(()));
1756 let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None)
1757 .expect("OpenRouter identity");
1758
1759 apply_provider_picker_test_connection_with_verifier(
1760 &mut app,
1761 &mut engine.handle,
1762 &mut config,
1763 identity,
1764 false,
1765 &verifier,
1766 )
1767 .await;
1768
1769 assert!(verifier.calls().is_empty());
1770 let _ = config_env;
1771 assert!(
1772 app.status_toasts
1773 .iter()
1774 .any(|toast| toast.text.contains("No API key saved")),
1775 "{:?}",
1776 app.status_toasts
1777 );
1778 let verified_route = crate::provider_readiness::route_identity_for_model(
1779 &config,
1780 ApiProvider::Openrouter,
1781 crate::config::DEFAULT_OPENROUTER_MODEL,
1782 );
1783 assert_eq!(
1784 crate::provider_readiness::resolve_with_identity(
1785 &verified_route,
1786 crate::provider_readiness::CredentialState::MissingKey,
1787 true,
1788 &app.provider_health,
1789 ),
1790 crate::provider_readiness::ResolvedProviderReadiness::MissingKey,
1791 );
1792 }
1793
1794 #[tokio::test]
1795 async fn test_connection_failure_redacts_the_api_key() {
1796 let config_env = ConfigPathEnvGuard::new();
1797 let mut app = create_test_app();
1798 let mut engine = mock_engine_handle();
1799 let mut config = openrouter_config("https://mock.openrouter.test/v1");
1800 config.provider = Some("openrouter".to_string());
1801 if let Some(providers) = config.providers.as_mut() {
1802 providers.openrouter.api_key = Some("sk-saved".to_string());
1803 }
1804 let verifier = MockProviderKeyVerifier::new(Err(
1805 "HTTP 401: upstream echoed sk-saved in a long diagnostic body that must not stay visible"
1806 .repeat(4),
1807 ));
1808 let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None)
1809 .expect("OpenRouter identity");
1810
1811 apply_provider_picker_test_connection_with_verifier(
1812 &mut app,
1813 &mut engine.handle,
1814 &mut config,
1815 identity,
1816 true,
1817 &verifier,
1818 )
1819 .await;
1820
1821 let _ = config_env;
1822 let status = app
1823 .status_toasts
1824 .iter()
1825 .map(|toast| toast.text.as_str())
1826 .collect::<Vec<_>>()
1827 .join("\n");
1828 assert!(
1829 !status.contains("sk-saved"),
1830 "probe toast leaked the API key: {status}"
1831 );
1832 assert!(status.contains("***"), "{status}");
1833 assert!(
1834 app.provider_picker_memory
1835 .as_ref()
1836 .is_some_and(|memory| memory.catalog_view),
1837 "catalog browsing context must survive the probe"
1838 );
1839 let verified_route = crate::provider_readiness::route_identity_for_model(
1840 &config,
1841 ApiProvider::Openrouter,
1842 crate::config::DEFAULT_OPENROUTER_MODEL,
1843 );
1844 assert!(matches!(
1845 crate::provider_readiness::resolve_with_identity(
1846 &verified_route,
1847 crate::provider_readiness::CredentialState::Saved,
1848 true,
1849 &app.provider_health,
1850 ),
1851 crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed { .. }
1852 ));
1853 }
1854
1855 /// #4526: the wizard's StepFun billing-route choice must be the endpoint
1856 /// the key is probed against, and it must reach disk only once the user
1857 /// confirms — never as a side effect of validation.
1858 #[tokio::test]
1859 async fn stepfun_plan_route_is_validated_before_the_key_is_persisted() {
1860 let config_env = ConfigPathEnvGuard::new();
1861 let mut app = create_test_app();
1862 let mut engine = mock_engine_handle();
1863 let mut config = Config::default();
1864 let verifier = MockProviderKeyVerifier::new(Ok(()));
1865 let identity = picker_provider_identity(&config, ApiProvider::Stepfun, None)
1866 .expect("StepFun identity");
1867
1868 apply_provider_picker_api_key_with_verifier(
1869 &mut app,
1870 &mut engine.handle,
1871 &mut config,
1872 identity,
1873 "step-plan-key".to_string(),
1874 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()),
1875 &verifier,
1876 )
1877 .await;
1878
1879 assert_eq!(
1880 verifier.calls(),
1881 vec![(
1882 ApiProvider::Stepfun,
1883 "step-plan-key".to_string(),
1884 crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()
1885 )],
1886 "the chosen Step Plan endpoint must be the one live-validated"
1887 );
1888 assert_eq!(
1889 config
1890 .providers
1891 .as_ref()
1892 .and_then(|providers| providers.stepfun.base_url.clone()),
1893 None,
1894 "validation must not mutate the live config"
1895 );
1896 let saved = std::fs::read_to_string(config_env.config_path()).unwrap_or_default();
1897 assert!(
1898 !saved.contains("step_plan"),
1899 "nothing persisted yet: {saved}"
1900 );
1901 assert!(!saved.contains("step-plan-key"), "no secret yet: {saved}");
1902 }
1903
1904 /// The confirm stage writes the endpoint into `[providers.stepfun]` and
1905 /// leaves every other provider table alone.
1906 #[tokio::test]
1907 async fn stepfun_setup_confirm_writes_only_the_stepfun_base_url() {
1908 let config_env = ConfigPathEnvGuard::new();
1909 let mut app = create_test_app();
1910 let mut engine = mock_engine_handle();
1911 let mut config = Config::default();
1912 let identity = picker_provider_identity(&config, ApiProvider::Stepfun, None)
1913 .expect("StepFun identity");
1914
1915 apply_provider_picker_setup_confirmed(
1916 &mut app,
1917 &mut engine.handle,
1918 &mut config,
1919 identity,
1920 "step-plan-key".to_string(),
1921 crate::config::DEFAULT_STEPFUN_MODEL.to_string(),
1922 None,
1923 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()),
1924 )
1925 .await;
1926
1927 let saved = std::fs::read_to_string(config_env.config_path()).expect("config written");
1928 let document: toml::Table = toml::from_str(&saved).expect("valid TOML");
1929 let providers = document
1930 .get("providers")
1931 .and_then(toml::Value::as_table)
1932 .expect("providers table");
1933 assert_eq!(
1934 providers
1935 .get("stepfun")
1936 .and_then(|entry| entry.get("base_url"))
1937 .and_then(toml::Value::as_str),
1938 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
1939 );
1940 assert_eq!(
1941 providers.keys().collect::<Vec<_>>(),
1942 vec!["stepfun"],
1943 "the route choice must not touch other provider tables"
1944 );
1945 assert!(
1946 document.get("base_url").is_none(),
1947 "the root base_url must stay untouched: {saved}"
1948 );
1949 assert_eq!(
1950 config
1951 .providers
1952 .as_ref()
1953 .and_then(|providers| providers.stepfun.base_url.as_deref()),
1954 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL),
1955 "the live config mirrors the persisted endpoint"
1956 );
1957 }
1958
1959 #[tokio::test]
1960 async fn replacing_legacy_kimi_import_verifies_and_persists_the_kimi_code_api_key_route() {
1961 let config_env = ConfigPathEnvGuard::new();
1962 std::fs::write(
1963 config_env.config_path(),
1964 r#"# preserve-kimi-comment
1965 [providers.moonshot]
1966 auth_mode = "kimi_oauth"
1967 "#,
1968 )
1969 .expect("seed legacy Kimi import config");
1970 let mut app = create_test_app();
1971 let mut engine = mock_engine_handle();
1972 let mut config = Config {
1973 providers: Some(ProvidersConfig {
1974 moonshot: ProviderConfig {
1975 auth_mode: Some("kimi_oauth".to_string()),
1976 ..ProviderConfig::default()
1977 },
1978 ..ProvidersConfig::default()
1979 }),
1980 ..Config::default()
1981 };
1982 let identity = picker_provider_identity(&config, ApiProvider::Moonshot, None)
1983 .expect("Moonshot identity");
1984 let verifier = MockProviderKeyVerifier::new(Ok(()));
1985
1986 apply_provider_picker_api_key_with_verifier(
1987 &mut app,
1988 &mut engine.handle,
1989 &mut config,
1990 identity.clone(),
1991 "sk-kimi-supported".to_string(),
1992 None,
1993 &verifier,
1994 )
1995 .await;
1996
1997 assert_eq!(
1998 verifier.calls(),
1999 vec![(
2000 ApiProvider::Moonshot,
2001 "sk-kimi-supported".to_string(),
2002 crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string(),
2003 )],
2004 "replacement keys must be verified against Kimi Code, not the ordinary Moonshot API"
2005 );
2006
2007 apply_provider_picker_setup_confirmed(
2008 &mut app,
2009 &mut engine.handle,
2010 &mut config,
2011 identity,
2012 "sk-kimi-supported".to_string(),
2013 crate::config::DEFAULT_KIMI_CODE_MODEL.to_string(),
2014 None,
2015 None,
2016 )
2017 .await;
2018
2019 let moonshot = config
2020 .providers
2021 .as_ref()
2022 .map(|providers| &providers.moonshot)
2023 .expect("in-memory Moonshot config");
2024 assert_eq!(moonshot.auth_mode.as_deref(), Some("api_key"));
2025 assert_eq!(
2026 moonshot.base_url.as_deref(),
2027 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)
2028 );
2029 assert_eq!(moonshot.api_key.as_deref(), Some("sk-kimi-supported"));
2030
2031 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2032 assert!(saved.contains("# preserve-kimi-comment"));
2033 assert!(saved.contains("auth_mode = \"api_key\""));
2034 assert!(saved.contains(&format!(
2035 "base_url = \"{}\"",
2036 crate::config::DEFAULT_KIMI_CODE_BASE_URL
2037 )));
2038 }
2039
2040 #[tokio::test]
2041 async fn provider_setup_confirm_persists_provider_model_and_preserves_comments() {
2042 let config_env = ConfigPathEnvGuard::new();
2043 // Seed a commented config so the confirm path must preserve it.
2044 std::fs::write(
2045 config_env.config_path(),
2046 r#"# keep-me-comment
2047 [providers.openrouter]
2048 # openrouter-table-comment
2049 base_url = "https://mock.openrouter.test/v1"
2050
2051 [providers.anthropic]
2052 api_key = "fixture-other-provider-key"
2053 "#,
2054 )
2055 .expect("seed config");
2056
2057 let mut app = create_test_app();
2058 let mut engine = mock_engine_handle();
2059 let mut config = openrouter_config("https://mock.openrouter.test/v1");
2060 config
2061 .providers
2062 .get_or_insert_with(ProvidersConfig::default)
2063 .anthropic
2064 .api_key = Some("fixture-other-provider-key".to_string());
2065 let model = "deepseek/deepseek-v4-pro".to_string();
2066 let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None)
2067 .expect("OpenRouter identity");
2068
2069 apply_provider_picker_setup_confirmed(
2070 &mut app,
2071 &mut engine.handle,
2072 &mut config,
2073 identity,
2074 "sk-confirmed".to_string(),
2075 model.clone(),
2076 None,
2077 None,
2078 )
2079 .await;
2080
2081 assert_eq!(app.api_provider, ApiProvider::Openrouter);
2082 assert_eq!(config.provider.as_deref(), Some("openrouter"));
2083 assert_eq!(
2084 config
2085 .providers
2086 .as_ref()
2087 .and_then(|providers| providers.openrouter.api_key.as_deref()),
2088 Some("sk-confirmed")
2089 );
2090 assert_eq!(
2091 config
2092 .providers
2093 .as_ref()
2094 .and_then(|providers| providers.openrouter.model.as_deref()),
2095 Some(model.as_str())
2096 );
2097 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2098 assert!(
2099 saved.contains("# keep-me-comment"),
2100 "root comment lost:\n{saved}"
2101 );
2102 assert!(
2103 saved.contains("# openrouter-table-comment"),
2104 "table comment lost:\n{saved}"
2105 );
2106 assert!(saved.contains("[providers.openrouter]"));
2107 assert!(saved.contains("api_key = \"sk-confirmed\""));
2108 assert!(saved.contains(&format!("model = \"{model}\"")));
2109 assert!(saved.contains("[providers.anthropic]"));
2110 assert!(saved.contains("api_key = \"fixture-other-provider-key\""));
2111 assert_eq!(
2112 config
2113 .providers
2114 .as_ref()
2115 .and_then(|providers| providers.anthropic.api_key.as_deref()),
2116 Some("fixture-other-provider-key"),
2117 "saving OpenRouter must not overwrite a different provider slot"
2118 );
2119 }
2120
2121 #[tokio::test]
2122 async fn provider_key_submit_reopens_picker_without_persisting_on_validation_failure() {
2123 let config_env = ConfigPathEnvGuard::new();
2124 let mut app = create_test_app();
2125 let mut engine = mock_engine_handle();
2126 let mut config = openrouter_config("https://mock.openrouter.test/v1");
2127 let verifier = MockProviderKeyVerifier::new(Err("HTTP 401: unauthorized".to_string()));
2128 let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None)
2129 .expect("OpenRouter identity");
2130
2131 apply_provider_picker_api_key_with_verifier(
2132 &mut app,
2133 &mut engine.handle,
2134 &mut config,
2135 identity,
2136 "sk-rejected".to_string(),
2137 None,
2138 &verifier,
2139 )
2140 .await;
2141
2142 assert_eq!(app.api_provider, ApiProvider::Deepseek);
2143 assert_eq!(config.provider.as_deref(), None);
2144 assert_eq!(
2145 config
2146 .providers
2147 .as_ref()
2148 .and_then(|providers| providers.openrouter.api_key.as_deref()),
2149 None
2150 );
2151 let saved = std::fs::read_to_string(config_env.config_path()).unwrap_or_default();
2152 assert!(!saved.contains("sk-rejected"));
2153 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
2154 assert!(
2155 app.status_message
2156 .as_deref()
2157 .is_some_and(|status| status.contains("API key verification failed")),
2158 "status names validation failure: {:?}",
2159 app.status_message
2160 );
2161
2162 let picker = app.view_stack.pop().expect("provider picker reopened");
2163 let area = Rect::new(0, 0, 90, 14);
2164 let mut buf = Buffer::empty(area);
2165 picker.render(area, &mut buf);
2166 let rendered = (0..area.height)
2167 .map(|y| {
2168 (0..area.width)
2169 .map(|x| buf[(x, y)].symbol())
2170 .collect::<String>()
2171 })
2172 .collect::<Vec<_>>()
2173 .join("\n");
2174 assert!(rendered.contains("Verification failed: HTTP 401: unauthorized"));
2175 }
2176
2177 #[tokio::test]
2178 async fn named_custom_verification_failure_and_dismiss_keep_committed_a_route() {
2179 let _config_env = ConfigPathEnvGuard::new();
2180 let mut app = create_test_app();
2181 app.set_provider_identity(ApiProvider::Custom, "custom-a");
2182 app.set_model_selection("model-a".to_string());
2183 let mut engine = mock_engine_handle();
2184 let mut config = two_named_custom_routes();
2185 let identity = picker_provider_identity(&config, ApiProvider::Custom, Some("custom-b"))
2186 .expect("custom B identity");
2187 let verifier = MockProviderKeyVerifier::new(Err("HTTP 401: unauthorized".to_string()));
2188
2189 apply_provider_picker_api_key_with_verifier(
2190 &mut app,
2191 &mut engine.handle,
2192 &mut config,
2193 identity,
2194 "rejected-b-key".to_string(),
2195 None,
2196 &verifier,
2197 )
2198 .await;
2199
2200 assert_eq!(config.provider.as_deref(), Some("custom-a"));
2201 assert_eq!(app.provider_identity_for_persistence(), "custom-a");
2202 app.view_stack.pop().expect("failed verifier picker");
2203 sync_config_provider_from_app(&mut config, &app);
2204 let route = validated_app_runtime_route(&app, &config).expect("committed A route");
2205 assert_eq!(route.identity.key, "custom-a");
2206 assert_eq!(route.client.base_url(), "http://127.0.0.1:18181/v1");
2207 }
2208
2209 #[tokio::test]
2210 async fn named_custom_setup_persists_exact_provider_table_and_model() {
2211 let config_env = ConfigPathEnvGuard::new();
2212 std::fs::write(
2213 config_env.config_path(),
2214 r#"provider = "custom-a"
2215
2216 [providers.custom-a]
2217 kind = "openai-compatible"
2218 base_url = "http://127.0.0.1:18181/v1"
2219 model = "model-a"
2220
2221 [providers.custom-b]
2222 kind = "openai-compatible"
2223 base_url = "http://127.0.0.1:18182/v1"
2224 model = "model-b"
2225 "#,
2226 )
2227 .expect("seed named custom config");
2228 let mut app = create_test_app();
2229 app.set_provider_identity(ApiProvider::Custom, "custom-a");
2230 app.set_model_selection("model-a".to_string());
2231 let mut engine = mock_engine_handle();
2232 let mut config = two_named_custom_routes();
2233 let identity = picker_provider_identity(&config, ApiProvider::Custom, Some("custom-b"))
2234 .expect("custom B identity");
2235
2236 apply_provider_picker_setup_confirmed(
2237 &mut app,
2238 &mut engine.handle,
2239 &mut config,
2240 identity,
2241 "saved-b-key".to_string(),
2242 "model-b-confirmed".to_string(),
2243 None,
2244 None,
2245 )
2246 .await;
2247
2248 assert_eq!(app.provider_identity_for_persistence(), "custom-b");
2249 assert_eq!(config.provider.as_deref(), Some("custom-b"));
2250 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2251 assert!(saved.contains("[providers.custom-b]"));
2252 assert!(saved.contains("api_key = \"saved-b-key\""));
2253 assert!(saved.contains("model = \"model-b-confirmed\""));
2254 assert!(!saved.contains("[providers.custom]\n"));
2255 }
2256
2257 #[test]
2258 fn legacy_literal_custom_identity_persistence_stays_root_shaped() {
2259 let config_env = ConfigPathEnvGuard::new();
2260 std::fs::write(
2261 config_env.config_path(),
2262 r#"provider = "custom"
2263 base_url = "http://127.0.0.1:18180/v1"
2264 default_text_model = "legacy-model"
2265 "#,
2266 )
2267 .expect("seed legacy root route");
2268 let config = Config {
2269 provider: Some("custom".to_string()),
2270 base_url: Some("http://127.0.0.1:18180/v1".to_string()),
2271 default_text_model: Some("legacy-model".to_string()),
2272 ..Default::default()
2273 };
2274 let identity = config
2275 .resolve_provider_identity("custom")
2276 .expect("legacy identity");
2277
2278 crate::config::save_api_key_for_identity(&identity, &config, "legacy-saved-key")
2279 .expect("save legacy key");
2280 crate::config::save_provider_model_for_identity(&identity, &config, "legacy-model-updated")
2281 .expect("save legacy model");
2282
2283 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2284 assert!(saved.contains("api_key = \"legacy-saved-key\""));
2285 assert!(saved.contains("default_text_model = \"legacy-model-updated\""));
2286 assert!(!saved.contains("[providers.custom]"));
2287 let reloaded = Config::load(Some(config_env.config_path()), None).expect("reload legacy");
2288 assert!(reloaded.uses_legacy_literal_custom_route());
2289 assert_eq!(
2290 reloaded
2291 .resolve_provider_identity("custom")
2292 .expect("repeat legacy identity"),
2293 identity
2294 );
2295 let route =
2296 resolve_runtime_route(&reloaded, ApiProvider::Custom, Some("legacy-model-updated"))
2297 .expect("resolve reloaded legacy")
2298 .validate()
2299 .expect("preflight reloaded legacy");
2300 assert_eq!(route.client.base_url(), "http://127.0.0.1:18180/v1");
2301 }
2302
2303 #[test]
2304 fn legacy_active_route_does_not_redirect_named_custom_persistence_to_root() {
2305 let config_env = ConfigPathEnvGuard::new();
2306 std::fs::write(
2307 config_env.config_path(),
2308 r#"provider = "custom"
2309 api_key = "legacy-root-key"
2310 base_url = "http://127.0.0.1:18180/v1"
2311 default_text_model = "legacy-model"
2312
2313 [providers.custom-b]
2314 kind = "openai-compatible"
2315 base_url = "http://127.0.0.1:18182/v1"
2316 model = "model-b"
2317 "#,
2318 )
2319 .expect("seed coexistence config");
2320 let config = Config::load(Some(config_env.config_path()), None).expect("load config");
2321 assert!(config.uses_legacy_literal_custom_route());
2322 let identity = config
2323 .resolve_provider_identity("custom-b")
2324 .expect("named custom identity");
2325
2326 crate::config::save_api_key_for_identity(&identity, &config, "saved-b-key")
2327 .expect("save named custom key");
2328 crate::config::save_provider_model_for_identity(&identity, &config, "model-b-updated")
2329 .expect("save named custom model");
2330
2331 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2332 assert!(saved.contains("api_key = \"legacy-root-key\""));
2333 assert!(saved.contains("default_text_model = \"legacy-model\""));
2334 assert!(saved.contains("[providers.custom-b]"));
2335 assert!(saved.contains("api_key = \"saved-b-key\""));
2336 assert!(saved.contains("model = \"model-b-updated\""));
2337 }
2338 }
2339
2340 /// Build the foreground receipt only from the immutable route captured when
2341 /// this turn started. The app's selected route may already have changed by the
2342 /// time `TurnComplete` is handled, so it is not accepted as an input here.
2343 fn completed_turn_cost_route_receipt(
2344 completed_turn: Option<&crate::tui::app::ActiveTurnMetadata>,
2345 audit: &crate::pricing::TurnCostAudit,
2346 ) -> Option<String> {
2347 let route = completed_turn?.route.as_ref()?;
2348 Some(route.cost_envelope()?.receipt(audit))
2349 }
2350
2351 #[cfg(test)]
2352 mod tests;
2353
2354 #[cfg(test)]
2355 #[test]
2356 fn fleet_role_entry_opens_shared_picker_and_cancel_restores_parked_roster() {
2357 use crate::tui::views::ModalView;
2358 let _env = crate::test_support::lock_test_env();
2359 let workspace = tempfile::tempdir().unwrap();
2360 let config = Config::default();
2361 let mut app = App::new(
2362 crate::test_support::test_tui_options(workspace.path()),
2363 &config,
2364 );
2365 app.view_stack
2366 .push(crate::tui::views::fleet_roster::FleetRosterView::new(
2367 &app, &config,
2368 ));
2369 open_fleet_setup_target(&mut app, &config, Some("manager"));
2370 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ModelPicker));
2371 let mut picker = app.view_stack.pop().unwrap();
2372 let action = picker
2373 .as_any_mut()
2374 .downcast_mut::<crate::tui::model_picker::ModelPickerView>()
2375 .unwrap()
2376 .handle_key(crossterm::event::KeyEvent::new(
2377 crossterm::event::KeyCode::Esc,
2378 crossterm::event::KeyModifiers::NONE,
2379 ));
2380 let ViewAction::EmitAndClose(ViewEvent::FleetAssignmentPickerDismissed { editor_id }) = action
2381 else {
2382 panic!("assignment cancel must identify its editor")
2383 };
2384 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::FleetSetup));
2385 handlers::dismiss_fleet_assignment(&mut app, editor_id);
2386 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::FleetRoster));
2387 assert!(
2388 !workspace
2389 .path()
2390 .join(".codewhale/agents/manager.toml")
2391 .exists()
2392 );
2393 }
2394
2394 lines RUST