返回 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 #[cfg(test)]
59 use crate::config::ProviderConfig;
60 use crate::config::{
61 Config, ProviderIdentity, ProviderKind, ProvidersConfig, StatusItem, UpdateConfig,
62 persist_external_credential_consent_for_at, revoke_external_credential_consent_for_at,
63 };
64 use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
65 use crate::core::events::Event as EngineEvent;
66 use crate::core::ops::{Op, ProviderRuntimeStatus, UserInputProvenance};
67 use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals};
68 use crate::llm_client::LlmClient;
69 use crate::prompts;
70 #[cfg(test)]
71 use crate::route_runtime::resolve_runtime_route;
72 use crate::route_runtime::resolve_runtime_route_for_identity;
73 #[cfg(test)]
74 use crate::session_manager::create_saved_session_with_id_and_mode;
75 use crate::session_manager::{
76 OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager,
77 };
78 use crate::settings::Settings;
79 use crate::task_manager::{
80 NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, TaskSummary,
81 };
82 use crate::tools::goal::{GoalSnapshot, GoalStatus};
83 use crate::tools::shell::{ShellJobSnapshot, ShellStatus};
84 use crate::tools::spec::{RuntimeToolServices, ToolResult};
85 use crate::tools::subagent::{MailboxMessage, SubAgentStatus, subagent_progress_tool_display_name};
86 use crate::tui::auto_router;
87 use crate::tui::clipboard::ClipboardContent;
88 use crate::tui::color_compat::ColorCompatBackend;
89 use crate::tui::command_palette::{
90 CommandPaletteView, build_entries_with_plugins as build_command_palette_entries,
91 };
92 use crate::tui::composer_ui::*;
93 use crate::tui::context_inspector::ContextInspectorView;
94 use crate::tui::event_broker::EventBroker;
95 use crate::tui::file_picker_relevance;
96 use crate::tui::footer_ui::friendly_subagent_progress;
97 use crate::tui::format_helpers;
98 use crate::tui::hotbar::actions::HotbarDispatch;
99 use crate::tui::key_shortcuts;
100 use crate::tui::live_transcript::LiveTranscriptOverlay;
101 use crate::tui::mcp_routing::{add_mcp_message, open_mcp_extensions};
102 use crate::tui::mouse_ui::*;
103 use crate::tui::notifications;
104 use crate::tui::onboarding;
105 use crate::tui::pager::PagerView;
106 use crate::tui::persistence_actor::{self, PersistRequest};
107 use crate::tui::scrolling::TranscriptScroll;
108 use crate::turn_route_plan::{PlannedTurnRoute, TurnRoutePlanRequest, plan_turn_route};
109 use crate::work_graph::task_owner_snapshot;
110 use codewhale_localization::{MessageId, tr};
111 use codewhale_models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
112 use codewhale_palette as palette;
113 // SelectionAutoscroll unused
114 use crate::tui::motion::{FrameRequester, MotionMode};
115 use crate::tui::session_picker::SessionPickerView;
116 use crate::tui::shell_job_routing::{
117 add_shell_job_message, format_shell_job_list, format_shell_poll, open_shell_job_pager,
118 };
119 use crate::tui::streaming::StreamDisplayClock;
120 use crate::tui::streaming_thinking;
121 use crate::tui::subagent_routing::{
122 apply_subagent_terminal_projection, format_task_list, handle_subagent_mailbox_for_turn,
123 open_task_pager, parent_stop_status, reconcile_subagent_activity_state, running_agent_count,
124 sort_subagents_in_place, subagent_message_refreshes_workspace_context, task_mode_label,
125 task_summary_to_panel_entry,
126 };
127 #[cfg(test)]
128 use crate::tui::subagent_routing::{handle_subagent_mailbox, reconcile_subagent_activity_state_at};
129 #[cfg(test)]
130 use crate::tui::tool_routing::exploring_label;
131 use crate::tui::tool_routing::{
132 apply_owned_workflow_ui_event, handle_tool_call_complete, handle_tool_call_started,
133 };
134 use crate::tui::ui_text::history_cell_to_text;
135 use crate::tui::user_input::UserInputView;
136 use crate::tui::views::subagent_view_agents;
137 use crate::tui::vim_mode;
138 use crate::tui::workspace_context;
139
140 use crate::reasoning_preference::{EffectiveReasoningEffort, ReasoningEffort};
141
142 use super::key_actions;
143
144 use super::app::{
145 ActiveCompaction, ActiveTurnMetadata, AgentCurrentActivity, App, AppAction,
146 ComposerSubmitAction, ComposerSubmitChord, GoalControlIntent, OnboardingState,
147 PendingGoalControl, PendingProviderSwitch, QueuedMessage, RedactionGateNotice, ScreenMode,
148 StatusToast, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind,
149 ToolEvidence, TuiOptions, bound_agent_activity_text, is_stop_word,
150 looks_like_slash_command_input, shell_command_from_bang_input,
151 };
152 use super::approval::{
153 ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
154 };
155 use super::history::{
156 ExecCell, HistoryCell, ReasoningAction, ThinkingFold, ToolCell, ToolStatus,
157 history_cells_from_message, summarize_tool_output,
158 };
159 use super::slash_menu::{
160 apply_slash_menu_selection, partial_inline_skill_mention_at_cursor,
161 try_autocomplete_slash_command, visible_slash_menu_entries,
162 };
163 use super::views::{ConfigView, HelpView, ModalKind, ViewAction, ViewEvent};
164 use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview};
165 use super::widgets::{ChatWidget, ComposerWidget, Renderable};
166
167 // Activity Detail / raw-detail / pager-text helpers extracted into `activity_detail`
168 // (issue #4103). Re-export the cross-module entry points so existing
169 // `crate::tui::ui::{...}` importers (mouse_ui, footer_ui) keep resolving, and
170 // import the ui-internal entry points used from this file's own body.
171 pub(crate) use self::activity_detail::{
172 completed_assistant_answer_text, copy_cell_to_clipboard, detail_target_label,
173 open_details_pager_for_cell, open_focused_cell_pager, turn_handoff_markdown,
174 };
175 use self::activity_detail::{
176 copy_focused_cell, detail_target_cell_index, extract_reasoning_header,
177 open_reasoning_detail_pager, open_tool_details_pager, open_turn_inspector_pager,
178 };
179 // Ctrl+O now opens the full recorded Reasoning Detail for the selected or
180 // current reasoning block. The whole-turn Turn Inspector moved to Ctrl+Alt+O
181 // and `/turn inspect`. (`v` raw leaf detail keeps using `open_tool_details_pager`.)
182
183 // === Constants ===
184
185 /// Upper bound on slash-menu entries returned to the renderer. The composer's
186 /// render path already paginates with center-tracking (see
187 /// `widgets::ComposerWidget::render`), so this only needs to be high enough to
188 /// encompass the full filtered command list — never the visible-row budget.
189 /// Bumped from 6 to 128 to fix #64 (selection couldn't reach commands beyond
190 /// the visible window because the source list itself was capped).
191 const SLASH_MENU_LIMIT: usize = 128;
192 const MIN_CHAT_HEIGHT: u16 = 3;
193 const MIN_COMPOSER_HEIGHT: u16 = 2;
194 const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
195 const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
196 const CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT: f64 = 60.0;
197 const UI_IDLE_POLL_MS: u64 = 48;
198 const UI_ACTIVE_POLL_MS: u64 = 24;
199 const SUBAGENT_HOOK_PREVIEW_LIMIT: usize = 2_048;
200 const DISPATCH_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(30);
201 /// Wall-clock time a turn may stay in `"in_progress"` with no activity before
202 /// the UI assumes the engine stalled (sub-agent hang, lost completion event,
203 /// engine panic) — unless the engine heartbeat reports a live bounded wait.
204 const TURN_STALL_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(300);
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<ContentBlock>;
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 .and_then(|wizard| {
613 wizard.install_model_draft(constitution, model_label.clone())
614 });
615 app.view_stack.push_boxed(boxed);
616 if let Some((title, content)) = preview {
617 open_text_pager(app, title, content);
618 app.status_message = Some(crate::tui::setup::model_draft_ready_message(
619 locale,
620 &model_label,
621 ));
622 }
623 }
624 }
625 Err(reason) => {
626 app.status_message = Some(crate::tui::setup::model_draft_failed_message(
627 locale,
628 &model_label,
629 &reason,
630 ));
631 }
632 }
633 app.needs_redraw = true;
634 }
635
636 /// Install a completed fleet-profile draft into the wizard (if it is still on
637 /// top), or surface a failure. Called from the event loop when the
638 /// background draft lands, and directly on the pre-spawn
639 /// provider-construction failure.
640 ///
641 /// The preview renders inline on the wizard's own Review step — deliberately
642 /// NOT in a separate pager (#4093): a standalone pager view owns its own
643 /// `g`/`G` scroll bindings and would swallow the ratify keypress, forcing an
644 /// Esc-then-g round trip before the user could actually save.
645 fn deliver_fleet_draft_result(
646 app: &mut App,
647 model_label: String,
648 picked_route: Option<(String, String)>,
649 reasoning_effort: Option<String>,
650 outcome: Result<Box<crate::fleet::profile::FleetProfileDraft>, String>,
651 locale: codewhale_localization::Locale,
652 ) {
653 match outcome {
654 Ok(draft) => {
655 if app.view_stack.top_kind() == Some(ModalKind::FleetSetup)
656 && let Some(mut boxed) = app.view_stack.pop()
657 {
658 let installed = boxed
659 .as_any_mut()
660 .downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>()
661 .and_then(|wizard| {
662 wizard.install_model_draft(
663 draft,
664 model_label.clone(),
665 picked_route.clone(),
666 reasoning_effort.clone(),
667 )
668 })
669 .is_some();
670 app.view_stack.push_boxed(boxed);
671 if installed {
672 app.status_message = Some(match locale {
673 codewhale_localization::Locale::ZhHans => {
674 format!("{model_label} 已起草配置。请查看下方 TOML,然后按 g 保存。")
675 }
676 _ => format!(
677 "{model_label} drafted the profile. Review the TOML below, then press g to save."
678 ),
679 });
680 }
681 }
682 }
683 Err(reason) => {
684 app.status_message = Some(match locale {
685 codewhale_localization::Locale::ZhHans => {
686 format!("{model_label} 未能起草配置({reason})。按 Enter 仍会插入编写提示。")
687 }
688 _ => format!(
689 "{model_label} could not draft the profile ({reason}). Enter still inserts the authoring prompt."
690 ),
691 });
692 }
693 }
694 app.needs_redraw = true;
695 }
696
697 // `format_*` chip/message builders moved to `tui/format_helpers.rs`.
698
699 fn is_work_graph_mutation_tool(name: &str) -> bool {
700 matches!(
701 name,
702 "update_plan"
703 | "work_update"
704 | "checklist_write"
705 | "todo_write"
706 | "checklist_add"
707 | "todo_add"
708 | "checklist_update"
709 | "todo_update"
710 | "task_create"
711 | "task_cancel"
712 // Unified durable-task tool (piagent phase B): covers the
713 // create/cancel actions the legacy names above carried.
714 | "tasks"
715 | "exec_shell"
716 | "exec_shell_wait"
717 | "exec_shell_cancel"
718 | "agent"
719 | "workflow"
720 )
721 }
722
723 /// UI watchdog bound for an in-progress turn with no activity (#6184).
724 ///
725 /// Decoupled from `stream_chunk_timeout_secs`: tying it to that budget made
726 /// the UI watchdog unable to fire before the 900s stream idle timeout. A
727 /// quiet model wait is protected by the engine heartbeat instead — while the
728 /// engine reports a bounded wait it has not flagged as overdue, the UI defers
729 /// to it (`reconcile_turn_liveness_with`).
730 fn turn_stall_watchdog_timeout(_app: &App) -> Duration {
731 TURN_STALL_WATCHDOG_TIMEOUT
732 }
733
734 fn active_turn_has_running_tool(app: &App) -> bool {
735 app.active_cell.as_ref().is_some_and(|active| {
736 active.entries().iter().any(|cell| match cell {
737 HistoryCell::Tool(tool) => tool.is_running(),
738 _ => false,
739 })
740 })
741 }
742
743 // Per-turn notification composition (settings, message body, summary)
744 // moved to `tui/notifications.rs` alongside the dispatch primitives.
745
746 /// The TUI's copy of a tool result for its API-message mirror. It is the same
747 /// view the engine gives the model (#6508): whole within the route's inline
748 /// budget, otherwise cut around a footer that names the saved full output.
749 /// A separate receipt here used to replace anything over 12,000 characters
750 /// with a 240-character preview, and that copy is what `SyncSession` sends
751 /// back to the engine.
752 fn tool_result_content_for_api_message(app: &App, name: &str, output: &ToolResult) -> String {
753 crate::core::engine::compact_tool_result_for_route(
754 app.api_provider,
755 &app.model,
756 app.active_route_limits,
757 name,
758 output,
759 )
760 }
761
762 // Streaming-thinking lifecycle helpers moved to `tui/streaming_thinking.rs`.
763
764 /// Data produced by the async dispatch phase that is needed to apply the
765 /// post-acceptance mutations to `App`.
766 #[derive(Debug, Clone)]
767 pub(crate) struct UserDispatchOutcome {
768 turn_compaction: CompactionConfig,
769 effective_provider: ProviderKind,
770 effective_model: String,
771 effective_provider_identity: String,
772 effective_provider_label: String,
773 effective_reasoning_effort: EffectiveReasoningEffort,
774 auto_selection: Option<crate::model_routing::AutoRouteSelection>,
775 }
776
777 /// Tell the operator that an explicit "make this my default" request did not
778 /// take effect, instead of leaving a normal apply summary that reads like
779 /// success. Silence here is what made the sticky-default bug so confusing.
780 fn note_startup_default_not_saved(app: &mut App, save_as_startup_default: bool) {
781 if !save_as_startup_default {
782 return;
783 }
784 let existing = app.status_message.take();
785 let note = "Startup default unchanged — the route was not applied.";
786 app.status_message = Some(match existing {
787 Some(message) if !message.trim().is_empty() => format!("{message} · {note}"),
788 _ => note.to_string(),
789 });
790 }
791
792 /// Route every Fleet-setup entry point to the storage surface that actually
793 /// controls the effective roster. A selected v2 Fleet always opens its exact
794 /// named editor; the legacy profile wizard is reachable only with no selected
795 /// Fleet. Selection resolution deliberately does not consult project trust.
796 fn open_fleet_setup_target(app: &mut App, config: &Config, member_id: Option<&str>) {
797 use crate::tui::views::fleet_setup::{FleetSetupEditTarget, resolve_fleet_setup_edit_target};
798
799 match resolve_fleet_setup_edit_target(&app.workspace) {
800 Ok(FleetSetupEditTarget::SelectedFleet { name, scope }) => {
801 if app.view_stack.top_kind() == Some(ModalKind::FleetDetail) {
802 return;
803 }
804 let Some(mut view) = crate::tui::views::fleet_detail::FleetDetailView::open_for_member(
805 app, config, &name, scope, member_id,
806 ) else {
807 app.set_sticky_status(
808 "Selected team is invalid or unreadable; open /fleet teams to repair or clear the selection. Legacy profiles were not opened."
809 .to_string(),
810 StatusToastLevel::Error,
811 None,
812 );
813 return;
814 };
815 let fleet_name = crate::safe_label::SafeLabel::phrase(&name);
816 let picker = if member_id.is_some() {
817 let (editor_id, target) = view.direct_assignment();
818 let (role, scope) = view.assignment_context();
819 view.route_selection(editor_id, target).map(|selection| {
820 crate::tui::model_picker::ModelPickerView::new_for_fleet_route(
821 app, config, target, editor_id, selection,
822 )
823 .with_assignment_context(role, scope)
824 })
825 } else {
826 None
827 };
828 app.view_stack.push(view);
829 if let Some(picker) = picker {
830 app.view_stack.push(picker);
831 }
832 app.status_message = Some(format!(
833 "Editing selected team `{fleet_name}` ({}) — legacy profiles will not be changed.",
834 scope.label()
835 ));
836 }
837 Ok(FleetSetupEditTarget::LegacyProfiles) => {
838 if app.view_stack.top_kind() == Some(ModalKind::FleetSetup) {
839 return;
840 }
841 if let Some(member_id) = member_id {
842 match crate::tui::views::fleet_setup::FleetSetupView::new_for_route_assignment(
843 app, config, member_id,
844 ) {
845 Ok(view) => {
846 if let ViewAction::Emit(ViewEvent::FleetProfileRoutePickRequested {
847 editor_id,
848 }) = view.route_pick_request()
849 && let Some(selection) = view.route_selection(editor_id)
850 {
851 let (role, scope) = view.assignment_context();
852 let picker =
853 crate::tui::model_picker::ModelPickerView::new_for_fleet_profile(
854 app, config, editor_id, selection,
855 )
856 .with_assignment_context(role, scope);
857 app.view_stack.push(view);
858 app.view_stack.push(picker);
859 }
860 }
861 Err(reason) => app.set_sticky_status(reason, StatusToastLevel::Error, None),
862 }
863 return;
864 }
865 let _ = app.next_draft_gen();
866 let view = match member_id {
867 Some(member_id) => crate::tui::views::fleet_setup::FleetSetupView::new_for_role(
868 app, config, member_id,
869 ),
870 None => crate::tui::views::fleet_setup::FleetSetupView::new(app, config),
871 };
872 app.view_stack.push(view);
873 }
874 Err(message) => {
875 app.set_sticky_status(message, StatusToastLevel::Error, None);
876 }
877 }
878 }
879
880 pub(crate) struct ProviderFallbackRollback {
881 identity: ProviderIdentity,
882 chain: Option<codewhale_config::ProviderChain>,
883 }
884
885 // File-picker relevance scoring moved to `tui/file_picker_relevance.rs`.
886
887 #[cfg(test)]
888 use std::process::{Command, Stdio};
889
890 // `ui.rs` had grown past 19k lines. These three modules hold the same code,
891 // moved verbatim, and are re-exported so every existing path still resolves.
892 mod apply;
893 mod approval_routing;
894 pub(crate) mod feedback_host;
895 use approval_routing::*;
896 mod event_loop;
897 mod handlers;
898
899 pub(crate) use apply::*;
900 pub(crate) use event_loop::*;
901 pub(crate) use handlers::*;
902 // The crate-wide glob would otherwise narrow this to `pub(crate)`; `tui/mod.rs`
903 // re-exports it as the binary's entry point.
904 pub use event_loop::run_tui;
905
906 mod compaction_flow;
907 pub(crate) use compaction_flow::*;
908 pub(crate) use provider_setup::*;
909 mod dispatch;
910 mod dispatch_prepare;
911 pub(crate) use dispatch_prepare::*;
912 pub(crate) mod fatal_signal_guard;
913 // #6169: runtime half of the foreground-ownership contract — restore on stop,
914 // rebuild on continue. Sits next to the fatal guard because both write the same
915 // teardown table.
916 pub(crate) mod job_control_guard;
917 mod motion;
918 mod observer_hooks;
919 mod provider_setup;
920 mod release_check;
921 mod remote_control_bridge;
922 mod task_projection;
923 mod terminal;
924 mod terminal_input;
925 use remote_control_bridge::*;
926 use terminal_input::*;
927 // #6165: `external_editor` is a sibling of `ui`, and the pump pause now lives
928 // inside its `with_suspended_tui` so no editor entry point can forget it.
929 pub(crate) use terminal_input::pause_terminal_input_for_child;
930
931 pub(crate) use dispatch::*;
932 pub(crate) use motion::*;
933 pub(crate) use release_check::*;
934 pub(crate) use terminal::*;
935
936 // `frame` is `pub(crate)` so sibling modules (e.g. the widgets ASCII-safety
937 // test) can reach the topbar builders that project `App` state.
938 pub(crate) mod frame;
939 mod overlays;
940 mod provider_routes;
941 mod session_state;
942
943 pub(crate) use frame::*;
944 pub(crate) use overlays::*;
945 pub(crate) use provider_routes::*;
946 pub(crate) use session_state::*;
947
948 #[cfg(test)]
949 fn spawn_external_url_command(mut command: Command) -> Result<()> {
950 command
951 .stdin(Stdio::null())
952 .stdout(Stdio::null())
953 .stderr(Stdio::null())
954 .spawn()
955 .map(|_| ())
956 .map_err(|err| anyhow::anyhow!("failed to launch browser command: {err}"))
957 }
958
959 async fn execute_command_input(
960 terminal: &mut AppTerminal,
961 app: &mut App,
962 engine_handle: &mut EngineHandle,
963 task_manager: &SharedTaskManager,
964 config: &mut Config,
965 input: &str,
966 ) -> Result<bool> {
967 let _ = app.note_manual_command_for_tip(input);
968 if let Some(parsed_index) = parse_queue_send_command(input) {
969 match parsed_index {
970 Ok(index) => {
971 send_queued_message_at_index_now(app, config, engine_handle, index).await?;
972 }
973 Err(message) => {
974 app.status_message = Some(message);
975 }
976 }
977 return Ok(false);
978 }
979
980 let result = commands::execute_with_config(input, app, config);
981 // The NOTES view reads the notes file off the render path on the
982 // workspace-context tick; a `/note` change refreshes it at once (#6565).
983 if input
984 .split_whitespace()
985 .next()
986 .is_some_and(|command| command.eq_ignore_ascii_case("/note"))
987 {
988 workspace_context::refresh_now(app, Instant::now());
989 }
990 // After /logout: clear the in-memory api_key fields so the next
991 // onboarding round entering a new key doesn't see the stale value
992 // (#343). The on-disk side is handled by clear_api_key() inside
993 // commands::config::logout.
994 if input.trim().eq_ignore_ascii_case("/logout") {
995 // Only clear the active provider's in-memory API key, not every
996 // provider. The on-disk clear_api_key() inside commands::config::logout
997 // already removes all saved keys; clearing only the active slot here
998 // prevents surprising side-effects when the user has multiple providers
999 // configured.
1000 clear_active_provider_api_key_from_memory(app, config).map_err(anyhow::Error::msg)?;
1001 app.api_key_env_only = crate::config::active_provider_uses_env_only_api_key(config);
1002 }
1003 apply_command_result(terminal, app, engine_handle, task_manager, config, result).await
1004 }
1005
1006 #[derive(Debug, Clone)]
1007 pub(crate) struct SteerPausedSnapshot {
1008 paused: bool,
1009 pausable: bool,
1010 paused_goal_objective: Option<String>,
1011 objective: Option<String>,
1012 tokens_used: u64,
1013 time_used_seconds: u64,
1014 continuation_count: u32,
1015 }
1016
1017 fn use_bundled_constitution(app: &mut App, config: &Config) {
1018 let mut state = crate::tui::setup::load_setup_state_for_app(app, config);
1019 state.complete_constitution_checkpoint(
1020 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
1021 codewhale_config::ConstitutionChoice::Bundled,
1022 );
1023 state.constitution_source = codewhale_config::ConstitutionSource::Bundled;
1024 state.constitution_validity = codewhale_config::ConstitutionValidity::Unknown;
1025 state.constitution_preview_hash = None;
1026 state.set_step(
1027 codewhale_config::SetupStep::Constitution,
1028 codewhale_config::StepEntry::new(
1029 codewhale_config::StepStatus::Verified,
1030 true,
1031 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
1032 )
1033 .with_result("bundled/default constitution"),
1034 );
1035
1036 match state.save() {
1037 Ok(()) => {
1038 app.status_message = Some(
1039 "Using the bundled/default constitution; custom user-global law is inactive."
1040 .to_string(),
1041 );
1042 }
1043 Err(err) => {
1044 app.status_message = Some(format!("Failed to save constitution choice: {err}"));
1045 app.add_message(HistoryCell::System {
1046 content: format!("Failed to save constitution choice: {err}"),
1047 });
1048 }
1049 }
1050 app.needs_redraw = true;
1051 }
1052
1053 fn prepare_config_update_result(
1054 mut result: commands::CommandResult,
1055 persist: bool,
1056 ) -> commands::CommandResult {
1057 // Live previews can fire on every navigation tick. Suppress routine
1058 // confirmations, but preserve errors and AppAction so one canonical path
1059 // remains responsible for both user-visible output and side effects.
1060 if !persist && !result.is_error {
1061 result.message = None;
1062 }
1063 result
1064 }
1065
1066 pub(crate) struct ApprovalDecisionEvent {
1067 tool_id: String,
1068 tool_name: String,
1069 decision: ReviewDecision,
1070 timed_out: bool,
1071 approval_key: String,
1072 approval_grouping_key: String,
1073 persistent_rules: Vec<codewhale_config::ToolAskRule>,
1074 }
1075
1076 fn mark_active_turn_cancelled_locally(app: &mut App) {
1077 app.retire_action_notices(None);
1078 // #2739: every local cancel surface (Esc, Ctrl+C, approval abort, paused
1079 // command abort) must snapshot before it clears turn state. Otherwise
1080 // --continue reloads the previous save and the interrupted turn vanishes.
1081 app.streaming_state.reset();
1082 app.finalize_active_cell_as_interrupted();
1083 app.finalize_streaming_assistant_as_interrupted();
1084 persist_recovery_snapshot(app);
1085 app.is_loading = false;
1086 // #6800: a dispatch still waiting on engine admission fails back now, not
1087 // after its 60 s bound; its closure retires `dispatch_in_flight`.
1088 app.cancel_in_flight_dispatch();
1089 app.dispatch_started_at = None;
1090 app.turn_started_at = None;
1091 app.turn_last_activity_at = None;
1092 app.runtime_turn_id = None;
1093 app.runtime_turn_status = None;
1094 app.suppress_stream_events_until_turn_complete = true;
1095 crate::retry_status::clear();
1096 crate::tui::notifications::clear_taskbar_progress();
1097 crate::tui::notifications::stop_title_animation_quietly();
1098 }
1099
1100 /// The Esc-shaped "cancel the active turn" body, extracted verbatim from the
1101 /// event loop's `EscapeAction::CancelRequest` arm so the session control
1102 /// socket's `interrupt` verb and the Esc key cannot drift apart. Returns
1103 /// `true` when the caller should stop handling the event (compaction cancel
1104 /// or goal-continuation stop consumed it), `false` otherwise. The caller
1105 /// keeps its own Esc-specific state (backtrack reset) outside this body.
1106 pub(crate) fn escape_cancel_request(
1107 app: &mut App,
1108 engine_handle: &EngineHandle,
1109 current_streaming_text: &mut String,
1110 stream_display_clock: &mut StreamDisplayClock,
1111 ) -> bool {
1112 let compacting = app.is_compacting || app.manual_compaction_queued;
1113 if compacting {
1114 try_cancel_compaction(app, engine_handle);
1115 if !compact_interrupt_should_stop_turn(app) {
1116 return true;
1117 }
1118 // Mid-turn compact is collateral. Esc/interrupt stops the turn
1119 // (Codex/GrokBuild): cancel_compaction alone continues the loop.
1120 }
1121 if app.paused || app.paused_goal_objective.is_some() {
1122 clear_paused_command_state(app, engine_handle);
1123 if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
1124 engine_handle.cancel();
1125 mark_active_turn_cancelled_locally(app);
1126 current_streaming_text.clear();
1127 stream_display_clock.reset();
1128 }
1129 app.active_allowed_tools = None;
1130 app.goal.objective = None;
1131 app.goal.tokens_used = 0;
1132 app.goal.time_used_seconds = 0;
1133 app.goal.continuation_count = 0;
1134 app.status_message = Some(parent_stop_status(app, "Paused command cancelled"));
1135 false
1136 } else {
1137 let was_waiting = app.goal_continuation_waiting;
1138 engine_handle.cancel();
1139 if was_waiting {
1140 app.goal_continuation_waiting = false;
1141 app.status_message = Some(app.tr(MessageId::GoalContinuationStopped).to_string());
1142 return true;
1143 }
1144 mark_active_turn_cancelled_locally(app);
1145 current_streaming_text.clear();
1146 stream_display_clock.reset();
1147 app.status_message = Some(parent_stop_status(app, "Request cancelled"));
1148 false
1149 }
1150 }
1151
1152 /// Stream events a local cancel hides until the turn completes. Approval,
1153 /// sandbox-elevation, and question requests are never silently hidden:
1154 /// `resolve_stale_parent_request` answers a stale parent request explicitly,
1155 /// and a child agent's request is always delivered (approvals C1).
1156 fn suppress_engine_event_after_local_cancel(event: &EngineEvent) -> bool {
1157 matches!(
1158 event,
1159 EngineEvent::MessageStarted { .. }
1160 | EngineEvent::MessageDelta { .. }
1161 | EngineEvent::MessageComplete { .. }
1162 | EngineEvent::ThinkingStarted { .. }
1163 | EngineEvent::ThinkingDelta { .. }
1164 | EngineEvent::ThinkingComplete { .. }
1165 | EngineEvent::ToolCallStarted { .. }
1166 | EngineEvent::ToolCallHeartbeat
1167 | EngineEvent::ToolCallComplete { .. }
1168 | EngineEvent::SessionUpdated { .. }
1169 )
1170 }
1171
1172 fn ignore_stale_stream_event_while_idle(event: &EngineEvent) -> bool {
1173 matches!(
1174 event,
1175 EngineEvent::MessageStarted { .. }
1176 | EngineEvent::MessageDelta { .. }
1177 | EngineEvent::MessageComplete { .. }
1178 | EngineEvent::ThinkingStarted { .. }
1179 | EngineEvent::ThinkingDelta { .. }
1180 | EngineEvent::ThinkingComplete { .. }
1181 | EngineEvent::ToolCallStarted { .. }
1182 | EngineEvent::ToolCallHeartbeat
1183 | EngineEvent::ToolCallComplete { .. }
1184 )
1185 }
1186
1187 /// `Ok` carries the `/models` roster the probe already downloaded, when it
1188 /// was one complete listing (see [`crate::client::verify_provider_api_key`]).
1189 type ProviderKeyVerification<'a> = Pin<
1190 Box<
1191 dyn Future<Output = Result<Option<codewhale_config::catalog::ProviderCatalogDelta>, String>>
1192 + Send
1193 + 'a,
1194 >,
1195 >;
1196
1197 pub(crate) fn request_foreground_shell_background(app: &mut App) {
1198 if !app.is_loading {
1199 app.status_message = Some("No foreground shell wait to move to /jobs".to_string());
1200 return;
1201 }
1202 if !active_foreground_shell_running(app) {
1203 // #3032 AC3: name the reason backgrounding is unavailable —
1204 // interactive execs and non-shell blocking tools are visibly running
1205 // but cannot be detached, and a generic shrug reads like a bug.
1206 let reason = if terminal_pause_has_live_owner(app) {
1207 "the running command is interactive"
1208 } else if app
1209 .active_cell
1210 .as_ref()
1211 .is_some_and(|active| !active.is_empty())
1212 {
1213 "the running tool is not a foreground shell command"
1214 } else {
1215 "no foreground shell command is running"
1216 };
1217 app.status_message = Some(format!(
1218 "Cannot move to /jobs: {reason}. Press Ctrl+C to cancel the turn, or wait for completion."
1219 ));
1220 return;
1221 }
1222
1223 match request_active_foreground_shell_background(app) {
1224 Ok(()) => {
1225 app.status_message = Some("Moving current shell command to /jobs...".to_string());
1226 }
1227 Err(err) => {
1228 app.status_message = Some(err.to_string());
1229 }
1230 }
1231 }
1232
1233 fn request_active_foreground_shell_background(app: &App) -> Result<()> {
1234 let shell_manager = app
1235 .runtime_services
1236 .shell_manager
1237 .clone()
1238 .context("No shell session is active.")?;
1239 let mut manager = shell_manager.lock().map_err(|_| {
1240 anyhow::anyhow!("Shell tracking hit an internal error — restart Codewhale to recover.")
1241 })?;
1242 manager.request_foreground_background();
1243 Ok(())
1244 }
1245
1246 pub(crate) fn prefill_jobs_cancel_all_if_tasks_sidebar(app: &mut App) -> bool {
1247 if !app.view_stack.is_empty()
1248 || app.work_surface.panel != crate::tui::work_surface::RailPanel::Tasks
1249 || app.work_surface.last_area.is_none()
1250 || !app
1251 .task_panel
1252 .iter()
1253 .any(crate::tui::background_indicator::is_live_shell_entry)
1254 {
1255 return false;
1256 }
1257
1258 app.input = "/jobs cancel-all".to_string();
1259 app.cursor_position = app.input.len();
1260 app.status_message = Some("Press Enter to cancel all running commands".to_string());
1261 true
1262 }
1263
1264 pub(crate) fn active_foreground_shell_running(app: &App) -> bool {
1265 app.active_cell.as_ref().is_some_and(|active| {
1266 active.entries().iter().any(|cell| {
1267 matches!(
1268 cell,
1269 HistoryCell::Tool(ToolCell::Exec(exec))
1270 if exec.status == ToolStatus::Running
1271 && exec.interaction.is_none()
1272 && exec.shell_task_id.is_none()
1273 )
1274 })
1275 })
1276 }
1277
1278 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1279 pub(crate) enum SearchDirection {
1280 Forward,
1281 Backward,
1282 }
1283
1284 pub(crate) fn clamp_event_poll_timeout(timeout: Duration) -> Duration {
1285 const MIN_EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(1);
1286 timeout.max(MIN_EVENT_POLL_TIMEOUT)
1287 }
1288
1289 /// Announce the background work that finished since the last notice, when
1290 /// the `[notifications].subagent_completion` mode says it is time (#6565).
1291 ///
1292 /// Finite work still live (running agents that are not suspect ghosts, a
1293 /// running workflow, queued or running durable tasks that are not stale)
1294 /// holds a `final-only`
1295 /// batch; a running background shell never does. `parent_idle` forces the
1296 /// parent-turn half of the rule open, for the moment a turn completes.
1297 /// `settings()` still has the final say (method=off / condition=never).
1298 pub(crate) fn flush_background_finished(app: &mut App, config: &Config, parent_idle: bool) {
1299 use crate::tui::background_finished::{background_finished_payload, ready_to_flush};
1300 if app.background_finished.is_empty() {
1301 return;
1302 }
1303 let mode = config.notifications_config().subagent_completion;
1304 let finite_work_live = session_state::live_running_agent_count(app, Instant::now()) > 0
1305 || frame::workflow_tool_is_running(app)
1306 || app.task_panel.iter().any(|entry| {
1307 // A stale entry (a recovered task whose ownership is unverified)
1308 // is not known to be running and could hold the batch forever,
1309 // the same reason suspect ghost agents are left out.
1310 !entry.stale
1311 && entry.kind != TaskPanelEntryKind::Shell
1312 && matches!(entry.status.as_str(), "queued" | "running")
1313 });
1314 let parent_busy = app.is_loading && !parent_idle;
1315 if !ready_to_flush(
1316 mode,
1317 &app.background_finished,
1318 finite_work_live,
1319 parent_busy,
1320 ) {
1321 return;
1322 }
1323 let batch = std::mem::take(&mut app.background_finished);
1324 if mode == crate::config::SubagentCompletionNotification::Off {
1325 return;
1326 }
1327 let Some((method, threshold, include_summary)) = notifications::settings(config) else {
1328 return;
1329 };
1330 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
1331 let notices: Vec<&[crate::tui::background_finished::FinishedWork]> =
1332 if mode == crate::config::SubagentCompletionNotification::Always {
1333 batch.chunks(1).collect()
1334 } else {
1335 vec![batch.as_slice()]
1336 };
1337 for items in notices {
1338 let elapsed = items
1339 .iter()
1340 .map(|item| item.elapsed)
1341 .max()
1342 .unwrap_or_default();
1343 if let Some(payload) = background_finished_payload(app.ui_locale, items, include_summary) {
1344 notifications::notify_done(method, in_tmux, &payload, threshold, elapsed);
1345 }
1346 }
1347 }
1348
1349 /// Settle the background-finished batch when the parent turn ends (#6565).
1350 ///
1351 /// A completed turn sends its own notice, which covers the shells and tasks
1352 /// that finished while it ran, so those are dropped rather than announced a
1353 /// second time. Whatever else was held for the turn is then flushed.
1354 pub(crate) fn settle_background_finished_at_turn_end(
1355 app: &mut App,
1356 config: &Config,
1357 turn_completed: bool,
1358 ) {
1359 if turn_completed {
1360 crate::tui::background_finished::drop_reported_by_turn(&mut app.background_finished);
1361 }
1362 flush_background_finished(app, config, true);
1363 }
1364
1365 // Keyboard-shortcut predicates moved to `tui/key_shortcuts.rs`.
1366
1367 #[derive(Debug, Clone, PartialEq, Eq)]
1368 pub(crate) enum StartupVersionCheckSource {
1369 Disabled,
1370 ConfiguredUrl(String),
1371 ReleaseResolver,
1372 }
1373
1374 /// A newer-stable-release notice, carrying enough context to render both the
1375 /// short transient toast and the durable in-transcript update prompt (#3961).
1376 #[derive(Debug, Clone, PartialEq, Eq)]
1377 pub(crate) struct UpdateNotice {
1378 current: String,
1379 latest: String,
1380 }
1381
1382 impl UpdateNotice {
1383 /// Short line for the transient status toast, naming the command that
1384 /// actually updates *this* install.
1385 fn toast_line(&self, install: InstallMethod) -> String {
1386 format!(
1387 "v{latest} available - run `{command}` and restart",
1388 latest = self.latest,
1389 command = install.update_command()
1390 )
1391 }
1392
1393 /// Compact header chip label shown once the check has landed. Quiet by
1394 /// design: no action verb, no repetition — the toast and transcript
1395 /// notice carry the update instructions (#14).
1396 fn chip_label(&self) -> String {
1397 format!("↑ v{latest}", latest = self.latest)
1398 }
1399
1400 /// Durable, actionable notice pushed into the transcript so it survives the
1401 /// toast TTL. Includes current/latest versions, release notes, the exact
1402 /// update command, and restart guidance.
1403 ///
1404 /// Package-managed installs get their manager's command instead of
1405 /// `codewhale update`, plus an explicit warning: self-updating a binary
1406 /// Homebrew or npm owns leaves the manager's metadata lying about what is
1407 /// on disk, and the next upgrade silently reverts the user.
1408 fn notice_block(&self, install: InstallMethod) -> String {
1409 let action = if install.supports_self_update() {
1410 "Run `/update install` here (preview it with a bare `/update`), or `codewhale update` in a shell, then restart Codewhale."
1411 .to_string()
1412 } else {
1413 format!(
1414 "Installed via {label}. Run `{command}`, then restart Codewhale.\n\
1415 Do not use `codewhale update` here — it would replace a binary {label} manages.",
1416 label = install.label(),
1417 command = install.update_command()
1418 )
1419 };
1420 format!(
1421 "Update available: v{current} -> v{latest}\n\
1422 Release notes: https://github.com/codewhale-hq/CodeWhale/releases/tag/v{latest}\n\
1423 {action}",
1424 current = self.current,
1425 latest = self.latest
1426 )
1427 }
1428 }
1429
1430 mod activity_detail;
1431
1432 #[cfg(test)]
1433 mod provider_key_validation_tests {
1434 use super::*;
1435 use crate::core::engine::mock_engine_handle;
1436 use ratatui::{buffer::Buffer, layout::Rect};
1437 use tempfile::TempDir;
1438
1439 struct ConfigPathEnvGuard {
1440 _tmp: TempDir,
1441 // Onboarding completion runs the setup transaction (setup_state.json,
1442 // settings.toml) against `CODEWHALE_HOME`; without this guard the
1443 // fixture provider landed in the developer's real ~/.codewhale (#5932).
1444 _codewhale_home: crate::test_support::EnvVarGuard,
1445 _codewhale_config_path: crate::test_support::EnvVarGuard,
1446 _deepseek_config_path: crate::test_support::EnvVarGuard,
1447 _lock: crate::test_support::TestEnvLock,
1448 }
1449
1450 impl ConfigPathEnvGuard {
1451 fn new() -> Self {
1452 let lock = crate::test_support::lock_test_env();
1453 let tmp = TempDir::new().expect("config tempdir");
1454 let home = tmp.path().join(".codewhale");
1455 let config_path = home.join("config.toml");
1456 std::fs::create_dir_all(config_path.parent().expect("config parent"))
1457 .expect("config dir");
1458 Self {
1459 _tmp: tmp,
1460 _codewhale_home: crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home),
1461 _codewhale_config_path: crate::test_support::EnvVarGuard::set(
1462 "CODEWHALE_CONFIG_PATH",
1463 &config_path,
1464 ),
1465 _deepseek_config_path: crate::test_support::EnvVarGuard::set(
1466 "DEEPSEEK_CONFIG_PATH",
1467 &config_path,
1468 ),
1469 _lock: lock,
1470 }
1471 }
1472
1473 fn config_path(&self) -> PathBuf {
1474 std::env::var_os("CODEWHALE_CONFIG_PATH")
1475 .map(PathBuf::from)
1476 .expect("config path set")
1477 }
1478 }
1479
1480 fn create_test_app() -> App {
1481 let options = TuiOptions {
1482 start_in_agent_mode: true,
1483 skip_onboarding: false,
1484 ..crate::test_support::test_tui_options(PathBuf::from("."))
1485 };
1486 let mut app = App::new(options, &Config::default());
1487 // These suites assert legacy strip geometry (work surface above the
1488 // transcript). The Bottom default (round 3, 2026-09-01) has its own
1489 // coverage in work_surface::rail_panels_render_in_all_placements.
1490 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
1491 app.api_provider = ProviderKind::Deepseek;
1492 app.model = "deepseek-v4-pro".to_string();
1493 app.auto_model = false;
1494 app
1495 }
1496
1497 #[test]
1498 fn api_key_live_mirror_revokes_stale_external_credential_consent() {
1499 let external_path = if cfg!(windows) {
1500 PathBuf::from(r"C:\Users\test\grok-auth.json")
1501 } else {
1502 PathBuf::from("/tmp/grok-auth.json")
1503 };
1504 let mut config = Config {
1505 providers: Some(ProvidersConfig {
1506 xai: ProviderConfig {
1507 auth_mode: Some("oauth".to_string()),
1508 external_credentials: Some(
1509 codewhale_config::ExternalCredentialConsentToml::read_only(
1510 codewhale_config::ProviderKind::Xai,
1511 codewhale_config::ExternalCredentialSource::GrokCli,
1512 external_path,
1513 ),
1514 ),
1515 ..Default::default()
1516 },
1517 ..Default::default()
1518 }),
1519 ..Default::default()
1520 };
1521
1522 {
1523 let captured_fixture_identity = (config).test_identity_for_kind(ProviderKind::Xai);
1524 mirror_saved_api_key_in_config(
1525 &mut config,
1526 &captured_fixture_identity,
1527 "codewhale-owned-api-key".to_string(),
1528 )
1529 .expect("admitted API-key fixture")
1530 };
1531
1532 let xai = config
1533 .provider_config_for(&config.test_identity_for_kind(ProviderKind::Xai))
1534 .expect("xAI live config");
1535 assert_eq!(xai.auth_mode.as_deref(), Some("api_key"));
1536 assert_eq!(xai.api_key.as_deref(), Some("codewhale-owned-api-key"));
1537 assert!(xai.external_credentials.is_none());
1538 }
1539
1540 struct MockProviderKeyVerifier {
1541 result: Result<(), String>,
1542 roster: Option<codewhale_config::catalog::ProviderCatalogDelta>,
1543 calls: std::sync::Mutex<Vec<(ProviderKind, String, String)>>,
1544 }
1545
1546 impl MockProviderKeyVerifier {
1547 fn new(result: Result<(), String>) -> Self {
1548 Self {
1549 result,
1550 roster: None,
1551 calls: std::sync::Mutex::new(Vec::new()),
1552 }
1553 }
1554
1555 fn with_roster(mut self, roster: codewhale_config::catalog::ProviderCatalogDelta) -> Self {
1556 self.roster = Some(roster);
1557 self
1558 }
1559
1560 fn calls(&self) -> Vec<(ProviderKind, String, String)> {
1561 self.calls.lock().expect("calls lock").clone()
1562 }
1563 }
1564
1565 impl ProviderKeyVerifier for MockProviderKeyVerifier {
1566 fn verify<'a>(
1567 &'a self,
1568 provider: ProviderKind,
1569 api_key: &'a str,
1570 base_url: &'a str,
1571 ) -> ProviderKeyVerification<'a> {
1572 self.calls.lock().expect("calls lock").push((
1573 provider,
1574 api_key.to_string(),
1575 base_url.to_string(),
1576 ));
1577 Box::pin(std::future::ready(
1578 self.result.clone().map(|()| self.roster.clone()),
1579 ))
1580 }
1581 }
1582
1583 fn openrouter_config(base_url: &str) -> Config {
1584 Config {
1585 providers: Some(ProvidersConfig {
1586 openrouter: ProviderConfig {
1587 base_url: Some(base_url.to_string()),
1588 ..ProviderConfig::default()
1589 },
1590 ..ProvidersConfig::default()
1591 }),
1592 ..Config::default()
1593 }
1594 }
1595
1596 fn two_named_custom_routes() -> Config {
1597 Config {
1598 provider: Some("custom-a".to_string()),
1599 providers: Some(ProvidersConfig {
1600 custom: std::collections::HashMap::from([
1601 (
1602 "custom-a".to_string(),
1603 ProviderConfig {
1604 kind: Some("openai-compatible".to_string()),
1605 base_url: Some("http://127.0.0.1:18181/v1".to_string()),
1606 model: Some("model-a".to_string()),
1607 api_key: Some("key-a".to_string()),
1608 ..Default::default()
1609 },
1610 ),
1611 (
1612 "custom-b".to_string(),
1613 ProviderConfig {
1614 kind: Some("openai-compatible".to_string()),
1615 base_url: Some("http://127.0.0.1:18182/v1".to_string()),
1616 model: Some("model-b".to_string()),
1617 ..Default::default()
1618 },
1619 ),
1620 ]),
1621 ..Default::default()
1622 }),
1623 ..Default::default()
1624 }
1625 }
1626
1627 #[test]
1628 fn provider_key_check_classifies_transport_failures_truthfully() {
1629 assert_eq!(
1630 provider_verification_error_category("connection refused"),
1631 crate::error_taxonomy::ErrorCategory::Network
1632 );
1633 assert_eq!(
1634 provider_verification_error_category("request timed out"),
1635 crate::error_taxonomy::ErrorCategory::Timeout
1636 );
1637 assert_eq!(
1638 provider_verification_error_category("HTTP 429 rate limit"),
1639 crate::error_taxonomy::ErrorCategory::RateLimit
1640 );
1641 assert_eq!(
1642 provider_verification_error_category("HTTP 401 unauthorized"),
1643 crate::error_taxonomy::ErrorCategory::Authentication
1644 );
1645 assert_eq!(
1646 provider_verification_error_category("HTTP 403 forbidden"),
1647 crate::error_taxonomy::ErrorCategory::Authorization
1648 );
1649 assert_eq!(
1650 provider_verification_error_category("HTTP 500 upstream failure"),
1651 crate::error_taxonomy::ErrorCategory::Network
1652 );
1653 }
1654
1655 /// Release QA: a DeepSeek key probe returned two served ids, yet guided
1656 /// setup offered catalog rows the endpoint rejects at the first turn. The
1657 /// roster the probe already downloaded must become the route's model list.
1658 #[tokio::test]
1659 async fn provider_key_probe_roster_becomes_the_model_pick_roster() {
1660 use codewhale_config::catalog::{
1661 CatalogOffering, CatalogSource, ProviderCatalogDelta, base_url_fingerprint, now_unix,
1662 };
1663 struct Reset;
1664 impl Drop for Reset {
1665 fn drop(&mut self) {
1666 crate::provider_catalog_live::reset_cache_for_test();
1667 crate::provider_lake::clear_live_snapshot();
1668 }
1669 }
1670 let _config_env = ConfigPathEnvGuard::new();
1671 let _live = crate::provider_lake::lock_live_snapshot();
1672 let _reset = Reset;
1673 crate::provider_catalog_live::reset_cache_for_test();
1674 let mut app = create_test_app();
1675 let mut engine = mock_engine_handle();
1676 let mut config = Config::default();
1677 let identity = picker_provider_identity(&config, ProviderKind::Deepseek, None)
1678 .expect("DeepSeek identity");
1679 let mut scoped = config.clone();
1680 scoped
1681 .scope_to_provider_identity(&identity)
1682 .expect("scope DeepSeek");
1683 let base_url = scoped.active_route_base_url();
1684 let fingerprint = base_url_fingerprint(&base_url);
1685 let fetched_at = now_unix();
1686 let served = ["deepseek-flash", "deepseek-v4-pro"];
1687 // Scoped to the provider kind, exactly as the live probe returns it,
1688 // plus an id the catalog does not offer as a chat model.
1689 let roster = ProviderCatalogDelta {
1690 provider: "deepseek".into(),
1691 base_url_fingerprint: fingerprint.clone(),
1692 fetched_at,
1693 offerings: served
1694 .iter()
1695 .chain(&["deepseek-embedding-fixture"])
1696 .map(|id| CatalogOffering {
1697 provider: "deepseek".into(),
1698 wire_model_id: (*id).into(),
1699 endpoint_key: "chat".into(),
1700 source: CatalogSource::Live {
1701 base_url_fingerprint: fingerprint.clone(),
1702 fetched_at,
1703 },
1704 ..Default::default()
1705 })
1706 .collect(),
1707 };
1708 assert_ne!(
1709 crate::provider_lake::catalog_models_for_route(
1710 ProviderKind::Deepseek,
1711 identity.key.as_str(),
1712 &base_url,
1713 ),
1714 served,
1715 "precondition: the catalog fallback lists more than the endpoint serves"
1716 );
1717 let verifier = MockProviderKeyVerifier::new(Ok(())).with_roster(roster);
1718
1719 apply_provider_picker_api_key_with_verifier(
1720 &mut app,
1721 &mut engine.handle,
1722 &mut config,
1723 identity.clone(),
1724 "sk-verified".to_string(),
1725 None,
1726 &verifier,
1727 )
1728 .await;
1729
1730 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
1731 assert_eq!(
1732 crate::provider_lake::catalog_models_for_route(
1733 ProviderKind::Deepseek,
1734 identity.key.as_str(),
1735 &base_url,
1736 ),
1737 served
1738 );
1739 }
1740
1741 #[tokio::test]
1742 async fn provider_key_submit_opens_model_pick_without_persisting_on_validation_success() {
1743 let config_env = ConfigPathEnvGuard::new();
1744 let mut app = create_test_app();
1745 let mut engine = mock_engine_handle();
1746 let mut config = openrouter_config("https://mock.openrouter.test/v1");
1747 let verifier = MockProviderKeyVerifier::new(Ok(()));
1748 let identity = picker_provider_identity(&config, ProviderKind::Openrouter, None)
1749 .expect("OpenRouter identity");
1750
1751 apply_provider_picker_api_key_with_verifier(
1752 &mut app,
1753 &mut engine.handle,
1754 &mut config,
1755 identity,
1756 "sk-verified".to_string(),
1757 None,
1758 &verifier,
1759 )
1760 .await;
1761
1762 assert_eq!(
1763 verifier.calls(),
1764 vec![(
1765 ProviderKind::Openrouter,
1766 "sk-verified".to_string(),
1767 "https://mock.openrouter.test/v1".to_string()
1768 )]
1769 );
1770 // Validation success must not persist or switch yet (#3875 residual):
1771 // the guided flow continues at model pick first.
1772 assert_eq!(app.api_provider, ProviderKind::Deepseek);
1773 assert_eq!(config.provider.as_deref(), None);
1774 assert_eq!(
1775 config
1776 .providers
1777 .as_ref()
1778 .and_then(|providers| providers.openrouter.api_key.as_deref()),
1779 None
1780 );
1781 let saved = std::fs::read_to_string(config_env.config_path()).unwrap_or_default();
1782 assert!(!saved.contains("sk-verified"));
1783 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
1784 assert!(
1785 app.status_message
1786 .as_deref()
1787 .is_some_and(|status| { status.contains("The provider accepted the key") }),
1788 "status names connection-probe success: {:?}",
1789 app.status_message
1790 );
1791 // The probe proves only this temporary credential generation, not the
1792 // original uncredentialed Config or any model's entitlement.
1793 let mut probed_config = config.clone();
1794 let probed_identity = picker_provider_identity(&config, ProviderKind::Openrouter, None)
1795 .expect("captured OpenRouter identity");
1796 probed_config
1797 .scope_to_provider_identity(&probed_identity)
1798 .expect("scope probe");
1799 probed_config
1800 .set_provider_api_key_override(&probed_identity, Some("sk-verified".to_string()))
1801 .expect("temporary probe key");
1802 let verified_route = crate::provider_readiness::route_identity_for_model(
1803 &probed_config,
1804 &probed_identity,
1805 crate::config::DEFAULT_OPENROUTER_MODEL,
1806 );
1807 assert_eq!(
1808 crate::provider_readiness::resolve_with_identity(
1809 &verified_route,
1810 crate::provider_readiness::CredentialState::Saved,
1811 true,
1812 &app.provider_health,
1813 ),
1814 crate::provider_readiness::ResolvedProviderReadiness::ConnectionCheckedModelUnchecked,
1815 "the live connection probe must not be reported as model ready",
1816 );
1817
1818 let picker = app.view_stack.pop().expect("provider picker reopened");
1819 let area = Rect::new(0, 0, 90, 16);
1820 let mut buf = Buffer::empty(area);
1821 picker.render(area, &mut buf);
1822 let rendered = (0..area.height)
1823 .map(|y| {
1824 (0..area.width)
1825 .map(|x| buf[(x, y)].symbol())
1826 .collect::<String>()
1827 })
1828 .collect::<Vec<_>>()
1829 .join("\n");
1830 assert!(
1831 rendered.contains("The provider accepted the key")
1832 && rendered.contains("pick the model to use by default"),
1833 "expected model-pick stage UI, got:\n{rendered}"
1834 );
1835 }
1836
1837 #[tokio::test]
1838 async fn test_connection_records_models_probe_not_ready() {
1839 let config_env = ConfigPathEnvGuard::new();
1840 let mut app = create_test_app();
1841 let mut engine = mock_engine_handle();
1842 let mut config = openrouter_config("https://mock.openrouter.test/v1");
1843 config.provider = Some("openrouter".to_string());
1844 if let Some(providers) = config.providers.as_mut() {
1845 providers.openrouter.api_key = Some("sk-saved".to_string());
1846 }
1847 let verifier = MockProviderKeyVerifier::new(Ok(()));
1848 let identity = picker_provider_identity(&config, ProviderKind::Openrouter, None)
1849 .expect("OpenRouter identity");
1850
1851 apply_provider_picker_test_connection_with_verifier(
1852 &mut app,
1853 &mut engine.handle,
1854 &mut config,
1855 identity,
1856 false,
1857 &verifier,
1858 )
1859 .await;
1860
1861 assert_eq!(
1862 verifier.calls(),
1863 vec![(
1864 ProviderKind::Openrouter,
1865 "sk-saved".to_string(),
1866 "https://mock.openrouter.test/v1".to_string()
1867 )]
1868 );
1869 let _ = config_env;
1870 assert_eq!(config.provider.as_deref(), Some("openrouter"));
1871 assert!(
1872 app.status_toasts.iter().any(|toast| {
1873 toast.text.contains("The provider accepted the key")
1874 && !toast.text.contains("pick the model")
1875 }),
1876 "test connection names reachability only: {:?}",
1877 app.status_toasts
1878 );
1879 let verified_route = crate::provider_readiness::route_identity_for_model(
1880 &config,
1881 &(config).test_identity_for_kind(ProviderKind::Openrouter),
1882 crate::config::DEFAULT_OPENROUTER_MODEL,
1883 );
1884 assert_eq!(
1885 crate::provider_readiness::resolve_with_identity(
1886 &verified_route,
1887 crate::provider_readiness::CredentialState::Saved,
1888 true,
1889 &app.provider_health,
1890 ),
1891 crate::provider_readiness::ResolvedProviderReadiness::ConnectionCheckedModelUnchecked,
1892 );
1893 assert_ne!(
1894 crate::provider_readiness::resolve_with_identity(
1895 &verified_route,
1896 crate::provider_readiness::CredentialState::Saved,
1897 true,
1898 &app.provider_health,
1899 ),
1900 crate::provider_readiness::ResolvedProviderReadiness::Ready,
1901 );
1902 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
1903 }
1904
1905 #[tokio::test]
1906 async fn test_connection_without_key_does_not_mark_ready() {
1907 let config_env = ConfigPathEnvGuard::new();
1908 let mut app = create_test_app();
1909 let mut engine = mock_engine_handle();
1910 let mut config = openrouter_config("https://mock.openrouter.test/v1");
1911 let verifier = MockProviderKeyVerifier::new(Ok(()));
1912 let identity = picker_provider_identity(&config, ProviderKind::Openrouter, None)
1913 .expect("OpenRouter identity");
1914
1915 apply_provider_picker_test_connection_with_verifier(
1916 &mut app,
1917 &mut engine.handle,
1918 &mut config,
1919 identity,
1920 false,
1921 &verifier,
1922 )
1923 .await;
1924
1925 assert!(verifier.calls().is_empty());
1926 let _ = config_env;
1927 assert!(
1928 app.status_toasts
1929 .iter()
1930 .any(|toast| toast.text.contains("No API key saved")),
1931 "{:?}",
1932 app.status_toasts
1933 );
1934 let verified_route = crate::provider_readiness::route_identity_for_model(
1935 &config,
1936 &(config).test_identity_for_kind(ProviderKind::Openrouter),
1937 crate::config::DEFAULT_OPENROUTER_MODEL,
1938 );
1939 assert_eq!(
1940 crate::provider_readiness::resolve_with_identity(
1941 &verified_route,
1942 crate::provider_readiness::CredentialState::MissingKey,
1943 true,
1944 &app.provider_health,
1945 ),
1946 crate::provider_readiness::ResolvedProviderReadiness::MissingKey,
1947 );
1948 }
1949
1950 #[tokio::test]
1951 async fn test_connection_failure_redacts_the_api_key() {
1952 let config_env = ConfigPathEnvGuard::new();
1953 let mut app = create_test_app();
1954 let mut engine = mock_engine_handle();
1955 let mut config = openrouter_config("https://mock.openrouter.test/v1");
1956 config.provider = Some("openrouter".to_string());
1957 if let Some(providers) = config.providers.as_mut() {
1958 providers.openrouter.api_key = Some("sk-saved".to_string());
1959 }
1960 let verifier = MockProviderKeyVerifier::new(Err(
1961 "HTTP 401: upstream echoed sk-saved in a long diagnostic body that must not stay visible"
1962 .repeat(4),
1963 ));
1964 let identity = picker_provider_identity(&config, ProviderKind::Openrouter, None)
1965 .expect("OpenRouter identity");
1966
1967 apply_provider_picker_test_connection_with_verifier(
1968 &mut app,
1969 &mut engine.handle,
1970 &mut config,
1971 identity,
1972 true,
1973 &verifier,
1974 )
1975 .await;
1976
1977 let _ = config_env;
1978 let status = app
1979 .status_toasts
1980 .iter()
1981 .map(|toast| toast.text.as_str())
1982 .collect::<Vec<_>>()
1983 .join("\n");
1984 assert!(
1985 !status.contains("sk-saved"),
1986 "probe toast leaked the API key: {status}"
1987 );
1988 assert!(status.contains("***"), "{status}");
1989 assert!(
1990 app.provider_picker_memory
1991 .as_ref()
1992 .is_some_and(|memory| memory.catalog_view),
1993 "catalog browsing context must survive the probe"
1994 );
1995 let verified_route = crate::provider_readiness::route_identity_for_model(
1996 &config,
1997 &(config).test_identity_for_kind(ProviderKind::Openrouter),
1998 crate::config::DEFAULT_OPENROUTER_MODEL,
1999 );
2000 assert!(matches!(
2001 crate::provider_readiness::resolve_with_identity(
2002 &verified_route,
2003 crate::provider_readiness::CredentialState::Saved,
2004 true,
2005 &app.provider_health,
2006 ),
2007 crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed { .. }
2008 ));
2009 }
2010
2011 /// #4526: the wizard's StepFun billing-route choice must be the endpoint
2012 /// the key is probed against, and it must reach disk only once the user
2013 /// confirms — never as a side effect of validation.
2014 #[tokio::test]
2015 async fn stepfun_plan_route_is_validated_before_the_key_is_persisted() {
2016 let config_env = ConfigPathEnvGuard::new();
2017 let mut app = create_test_app();
2018 let mut engine = mock_engine_handle();
2019 let mut config = Config::default();
2020 let verifier = MockProviderKeyVerifier::new(Ok(()));
2021 let identity = picker_provider_identity(&config, ProviderKind::Stepfun, None)
2022 .expect("StepFun identity");
2023
2024 apply_provider_picker_api_key_with_verifier(
2025 &mut app,
2026 &mut engine.handle,
2027 &mut config,
2028 identity,
2029 "step-plan-key".to_string(),
2030 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()),
2031 &verifier,
2032 )
2033 .await;
2034
2035 assert_eq!(
2036 verifier.calls(),
2037 vec![(
2038 ProviderKind::Stepfun,
2039 "step-plan-key".to_string(),
2040 crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()
2041 )],
2042 "the chosen Step Plan endpoint must be the one live-validated"
2043 );
2044 assert_eq!(
2045 config
2046 .providers
2047 .as_ref()
2048 .and_then(|providers| providers.stepfun.base_url.clone()),
2049 None,
2050 "validation must not mutate the live config"
2051 );
2052 let saved = std::fs::read_to_string(config_env.config_path()).unwrap_or_default();
2053 assert!(
2054 !saved.contains("step_plan"),
2055 "nothing persisted yet: {saved}"
2056 );
2057 assert!(!saved.contains("step-plan-key"), "no secret yet: {saved}");
2058 }
2059
2060 /// The confirm stage writes the endpoint into `[providers.stepfun]` and
2061 /// leaves every other provider table alone.
2062 #[tokio::test]
2063 async fn stepfun_setup_confirm_writes_only_the_stepfun_base_url() {
2064 let config_env = ConfigPathEnvGuard::new();
2065 let mut app = create_test_app();
2066 let mut engine = mock_engine_handle();
2067 let mut config = Config::default();
2068 let identity = picker_provider_identity(&config, ProviderKind::Stepfun, None)
2069 .expect("StepFun identity");
2070
2071 apply_provider_picker_setup_confirmed(
2072 &mut app,
2073 &mut engine.handle,
2074 &mut config,
2075 identity,
2076 "step-plan-key".to_string(),
2077 crate::config::DEFAULT_STEPFUN_MODEL.to_string(),
2078 None,
2079 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()),
2080 )
2081 .await;
2082
2083 let saved = std::fs::read_to_string(config_env.config_path()).expect("config written");
2084 let document: toml::Table = toml::from_str(&saved).expect("valid TOML");
2085 let providers = document
2086 .get("providers")
2087 .and_then(toml::Value::as_table)
2088 .expect("providers table");
2089 assert_eq!(
2090 providers
2091 .get("stepfun")
2092 .and_then(|entry| entry.get("base_url"))
2093 .and_then(toml::Value::as_str),
2094 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
2095 );
2096 assert_eq!(
2097 providers.keys().collect::<Vec<_>>(),
2098 vec!["stepfun"],
2099 "the route choice must not touch other provider tables"
2100 );
2101 assert!(
2102 document.get("base_url").is_none(),
2103 "the root base_url must stay untouched: {saved}"
2104 );
2105 assert_eq!(
2106 config
2107 .providers
2108 .as_ref()
2109 .and_then(|providers| providers.stepfun.base_url.as_deref()),
2110 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL),
2111 "the live config mirrors the persisted endpoint"
2112 );
2113 }
2114
2115 #[tokio::test]
2116 async fn replacing_legacy_kimi_import_verifies_and_persists_the_kimi_code_api_key_route() {
2117 let config_env = ConfigPathEnvGuard::new();
2118 std::fs::write(
2119 config_env.config_path(),
2120 r#"# preserve-kimi-comment
2121 [providers.moonshot]
2122 auth_mode = "kimi_oauth"
2123 "#,
2124 )
2125 .expect("seed legacy Kimi import config");
2126 let mut app = create_test_app();
2127 let mut engine = mock_engine_handle();
2128 let mut config = Config {
2129 providers: Some(ProvidersConfig {
2130 moonshot: ProviderConfig {
2131 auth_mode: Some("kimi_oauth".to_string()),
2132 ..ProviderConfig::default()
2133 },
2134 ..ProvidersConfig::default()
2135 }),
2136 ..Config::default()
2137 };
2138 let identity = picker_provider_identity(&config, ProviderKind::Moonshot, None)
2139 .expect("Moonshot identity");
2140 let verifier = MockProviderKeyVerifier::new(Ok(()));
2141
2142 apply_provider_picker_api_key_with_verifier(
2143 &mut app,
2144 &mut engine.handle,
2145 &mut config,
2146 identity.clone(),
2147 "sk-kimi-supported".to_string(),
2148 None,
2149 &verifier,
2150 )
2151 .await;
2152
2153 assert_eq!(
2154 verifier.calls(),
2155 vec![(
2156 ProviderKind::Moonshot,
2157 "sk-kimi-supported".to_string(),
2158 crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string(),
2159 )],
2160 "replacement keys must be verified against Kimi Code, not the ordinary Moonshot API"
2161 );
2162
2163 apply_provider_picker_setup_confirmed(
2164 &mut app,
2165 &mut engine.handle,
2166 &mut config,
2167 identity,
2168 "sk-kimi-supported".to_string(),
2169 crate::config::DEFAULT_KIMI_CODE_MODEL.to_string(),
2170 None,
2171 None,
2172 )
2173 .await;
2174
2175 let moonshot = config
2176 .providers
2177 .as_ref()
2178 .map(|providers| &providers.moonshot)
2179 .expect("in-memory Moonshot config");
2180 assert_eq!(moonshot.auth_mode.as_deref(), Some("api_key"));
2181 assert_eq!(
2182 moonshot.base_url.as_deref(),
2183 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)
2184 );
2185 assert_eq!(moonshot.api_key.as_deref(), Some("sk-kimi-supported"));
2186
2187 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2188 assert!(saved.contains("# preserve-kimi-comment"));
2189 assert!(saved.contains("auth_mode = \"api_key\""));
2190 assert!(saved.contains(&format!(
2191 "base_url = \"{}\"",
2192 crate::config::DEFAULT_KIMI_CODE_BASE_URL
2193 )));
2194 }
2195
2196 #[tokio::test]
2197 async fn provider_setup_confirm_persists_provider_model_and_preserves_comments() {
2198 let config_env = ConfigPathEnvGuard::new();
2199 // Seed a commented config so the confirm path must preserve it.
2200 std::fs::write(
2201 config_env.config_path(),
2202 r#"# keep-me-comment
2203 [providers.openrouter]
2204 # openrouter-table-comment
2205 base_url = "https://mock.openrouter.test/v1"
2206
2207 [providers.anthropic]
2208 api_key = "fixture-other-provider-key"
2209 "#,
2210 )
2211 .expect("seed config");
2212
2213 let mut app = create_test_app();
2214 let mut engine = mock_engine_handle();
2215 let mut config = openrouter_config("https://mock.openrouter.test/v1");
2216 config
2217 .providers
2218 .get_or_insert_with(ProvidersConfig::default)
2219 .anthropic
2220 .api_key = Some("fixture-other-provider-key".to_string());
2221 let model = "deepseek/deepseek-v4-pro".to_string();
2222 let identity = picker_provider_identity(&config, ProviderKind::Openrouter, None)
2223 .expect("OpenRouter identity");
2224
2225 apply_provider_picker_setup_confirmed(
2226 &mut app,
2227 &mut engine.handle,
2228 &mut config,
2229 identity,
2230 "sk-confirmed".to_string(),
2231 model.clone(),
2232 None,
2233 None,
2234 )
2235 .await;
2236
2237 assert_eq!(app.api_provider, ProviderKind::Openrouter);
2238 assert_eq!(config.provider.as_deref(), Some("openrouter"));
2239 assert_eq!(
2240 config
2241 .providers
2242 .as_ref()
2243 .and_then(|providers| providers.openrouter.api_key.as_deref()),
2244 Some("sk-confirmed")
2245 );
2246 assert_eq!(
2247 config
2248 .providers
2249 .as_ref()
2250 .and_then(|providers| providers.openrouter.model.as_deref()),
2251 Some(model.as_str())
2252 );
2253 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2254 assert!(
2255 saved.contains("# keep-me-comment"),
2256 "root comment lost:\n{saved}"
2257 );
2258 assert!(
2259 saved.contains("# openrouter-table-comment"),
2260 "table comment lost:\n{saved}"
2261 );
2262 assert!(saved.contains("[providers.openrouter]"));
2263 assert!(saved.contains("api_key = \"sk-confirmed\""));
2264 assert!(saved.contains(&format!("model = \"{model}\"")));
2265 assert!(saved.contains("[providers.anthropic]"));
2266 assert!(saved.contains("api_key = \"fixture-other-provider-key\""));
2267 assert_eq!(
2268 config
2269 .providers
2270 .as_ref()
2271 .and_then(|providers| providers.anthropic.api_key.as_deref()),
2272 Some("fixture-other-provider-key"),
2273 "saving OpenRouter must not overwrite a different provider slot"
2274 );
2275 }
2276
2277 #[tokio::test]
2278 async fn provider_key_submit_reopens_picker_without_persisting_on_validation_failure() {
2279 let config_env = ConfigPathEnvGuard::new();
2280 let mut app = create_test_app();
2281 let mut engine = mock_engine_handle();
2282 let mut config = openrouter_config("https://mock.openrouter.test/v1");
2283 let verifier = MockProviderKeyVerifier::new(Err("HTTP 401: unauthorized".to_string()));
2284 let identity = picker_provider_identity(&config, ProviderKind::Openrouter, None)
2285 .expect("OpenRouter identity");
2286
2287 apply_provider_picker_api_key_with_verifier(
2288 &mut app,
2289 &mut engine.handle,
2290 &mut config,
2291 identity,
2292 "sk-rejected".to_string(),
2293 None,
2294 &verifier,
2295 )
2296 .await;
2297
2298 assert_eq!(app.api_provider, ProviderKind::Deepseek);
2299 assert_eq!(config.provider.as_deref(), None);
2300 assert_eq!(
2301 config
2302 .providers
2303 .as_ref()
2304 .and_then(|providers| providers.openrouter.api_key.as_deref()),
2305 None
2306 );
2307 let saved = std::fs::read_to_string(config_env.config_path()).unwrap_or_default();
2308 assert!(!saved.contains("sk-rejected"));
2309 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
2310 assert!(
2311 app.status_message
2312 .as_deref()
2313 .is_some_and(|status| status.contains("The provider did not accept this key")),
2314 "status names validation failure: {:?}",
2315 app.status_message
2316 );
2317
2318 let picker = app.view_stack.pop().expect("provider picker reopened");
2319 let area = Rect::new(0, 0, 90, 14);
2320 let mut buf = Buffer::empty(area);
2321 picker.render(area, &mut buf);
2322 let rendered = (0..area.height)
2323 .map(|y| {
2324 (0..area.width)
2325 .map(|x| buf[(x, y)].symbol())
2326 .collect::<String>()
2327 })
2328 .collect::<Vec<_>>()
2329 .join("\n");
2330 // #6566: a plain sentence with the next step, not the raw reply.
2331 assert!(
2332 rendered.contains("The provider did not accept this key"),
2333 "{rendered}"
2334 );
2335 assert!(!rendered.contains("HTTP 401"), "{rendered}");
2336 }
2337
2338 #[tokio::test]
2339 async fn named_custom_verification_failure_and_dismiss_keep_committed_a_route() {
2340 let _config_env = ConfigPathEnvGuard::new();
2341 let mut app = create_test_app();
2342 app.set_provider_identity(ProviderKind::Custom, "custom-a");
2343 app.set_model_selection("model-a".to_string());
2344 let mut engine = mock_engine_handle();
2345 let mut config = two_named_custom_routes();
2346 let identity = picker_provider_identity(&config, ProviderKind::Custom, Some("custom-b"))
2347 .expect("custom B identity");
2348 let verifier = MockProviderKeyVerifier::new(Err("HTTP 401: unauthorized".to_string()));
2349
2350 apply_provider_picker_api_key_with_verifier(
2351 &mut app,
2352 &mut engine.handle,
2353 &mut config,
2354 identity,
2355 "rejected-b-key".to_string(),
2356 None,
2357 &verifier,
2358 )
2359 .await;
2360
2361 assert_eq!(config.provider.as_deref(), Some("custom-a"));
2362 assert_eq!(app.provider_identity_for_persistence(), "custom-a");
2363 app.view_stack.pop().expect("failed verifier picker");
2364 sync_config_provider_from_app(&mut config, &app);
2365 let route = validated_app_runtime_route(&app, &config).expect("committed A route");
2366 assert_eq!(route.identity.key.as_str(), "custom-a");
2367 assert_eq!(route.client.base_url(), "http://127.0.0.1:18181/v1");
2368 }
2369
2370 #[tokio::test]
2371 async fn named_custom_setup_persists_exact_provider_table_and_model() {
2372 let config_env = ConfigPathEnvGuard::new();
2373 std::fs::write(
2374 config_env.config_path(),
2375 r#"provider = "custom-a"
2376
2377 [providers.custom-a]
2378 kind = "openai-compatible"
2379 base_url = "http://127.0.0.1:18181/v1"
2380 model = "model-a"
2381
2382 [providers.custom-b]
2383 kind = "openai-compatible"
2384 base_url = "http://127.0.0.1:18182/v1"
2385 model = "model-b"
2386 "#,
2387 )
2388 .expect("seed named custom config");
2389 let mut app = create_test_app();
2390 app.set_provider_identity(ProviderKind::Custom, "custom-a");
2391 app.set_model_selection("model-a".to_string());
2392 let mut engine = mock_engine_handle();
2393 let mut config = two_named_custom_routes();
2394 let identity = picker_provider_identity(&config, ProviderKind::Custom, Some("custom-b"))
2395 .expect("custom B identity");
2396
2397 apply_provider_picker_setup_confirmed(
2398 &mut app,
2399 &mut engine.handle,
2400 &mut config,
2401 identity,
2402 "saved-b-key".to_string(),
2403 "model-b-confirmed".to_string(),
2404 None,
2405 None,
2406 )
2407 .await;
2408
2409 assert_eq!(app.provider_identity_for_persistence(), "custom-b");
2410 assert_eq!(config.provider.as_deref(), Some("custom-b"));
2411 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2412 assert!(saved.contains("[providers.custom-b]"));
2413 assert!(saved.contains("api_key = \"saved-b-key\""));
2414 assert!(saved.contains("model = \"model-b-confirmed\""));
2415 assert!(!saved.contains("[providers.custom]\n"));
2416 }
2417
2418 #[test]
2419 fn legacy_literal_custom_identity_persistence_moves_into_the_custom_table() {
2420 let config_env = ConfigPathEnvGuard::new();
2421 std::fs::write(
2422 config_env.config_path(),
2423 r#"provider = "custom"
2424 base_url = "http://127.0.0.1:18180/v1"
2425 default_text_model = "legacy-model"
2426 "#,
2427 )
2428 .expect("seed legacy root route");
2429 let config = Config {
2430 provider: Some("custom".to_string()),
2431 default_text_model: Some("legacy-model".to_string()),
2432 ..Default::default()
2433 }
2434 .with_legacy_root(None, Some("http://127.0.0.1:18180/v1".to_string()));
2435 let identity = config
2436 .resolve_provider_identity("custom")
2437 .expect("legacy identity");
2438
2439 crate::config::save_api_key_for_identity(&identity, &config, "legacy-saved-key")
2440 .expect("save legacy key");
2441 crate::config::save_provider_model_for_identity(&identity, &config, "legacy-model-updated")
2442 .expect("save legacy model");
2443
2444 // The literal custom route's top-level fields now live in
2445 // `[providers.custom]` (#6394); the saves write there and the write
2446 // moves the old top-level endpoint alongside.
2447 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2448 let table: toml::Table = toml::from_str(&saved).expect("saved config parses");
2449 assert!(
2450 !codewhale_config::legacy_root::has_legacy_root_keys(&table),
2451 "{saved}"
2452 );
2453 let custom = table["providers"]["custom"]
2454 .as_table()
2455 .expect("custom table");
2456 assert_eq!(custom["api_key"].as_str(), Some("legacy-saved-key"));
2457 assert_eq!(custom["model"].as_str(), Some("legacy-model-updated"));
2458 assert_eq!(
2459 custom["base_url"].as_str(),
2460 Some("http://127.0.0.1:18180/v1")
2461 );
2462 let reloaded = Config::load(Some(config_env.config_path()), None).expect("reload legacy");
2463 assert!(reloaded.selects_literal_custom_provider());
2464 assert_eq!(
2465 reloaded
2466 .resolve_provider_identity("custom")
2467 .expect("repeat legacy identity"),
2468 identity
2469 );
2470 let route = resolve_runtime_route(
2471 &reloaded,
2472 ProviderKind::Custom,
2473 Some("legacy-model-updated"),
2474 )
2475 .expect("resolve reloaded legacy")
2476 .validate()
2477 .expect("preflight reloaded legacy");
2478 assert_eq!(route.client.base_url(), "http://127.0.0.1:18180/v1");
2479 }
2480
2481 #[test]
2482 fn legacy_active_route_does_not_redirect_named_custom_persistence_to_root() {
2483 let config_env = ConfigPathEnvGuard::new();
2484 std::fs::write(
2485 config_env.config_path(),
2486 r#"provider = "custom"
2487 api_key = "legacy-root-key"
2488 base_url = "http://127.0.0.1:18180/v1"
2489 default_text_model = "legacy-model"
2490
2491 [providers.custom-b]
2492 kind = "openai-compatible"
2493 base_url = "http://127.0.0.1:18182/v1"
2494 model = "model-b"
2495 "#,
2496 )
2497 .expect("seed coexistence config");
2498 let config = Config::load(Some(config_env.config_path()), None).expect("load config");
2499 assert!(config.selects_literal_custom_provider());
2500 let identity = config
2501 .resolve_provider_identity("custom-b")
2502 .expect("named custom identity");
2503
2504 crate::config::save_api_key_for_identity(&identity, &config, "saved-b-key")
2505 .expect("save named custom key");
2506 crate::config::save_provider_model_for_identity(&identity, &config, "model-b-updated")
2507 .expect("save named custom model");
2508
2509 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
2510 let table: toml::Table = toml::from_str(&saved).expect("saved config parses");
2511 // The literal route's key moved into its own table, untouched by the
2512 // named route's save (#6394).
2513 assert_eq!(
2514 table["providers"]["custom"]["api_key"].as_str(),
2515 Some("legacy-root-key")
2516 );
2517 assert!(saved.contains("default_text_model = \"legacy-model\""));
2518 assert!(saved.contains("[providers.custom-b]"));
2519 assert!(saved.contains("api_key = \"saved-b-key\""));
2520 assert!(saved.contains("model = \"model-b-updated\""));
2521 }
2522 }
2523
2524 /// Build the foreground receipt only from the immutable route captured when
2525 /// this turn started. The app's selected route may already have changed by the
2526 /// time `TurnComplete` is handled, so it is not accepted as an input here.
2527 fn completed_turn_cost_route_receipt(
2528 completed_turn: Option<&crate::tui::app::ActiveTurnMetadata>,
2529 audit: &crate::pricing::TurnCostAudit,
2530 ) -> Option<String> {
2531 let route = completed_turn?.route.as_ref()?;
2532 Some(route.cost_envelope()?.receipt(audit))
2533 }
2534
2535 #[cfg(test)]
2536 mod tests;
2537
2538 #[cfg(test)]
2539 #[test]
2540 fn fleet_role_entry_opens_shared_picker_and_cancel_restores_parked_roster() {
2541 use crate::tui::views::ModalView;
2542 let _env = crate::test_support::lock_test_env();
2543 let workspace = tempfile::tempdir().unwrap();
2544 let config = Config::default();
2545 let mut app = App::new(
2546 crate::test_support::test_tui_options(workspace.path()),
2547 &config,
2548 );
2549 app.view_stack
2550 .push(crate::tui::views::fleet_roster::FleetRosterView::new(
2551 &app, &config,
2552 ));
2553 open_fleet_setup_target(&mut app, &config, Some("manager"));
2554 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ModelPicker));
2555 let mut picker = app.view_stack.pop().unwrap();
2556 let action = picker
2557 .as_any_mut()
2558 .downcast_mut::<crate::tui::model_picker::ModelPickerView>()
2559 .unwrap()
2560 .handle_key(crossterm::event::KeyEvent::new(
2561 crossterm::event::KeyCode::Esc,
2562 crossterm::event::KeyModifiers::NONE,
2563 ));
2564 let ViewAction::EmitAndClose(ViewEvent::FleetAssignmentPickerDismissed { editor_id }) = action
2565 else {
2566 panic!("assignment cancel must identify its editor")
2567 };
2568 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::FleetSetup));
2569 handlers::dismiss_fleet_assignment(&mut app, editor_id);
2570 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::FleetRoster));
2571 assert!(
2572 !workspace
2573 .path()
2574 .join(".codewhale/agents/manager.toml")
2575 .exists()
2576 );
2577 }
2578
2578 lines RUST