返回 CodeWhale
ui.rs
根目录 / crates / tui / src / tui / ui.rs
1 //! TUI event loop and rendering logic for `DeepSeek` CLI.
2
3 use std::cell::Cell;
4 use std::collections::{HashSet, VecDeque};
5 use std::fmt::Write as _;
6 use std::future::Future;
7 use std::io::{self, IsTerminal, Stdout, Write};
8 use std::path::{Path, PathBuf};
9 use std::pin::Pin;
10 use std::sync::{
11 Arc, LazyLock,
12 atomic::{AtomicBool, Ordering},
13 };
14 use std::thread::{self, JoinHandle};
15 use std::time::{Duration, Instant};
16
17 use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, ErrorSeverity};
18 use crate::resource_telemetry::{TokenThroughput, estimate_output_tokens_from_text};
19 use anyhow::{Context, Result};
20 use codewhale_release::InstallMethod;
21 // On Windows the push/pop helpers write the escapes directly; crossterm's
22 // PushKeyboardEnhancementFlags / PopKeyboardEnhancementFlags commands are
23 // never referenced, so the imports are gated to avoid -D warnings failures.
24 #[cfg(not(windows))]
25 use crossterm::event::{
26 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
27 };
28 use crossterm::{
29 event::{
30 self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
31 EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
32 KeyModifiers,
33 },
34 execute,
35 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
36 };
37 use ratatui::{
38 Frame, Terminal,
39 layout::{Constraint, Direction, Layout, Rect, Size},
40 prelude::Widget,
41 style::Style,
42 widgets::Block,
43 };
44 use tracing;
45 #[cfg(target_os = "windows")]
46 use windows::Win32::System::Console::{GetConsoleMode, GetStdHandle, SetConsoleMode};
47
48 use crate::audit::log_sensitive_event;
49 use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler};
50 use crate::client::{
51 CacheWarmupKey, DeepSeekClient, PromptInspection, build_cache_warmup_request,
52 inspect_prompt_for_request,
53 };
54 use crate::commands;
55 use crate::compaction::CompactionConfig;
56 use crate::compaction::{estimate_input_tokens_conservative, estimate_tokens};
57 use crate::config::{
58 ApiProvider, Config, ProviderConfig, ProviderIdentity, ProvidersConfig, StatusItem,
59 UpdateConfig, persist_external_credential_consent_for_at,
60 revoke_external_credential_consent_for_at,
61 };
62 use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent};
63 use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
64 use crate::core::events::Event as EngineEvent;
65 use crate::core::ops::{Op, ProviderRuntimeStatus, USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance};
66 use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals};
67 use crate::llm_client::LlmClient;
68 use crate::localization::{MessageId, tr};
69 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
70 use crate::palette;
71 use crate::prompts;
72 use crate::route_runtime::{resolve_runtime_route, resolve_runtime_route_for_identity};
73 use crate::session_manager::{
74 OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager,
75 create_saved_session_with_id_and_mode, create_saved_session_with_mode,
76 };
77 use crate::settings::Settings;
78 use crate::task_manager::{
79 NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, TaskSummary,
80 };
81 use crate::tools::goal::{GoalSnapshot, GoalStatus};
82 use crate::tools::shell::{ShellJobSnapshot, ShellStatus};
83 use crate::tools::spec::{RuntimeToolServices, ToolResult};
84 use crate::tools::subagent::{MailboxMessage, SubAgentStatus, subagent_progress_tool_display_name};
85 use crate::tui::auto_router;
86 use crate::tui::clipboard::ClipboardContent;
87 use crate::tui::color_compat::ColorCompatBackend;
88 use crate::tui::command_palette::{
89 CommandPaletteView, build_entries_with_plugins as build_command_palette_entries,
90 };
91 use crate::tui::composer_ui::*;
92 use crate::tui::context_inspector::ContextInspectorView;
93 use crate::tui::event_broker::EventBroker;
94 use crate::tui::file_mention::ContextReference;
95 use crate::tui::file_picker_relevance;
96 use crate::tui::footer_ui::{friendly_subagent_progress, is_noisy_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_manager_pager};
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 // SelectionAutoscroll unused
111 use crate::tui::motion::{FrameRequester, MotionMode};
112 use crate::tui::session_picker::SessionPickerView;
113 use crate::tui::shell_job_routing::{
114 add_shell_job_message, format_shell_job_list, format_shell_poll, open_shell_job_pager,
115 };
116 use crate::tui::streaming::StreamDisplayClock;
117 use crate::tui::streaming_thinking;
118 use crate::tui::subagent_routing::{
119 apply_subagent_terminal_projection, format_task_list, handle_subagent_mailbox_for_turn,
120 open_task_pager, parent_stop_status, reconcile_subagent_activity_state, running_agent_count,
121 sort_subagents_in_place, subagent_message_refreshes_workspace_context, task_mode_label,
122 task_summary_to_panel_entry,
123 };
124 #[cfg(test)]
125 use crate::tui::subagent_routing::{handle_subagent_mailbox, reconcile_subagent_activity_state_at};
126 #[cfg(test)]
127 use crate::tui::tool_routing::exploring_label;
128 use crate::tui::tool_routing::{
129 apply_workflow_ui_event, handle_tool_call_complete, handle_tool_call_started,
130 };
131 use crate::tui::ui_text::history_cell_to_text;
132 use crate::tui::user_input::UserInputView;
133 use crate::tui::views::subagent_view_agents;
134 use crate::tui::vim_mode;
135 use crate::tui::workspace_context;
136
137 use super::key_actions;
138
139 use super::app::{
140 ActiveTurnMetadata, AgentCurrentActivity, AgentCurrentActivityStatus, App, AppAction, AppMode,
141 ComposerSubmitAction, ComposerSubmitChord, EffectiveReasoningEffort, HuntVerdict,
142 OnboardingState, PendingProviderSwitch, QueuedMessage, ReasoningEffort, StatusToast,
143 StatusToastLevel, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind, ToolEvidence,
144 TuiOptions, bound_agent_activity_text, is_stop_word, looks_like_slash_command_input,
145 shell_command_from_bang_input,
146 };
147 use super::approval::{
148 ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
149 };
150 use super::history::{
151 ExecCell, HistoryCell, ToolCell, ToolStatus, history_cells_from_message, summarize_tool_output,
152 };
153 use super::slash_menu::{
154 apply_slash_menu_selection, partial_inline_skill_mention_at_cursor,
155 try_autocomplete_slash_command, visible_slash_menu_entries,
156 };
157 use super::views::{ConfigView, ContextMenuAction, HelpView, ModalKind, ViewEvent};
158 use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview};
159 use super::widgets::{ChatWidget, ComposerWidget, Renderable};
160
161 // Activity Detail / raw-detail / pager-text helpers extracted into `activity_detail`
162 // (issue #4103). Re-export the cross-module entry points so existing
163 // `crate::tui::ui::{...}` importers (mouse_ui, footer_ui) keep resolving, and
164 // import the ui-internal entry points used from this file's own body.
165 pub(crate) use self::activity_detail::{
166 copy_cell_to_clipboard, detail_target_label, open_details_pager_for_cell, turn_handoff_markdown,
167 };
168 use self::activity_detail::{
169 copy_focused_cell, detail_target_cell_index, extract_reasoning_header,
170 open_reasoning_detail_pager, open_tool_details_pager, open_turn_inspector_pager,
171 };
172 // Ctrl+O now opens the full recorded Reasoning Detail for the selected or
173 // current reasoning block. The whole-turn Turn Inspector moved to Ctrl+Alt+O
174 // and `/turn inspect`. (`v` raw leaf detail keeps using `open_tool_details_pager`.)
175
176 // === Constants ===
177
178 /// Upper bound on slash-menu entries returned to the renderer. The composer's
179 /// render path already paginates with center-tracking (see
180 /// `widgets::ComposerWidget::render`), so this only needs to be high enough to
181 /// encompass the full filtered command list — never the visible-row budget.
182 /// Bumped from 6 to 128 to fix #64 (selection couldn't reach commands beyond
183 /// the visible window because the source list itself was capped).
184 const SLASH_MENU_LIMIT: usize = 128;
185 const MIN_CHAT_HEIGHT: u16 = 3;
186 const MIN_COMPOSER_HEIGHT: u16 = 2;
187 const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
188 const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
189 const CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT: f64 = 60.0;
190 const UI_IDLE_POLL_MS: u64 = 48;
191 const UI_ACTIVE_POLL_MS: u64 = 24;
192 const SUBAGENT_HOOK_PREVIEW_LIMIT: usize = 2_048;
193 const WEB_CONFIG_POLL_MS: u64 = 16;
194 const DISPATCH_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(30);
195 /// Minimum wall-clock time a turn may stay in `"in_progress"` before the UI
196 /// assumes the engine stalled (e.g. sub-agent hang, lost completion event,
197 /// engine panic). The effective watchdog also respects the configured stream
198 /// idle timeout so legitimate long model-reasoning pauses are not interrupted
199 /// prematurely.
200 const TURN_STALL_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(300);
201 const TURN_STALL_WATCHDOG_GRACE: Duration = Duration::from_secs(30);
202 /// Running tools can legitimately exceed the silent-turn timeout, but a tool
203 /// with no progress heartbeat or output beyond this ceiling is treated as hung.
204 // Must stay comfortably above `turn_stall_watchdog_timeout` so a running tool
205 // gets extra grace beyond the turn-stall threshold (#1862 trimmed 15m → 10m).
206 const TOOL_HANG_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(600);
207 // Forced repaint cadence while a turn is live (model loading, compacting,
208 // sub-agents running). Drives the footer water-spout animation as well as
209 // the per-tool spinner pulse — keep this fast enough that the whale-spout
210 // braille pattern reads as continuous motion instead of teleport-frames.
211 const UI_STATUS_ANIMATION_MS: u64 = crate::tui::spinner::BRAILLE_SPINNER_FRAME_MS;
212 /// Ambient fish, the idle-mark caustic, and the completion wake use a modest
213 /// ~12.5fps clock by default. On measured high-Hz displays the adaptive probe
214 /// may raise this (still bounded); low_motion always freezes the cadence.
215 /// Active markers run at 8fps; atmosphere stays subordinate.
216 pub(crate) const UI_UNDERWATER_ANIMATION_MS: u64 = 80;
217 // Minimum chat-host width at which the file-tree pane renders. At an
218 // 80-column terminal the file tree owns 20 columns, leaving a 60-column chat
219 // host; below this floor the tree is hidden rather than squeezing the
220 // transcript under 40 columns. (Named for the file tree — the legacy sidebar
221 // this constant once described no longer gates on it.)
222 pub(crate) const FILE_TREE_MIN_HOST_WIDTH: u16 = 60;
223 const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500;
224 const TURN_META_PREFIX: &str = "<turn_meta>";
225 const SESSION_TITLE_MAX_CHARS: usize = 32;
226 const VERSION_HINT_TOAST_TTL_MS: u64 = 12_000;
227
228 const REQUIRED_RELEASE_ASSETS: &[&str] = &[
229 "codewhale-artifacts-sha256.txt",
230 "codew-android-arm64",
231 "codewhale-android-arm64",
232 "codewhale-android-arm64.tar.gz",
233 "codewhale-tui-android-arm64",
234 "codewhale-linux-arm64",
235 "codewhale-linux-arm64.tar.gz",
236 "codewhale-linux-x64",
237 "codewhale-linux-x64.tar.gz",
238 "codewhale-macos-arm64",
239 "codewhale-macos-arm64.tar.gz",
240 "codewhale-macos-x64",
241 "codewhale-macos-x64.tar.gz",
242 "codewhale-tui-linux-arm64",
243 "codewhale-tui-linux-x64",
244 "codewhale-tui-macos-arm64",
245 "codewhale-tui-macos-x64",
246 "codewhale-tui-windows-x64.exe",
247 "codewhale-windows-x64.exe",
248 "codewhale-windows-x64-portable.zip",
249 "codewhale-windows-x64.zip",
250 "codew-windows-arm64.exe",
251 "codewhale-tui-windows-arm64.exe",
252 "codewhale-windows-arm64.exe",
253 "codewhale-windows-arm64-portable.zip",
254 "codewhale-windows-arm64.zip",
255 ];
256
257 fn is_session_approved_for_tool(app: &App, tool_name: &str, grouping_key: &str) -> bool {
258 app.approval_session_approved.contains(grouping_key)
259 || app.approval_session_approved.contains(tool_name)
260 }
261
262 fn is_session_denied_for_key(app: &App, approval_key: &str) -> bool {
263 app.approval_session_denied.contains(approval_key)
264 }
265
266 fn session_denied_notice(app: &App, tool_name: &str) -> String {
267 app.tr(MessageId::ApprovalAutoDeniedSession)
268 .replace("{tool}", tool_name)
269 }
270
271 fn surface_session_denied_notice(app: &mut App, tool_name: &str) {
272 let notice = session_denied_notice(app, tool_name);
273 app.status_message = Some(notice.clone());
274 app.push_status_toast(notice.clone(), StatusToastLevel::Warning, Some(12_000));
275
276 // Tool completion and turn completion can replace the one-line status
277 // before the next frame is painted. Keep the recovery path in the
278 // transcript as a settled receipt as well, where it survives that event
279 // ordering and remains available to screen readers and scrollback.
280 let latest_transcript_cell = app
281 .active_cell
282 .as_ref()
283 .and_then(|cell| cell.entries().last())
284 .or_else(|| app.history.last());
285 let already_latest_receipt = matches!(
286 latest_transcript_cell,
287 Some(HistoryCell::System { content }) if content == &notice
288 );
289 if !already_latest_receipt {
290 let receipt = HistoryCell::System { content: notice };
291 if let Some(active_cell) = app.active_cell.as_mut() {
292 // Never grow committed history underneath an active cell: tool
293 // lookup indices address `history ++ active_cell`, so changing
294 // history.len() mid-turn would retarget the pending completion.
295 active_cell.push_untracked(receipt);
296 app.bump_active_cell_revision();
297 } else {
298 app.add_message(receipt);
299 }
300 }
301 }
302
303 async fn auto_deny_session_approval(
304 app: &mut App,
305 engine_handle: &EngineHandle,
306 id: &str,
307 tool_name: &str,
308 approval_key: &str,
309 ) {
310 log_sensitive_event(
311 "tool.approval.auto_deny_session",
312 serde_json::json!({
313 "tool_name": tool_name,
314 "approval_key": approval_key,
315 "session_id": app.current_session_id,
316 }),
317 );
318 let _ = engine_handle.deny_tool_call(id.to_string()).await;
319 surface_session_denied_notice(app, tool_name);
320 }
321
322 fn app_auto_approve_enabled(app: &App) -> bool {
323 app.mode == AppMode::Yolo || app.approval_mode == ApprovalMode::Bypass
324 }
325
326 /// Build the UI-side TurnAuthority for approval disposition (#4412).
327 ///
328 /// Shell/trust bits do not affect disposition; mode + approval_mode + the
329 /// full-access shape (Yolo/Bypass) are what the shared resolver consults.
330 fn app_turn_authority_for_approvals(app: &App) -> crate::core::authority::TurnAuthority {
331 crate::core::authority::TurnAuthority::from_effective_fields(
332 app.mode,
333 true,
334 false,
335 app_auto_approve_enabled(app),
336 app.approval_mode,
337 )
338 }
339
340 fn resolve_ui_approval_disposition(
341 app: &App,
342 tool_name: &str,
343 grouping_key: &str,
344 approval_key: &str,
345 approval_force_prompt: bool,
346 ) -> crate::core::authority::ApprovalRequestDisposition {
347 crate::core::authority::resolve_approval_request_disposition(
348 &app_turn_authority_for_approvals(app),
349 is_session_approved_for_tool(app, tool_name, grouping_key),
350 is_session_denied_for_key(app, approval_key),
351 approval_force_prompt,
352 )
353 }
354
355 fn should_suppress_user_input_prompt(app: &App) -> bool {
356 // Legacy hosts may still report Yolo/auto-approve with a stale `Auto`
357 // enum. Canonicalize that shape to Full Access before applying the one
358 // posture that suppresses questions: genuine Auto-Review.
359 let effective_posture = if app_auto_approve_enabled(app) {
360 ApprovalMode::Bypass
361 } else {
362 app.approval_mode
363 };
364 !crate::core::authority::permission_posture_allows_questions(effective_posture)
365 }
366
367 type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
368
369 type PendingToolUses = Vec<(String, String, serde_json::Value)>;
370
371 #[derive(Debug)]
372 enum TranslationEvent {
373 AssistantMessage {
374 history_index: Option<usize>,
375 original_text: String,
376 translated: anyhow::Result<String>,
377 thinking: Option<String>,
378 tool_uses: PendingToolUses,
379 },
380 Thinking {
381 placeholder: String,
382 translated: anyhow::Result<String>,
383 },
384 }
385
386 // Reset scroll region (`\x1b[r`), origin mode (`\x1b[?6l`), and home the cursor
387 // (`\x1b[H`) before letting ratatui's diff renderer repaint. The destructive
388 // `\x1b[2J\x1b[3J` pair was previously appended here to also wipe the visible
389 // screen and saved scrollback, but combined with the immediately-following
390 // `terminal.clear()` it produced a double-clear that several terminals
391 // (Ghostty, VSCode terminal, Win10 conhost) render as visible flicker on every
392 // TurnComplete / focus-gain / resize. The alt-screen buffer's double-buffering
393 // plus ratatui's `terminal.clear()` are sufficient to repaint cleanly.
394 const TERMINAL_ORIGIN_RESET: &[u8] = b"\x1b[r\x1b[?6l\x1b[H";
395 // Xterm alternate-scroll mode (DECSET 1007) converts wheel input into arrow
396 // keys. It is only meaningful when mouse reporting is unavailable; while
397 // mouse capture is active the terminal must deliver wheel events as mouse
398 // events, so 1007 stays off (iTerm2 converts anyway, breaking transcript
399 // wheel-scroll — #5223). `--no-mouse-capture` also keeps it off so the host
400 // terminal owns raw mouse selection behavior end-to-end (#4026).
401 const ENABLE_ALT_SCROLL_MODE: &[u8] = b"\x1b[?1007h";
402 const DISABLE_ALT_SCROLL_MODE: &[u8] = b"\x1b[?1007l";
403 /// Begin synchronized update (DEC 2026): tell the terminal to defer
404 /// rendering until END_SYNC_UPDATE is received. Best-effort —
405 /// terminals that don't support this silently ignore the sequence.
406 /// Reduces flicker on GPU-accelerated terminals (Ghostty, VSCode
407 /// Terminal, Kitty, WezTerm) by batching ratatui's incremental
408 /// diff writes into a single frame.
409 const BEGIN_SYNC_UPDATE: &[u8] = b"\x1b[?2026h";
410 /// End synchronized update (DEC 2026): tell the terminal to render
411 /// the complete frame now.
412 const END_SYNC_UPDATE: &[u8] = b"\x1b[?2026l";
413 const TERMINAL_INPUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
414 const TERMINAL_INPUT_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(500);
415 const TERMINAL_INPUT_STALL_TIMEOUT: Duration = Duration::from_secs(5);
416 const TERMINAL_INPUT_RECOVERY_COOLDOWN: Duration = Duration::from_secs(10);
417 const TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT: Duration = Duration::from_millis(500);
418 const TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL: Duration = Duration::from_millis(5);
419 /// Upper bound on engine events processed before yielding to terminal input.
420 const MAX_ENGINE_EVENTS_PER_DRAIN: usize = 16;
421 /// Wall-clock budget for one engine drain batch (#1830 / #2317 input fairness).
422 const ENGINE_DRAIN_TIME_BUDGET: Duration = Duration::from_millis(8);
423 /// Throttled in-progress checkpoint while a turn is live (#1830 progress loss).
424 const RECOVERY_SNAPSHOT_INTERVAL: Duration = Duration::from_secs(45);
425
426 enum TerminalInputMessage {
427 Event(Event),
428 Heartbeat,
429 Error(io::Error),
430 }
431
432 pub(crate) struct TerminalInputPump {
433 rx: std::sync::mpsc::Receiver<TerminalInputMessage>,
434 stop: Arc<AtomicBool>,
435 paused: Arc<AtomicBool>,
436 paused_ack: Arc<AtomicBool>,
437 handle: Option<JoinHandle<()>>,
438 last_alive_at: Cell<Instant>,
439 }
440
441 struct TerminalInputPumpParts {
442 rx: std::sync::mpsc::Receiver<TerminalInputMessage>,
443 stop: Arc<AtomicBool>,
444 paused: Arc<AtomicBool>,
445 paused_ack: Arc<AtomicBool>,
446 handle: JoinHandle<()>,
447 }
448
449 impl TerminalInputPump {
450 fn spawn() -> io::Result<Self> {
451 let parts = Self::spawn_parts()?;
452 Ok(Self {
453 rx: parts.rx,
454 stop: parts.stop,
455 paused: parts.paused,
456 paused_ack: parts.paused_ack,
457 handle: Some(parts.handle),
458 last_alive_at: Cell::new(Instant::now()),
459 })
460 }
461
462 fn spawn_parts() -> io::Result<TerminalInputPumpParts> {
463 let (tx, rx) = std::sync::mpsc::channel();
464 let stop = Arc::new(AtomicBool::new(false));
465 let paused = Arc::new(AtomicBool::new(false));
466 let paused_ack = Arc::new(AtomicBool::new(false));
467 let thread_stop = Arc::clone(&stop);
468 let thread_paused = Arc::clone(&paused);
469 let thread_paused_ack = Arc::clone(&paused_ack);
470 let handle = thread::Builder::new()
471 .name("codewhale-terminal-input".to_string())
472 .spawn(move || {
473 let mut last_heartbeat = Instant::now();
474 while !thread_stop.load(Ordering::Acquire) {
475 if thread_paused.load(Ordering::Acquire) {
476 thread_paused_ack.store(true, Ordering::Release);
477 thread::sleep(TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL);
478 continue;
479 }
480 thread_paused_ack.store(false, Ordering::Release);
481 match event::poll(TERMINAL_INPUT_POLL_INTERVAL) {
482 Ok(true) => match event::read() {
483 Ok(event) => {
484 last_heartbeat = Instant::now();
485 if tx.send(TerminalInputMessage::Event(event)).is_err() {
486 break;
487 }
488 }
489 Err(err) => {
490 let _ = tx.send(TerminalInputMessage::Error(err));
491 break;
492 }
493 },
494 Ok(false) => {
495 let now = Instant::now();
496 if now.duration_since(last_heartbeat)
497 >= TERMINAL_INPUT_HEARTBEAT_INTERVAL
498 {
499 last_heartbeat = now;
500 if tx.send(TerminalInputMessage::Heartbeat).is_err() {
501 break;
502 }
503 }
504 }
505 Err(err) => {
506 let _ = tx.send(TerminalInputMessage::Error(err));
507 break;
508 }
509 }
510 }
511 })?;
512 Ok(TerminalInputPumpParts {
513 rx,
514 stop,
515 paused,
516 paused_ack,
517 handle,
518 })
519 }
520
521 fn recv_timeout(&self, timeout: Duration) -> io::Result<Option<Event>> {
522 let deadline = Instant::now() + timeout;
523 loop {
524 let remaining = deadline.saturating_duration_since(Instant::now());
525 match self.rx.recv_timeout(remaining) {
526 Ok(TerminalInputMessage::Event(event)) => {
527 self.mark_alive();
528 return Ok(Some(event));
529 }
530 Ok(TerminalInputMessage::Heartbeat) => {
531 self.mark_alive();
532 if remaining.is_zero() {
533 return Ok(None);
534 }
535 }
536 Ok(TerminalInputMessage::Error(err)) => {
537 self.mark_alive();
538 return Err(err);
539 }
540 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Ok(None),
541 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
542 return Err(io::Error::new(
543 io::ErrorKind::BrokenPipe,
544 "terminal input pump disconnected",
545 ));
546 }
547 }
548 }
549 }
550
551 fn try_recv(&self) -> io::Result<Option<Event>> {
552 loop {
553 match self.rx.try_recv() {
554 Ok(TerminalInputMessage::Event(event)) => {
555 self.mark_alive();
556 return Ok(Some(event));
557 }
558 Ok(TerminalInputMessage::Heartbeat) => {
559 self.mark_alive();
560 }
561 Ok(TerminalInputMessage::Error(err)) => {
562 self.mark_alive();
563 return Err(err);
564 }
565 Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(None),
566 Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(None),
567 }
568 }
569 }
570
571 fn mark_alive(&self) {
572 self.last_alive_at.set(Instant::now());
573 }
574
575 fn stalled_for(&self, now: Instant) -> Duration {
576 now.saturating_duration_since(self.last_alive_at.get())
577 }
578
579 fn pause_for_child_terminal(&self) -> io::Result<()> {
580 self.paused.store(true, Ordering::Release);
581 if self.handle.is_none() {
582 self.paused_ack.store(true, Ordering::Release);
583 self.mark_alive();
584 return Ok(());
585 }
586
587 let deadline = Instant::now() + TERMINAL_INPUT_CHILD_PAUSE_TIMEOUT;
588 while !self.paused_ack.load(Ordering::Acquire) {
589 if Instant::now() >= deadline {
590 self.paused_ack.store(false, Ordering::Release);
591 self.paused.store(false, Ordering::Release);
592 return Err(io::Error::new(
593 io::ErrorKind::TimedOut,
594 "terminal input pump did not pause before launching editor",
595 ));
596 }
597 thread::sleep(TERMINAL_INPUT_CHILD_PAUSE_POLL_INTERVAL);
598 }
599 self.mark_alive();
600 Ok(())
601 }
602
603 fn resume_after_child_terminal(&self) {
604 self.paused_ack.store(false, Ordering::Release);
605 self.paused.store(false, Ordering::Release);
606 self.mark_alive();
607 }
608
609 /// Replace a wedged pump thread with a freshly spawned one.
610 ///
611 /// The old thread may be blocked forever inside crossterm's blocking
612 /// `event::read` (a stalled Windows console poll, or a Unix tty that
613 /// stopped delivering bytes), so it can never be joined. Instead it is
614 /// detached: `stop` is flagged and the `JoinHandle` dropped, so if the
615 /// thread ever wakes it exits on its own (its send fails once `rx` is
616 /// replaced, and the stop flag covers the poll loop).
617 fn restart_detached(&mut self) -> io::Result<()> {
618 self.detach_current_thread();
619 let parts = Self::spawn_parts()?;
620 self.install_parts(parts);
621 Ok(())
622 }
623
624 /// Flag the current pump thread to stop and drop its handle without
625 /// joining (the thread may be wedged in a blocking terminal read).
626 fn detach_current_thread(&mut self) {
627 self.stop.store(true, Ordering::Release);
628 let _ = self.handle.take();
629 }
630
631 /// Adopt freshly spawned pump parts and reset the liveness clock.
632 fn install_parts(&mut self, parts: TerminalInputPumpParts) {
633 self.rx = parts.rx;
634 self.stop = parts.stop;
635 self.paused = parts.paused;
636 self.paused_ack = parts.paused_ack;
637 self.handle = Some(parts.handle);
638 self.last_alive_at.set(Instant::now());
639 }
640 }
641
642 impl Drop for TerminalInputPump {
643 fn drop(&mut self) {
644 self.stop.store(true, Ordering::Release);
645 if let Some(handle) = self.handle.take() {
646 #[cfg(target_os = "windows")]
647 {
648 drop(handle);
649 }
650 #[cfg(not(target_os = "windows"))]
651 let _ = handle.join();
652 }
653 }
654 }
655
656 fn engine_drain_budget_exhausted(events_drained: usize, started: Instant, now: Instant) -> bool {
657 events_drained >= MAX_ENGINE_EVENTS_PER_DRAIN
658 || now.saturating_duration_since(started) >= ENGINE_DRAIN_TIME_BUDGET
659 }
660
661 /// Where a key goes while onboarding owns the screen (#4763).
662 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
663 pub(crate) enum OnboardingKeyRoute {
664 /// Terminate the session. Ctrl+C is unconditional during onboarding.
665 Quit,
666 /// Hand the key to the provider picker on the view stack.
667 ProviderPicker,
668 /// Hand the key to the theme picker owning the appearance step (#3937).
669 /// Escape belongs to the picker so its revert path runs; the shell must
670 /// not pop the modal and strand a previewed-but-unsaved theme.
671 ThemePicker,
672 /// Take the advertised offline exit (#3927). Reachable from Provider
673 /// setup even while the provider picker owns the screen, so the choice is
674 /// never hidden behind a modal the user cannot satisfy.
675 ExploreOffline,
676 /// Fall through to the legacy onboarding key switch.
677 Legacy,
678 }
679
680 fn surface_prompt_override_notices(app: &mut App) {
681 for notice in prompts::take_prompt_override_notices() {
682 app.add_message(HistoryCell::System {
683 content: format!("Warning: {notice}"),
684 });
685 app.push_status_toast(notice, StatusToastLevel::Warning, Some(12_000));
686 }
687 }
688
689 async fn drain_remote_control_events(
690 app: &mut App,
691 config: &Config,
692 engine_handle: &EngineHandle,
693 ) -> Result<bool> {
694 let mut changed = false;
695 while let Some(event) = app.remote_control.try_next_event() {
696 changed = true;
697 match event {
698 crate::remote_control::RemoteEvent::Notice(message) => {
699 app.add_message(HistoryCell::System {
700 content: message.clone(),
701 });
702 app.status_message = Some(message.clone());
703 app.sticky_status =
704 Some(StatusToast::new(message, StatusToastLevel::Warning, None));
705 }
706 crate::remote_control::RemoteEvent::Connected {
707 account_ref,
708 runner_id,
709 ..
710 } => {
711 let status = format!(
712 "REMOTE CONTROL · account {account_ref} · runner {runner_id} · /rc stop returns input here"
713 );
714 app.add_message(HistoryCell::System {
715 content: format!(
716 "{status}\n\nThe web now owns new prompts and approvals. This terminal remains readable."
717 ),
718 });
719 app.status_message = Some(status.clone());
720 app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Warning, None));
721 }
722 crate::remote_control::RemoteEvent::Failed(error) => {
723 let status = format!(
724 "REMOTE CONTROL LOST · {error} · input stays locked until the server lease expires"
725 );
726 app.status_message = Some(status.clone());
727 app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Error, None));
728 }
729 crate::remote_control::RemoteEvent::Stopped => {
730 app.sticky_status = None;
731 app.status_message =
732 Some("Remote control stopped; this terminal owns input again.".to_string());
733 }
734 crate::remote_control::RemoteEvent::OwnershipRestored { approvals } => {
735 app.sticky_status = None;
736 app.status_message = Some(
737 "The remote lease expired safely; this terminal owns input again.".to_string(),
738 );
739 for approval in approvals {
740 push_approval_request_view(
741 app,
742 &approval.tool_id,
743 &approval.tool_name,
744 &approval.description,
745 &approval.input,
746 &approval.approval_key,
747 approval.intent_summary.as_deref(),
748 );
749 }
750 }
751 crate::remote_control::RemoteEvent::Command {
752 run_id,
753 seq,
754 command,
755 } => {
756 match app.remote_control.claim_command(&run_id, seq, &command) {
757 Ok(true) => {}
758 Ok(false) => continue,
759 Err(error) => {
760 app.remote_control.acknowledge(
761 &run_id,
762 seq,
763 &command,
764 "failed",
765 Some(error.clone()),
766 );
767 app.remote_control.stop();
768 app.sticky_status = None;
769 app.status_message = Some(error);
770 continue;
771 }
772 }
773 match command.clone() {
774 crate::remote_control::RemoteCommand::Prompt { turn_id, prompt } => {
775 if app.is_loading || app.dispatch_in_flight {
776 app.remote_control.acknowledge(
777 &run_id,
778 seq,
779 &command,
780 "failed",
781 Some(
782 "The exact session is already running a turn; no second owner was started."
783 .to_string(),
784 ),
785 );
786 continue;
787 }
788 app.remote_control
789 .upload_snapshot(&run_id, &app.api_messages);
790 app.remote_control.activate_prompt(&run_id, &turn_id);
791 let message = QueuedMessage::new(prompt, None);
792 app.remote_control.set_applying_remote_command(true);
793 let result = dispatch_user_message_with_recovery(
794 app,
795 config,
796 engine_handle,
797 message,
798 DispatchRecovery::Immediate,
799 )
800 .await;
801 app.remote_control.set_applying_remote_command(false);
802 match result {
803 Ok(()) if app.is_loading || app.dispatch_in_flight => {
804 app.remote_control
805 .acknowledge(&run_id, seq, &command, "applied", None);
806 }
807 Ok(()) => {
808 app.remote_control.acknowledge(
809 &run_id,
810 seq,
811 &command,
812 "failed",
813 Some(
814 "The remote prompt was blocked before dispatch."
815 .to_string(),
816 ),
817 );
818 }
819 Err(error) => {
820 app.remote_control.acknowledge(
821 &run_id,
822 seq,
823 &command,
824 "failed",
825 Some(error.to_string()),
826 );
827 }
828 }
829 }
830 crate::remote_control::RemoteCommand::Approval { gate, approved } => {
831 let Some(tool_id) = app.remote_control.take_pending_approval(&gate) else {
832 app.remote_control.acknowledge(
833 &run_id,
834 seq,
835 &command,
836 "failed",
837 Some("This approval is no longer pending.".to_string()),
838 );
839 continue;
840 };
841 let result = if approved {
842 engine_handle.approve_tool_call(tool_id).await
843 } else {
844 engine_handle.deny_tool_call(tool_id).await
845 };
846 match result {
847 Ok(()) => app
848 .remote_control
849 .acknowledge(&run_id, seq, &command, "applied", None),
850 Err(error) => app.remote_control.acknowledge(
851 &run_id,
852 seq,
853 &command,
854 "failed",
855 Some(error.to_string()),
856 ),
857 }
858 }
859 crate::remote_control::RemoteCommand::Control { .. } => {
860 if !app.remote_control.active_run_matches(&run_id) {
861 app.remote_control.acknowledge(
862 &run_id,
863 seq,
864 &command,
865 "failed",
866 Some("This run no longer owns an active turn.".to_string()),
867 );
868 continue;
869 }
870 engine_handle.cancel();
871 mark_active_turn_cancelled_locally(app);
872 app.remote_control
873 .acknowledge(&run_id, seq, &command, "applied", None);
874 }
875 }
876 }
877 }
878 }
879 Ok(changed)
880 }
881
882 fn start_remote_control_session(app: &mut App) {
883 if app.is_loading {
884 app.status_message = Some(
885 "Finish or interrupt the current turn before handing this session to the web."
886 .to_string(),
887 );
888 return;
889 }
890 let session_id = app
891 .current_session_id
892 .clone()
893 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
894 app.current_session_id = Some(session_id.clone());
895 let target_ref = crate::remote_control::target_ref(&app.workspace, &session_id);
896 let workspace_label = app
897 .workspace
898 .file_name()
899 .and_then(|value| value.to_str())
900 .filter(|value| !value.is_empty())
901 .unwrap_or("Codewhale session")
902 .to_string();
903 let runtime_commit = option_env!("CODEWHALE_BUILD_COMMIT")
904 .unwrap_or("")
905 .to_string();
906 match app
907 .remote_control
908 .start(crate::remote_control::RemoteStart {
909 workspace_label,
910 target_ref,
911 session_id,
912 runtime_version: env!("CARGO_PKG_VERSION").to_string(),
913 runtime_commit,
914 }) {
915 Ok(()) => {
916 let status = app.remote_control.status_line();
917 app.status_message = Some(status.clone());
918 app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Warning, None));
919 }
920 Err(error) => {
921 app.status_message = Some(error.clone());
922 app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
923 }
924 }
925 }
926
927 #[cfg(test)]
928 #[test]
929 fn tui_launch_preflight_explains_non_tty_failure() {
930 assert!(require_interactive_terminal(true, true).is_ok());
931 for (stdin_is_tty, stdout_is_tty) in [(false, true), (true, false), (false, false)] {
932 let err = require_interactive_terminal(stdin_is_tty, stdout_is_tty)
933 .expect_err("a missing TTY must fail before raw mode");
934 let message = err.to_string();
935 assert!(message.contains("interactive terminal"), "{message}");
936 assert!(message.contains("codewhale exec"), "{message}");
937 }
938 }
939
940 fn should_show_resume_hint(session_id: Option<&str>) -> bool {
941 session_id.is_some_and(|id| !id.trim().is_empty())
942 }
943
944 fn resume_hint_text() -> &'static str {
945 "To continue this session, execute codewhale run --continue"
946 }
947
948 fn execute_subagent_observer_hook(
949 app: &App,
950 event: HookEvent,
951 agent_id: &str,
952 text_field: &str,
953 text: &str,
954 ) -> Result<(), String> {
955 if !app.hooks.has_hooks_for_event(event) {
956 return Ok(());
957 }
958
959 let (preview, truncated) = bounded_subagent_hook_preview(text);
960 let context = app.base_hook_context().with_message(&preview);
961 let mut payload = serde_json::json!({
962 "event": event.as_str(),
963 "agent_id": agent_id,
964 "session_id": context.session_id.as_deref(),
965 "workspace": context.workspace.as_ref().map(|path| path.display().to_string()),
966 "mode": context.mode.as_deref(),
967 "model": context.model.as_deref(),
968 "total_tokens": context.total_tokens,
969 });
970 if let Some(object) = payload.as_object_mut() {
971 object.insert(
972 format!("{text_field}_preview"),
973 serde_json::Value::String(preview),
974 );
975 object.insert(
976 format!("{text_field}_truncated"),
977 serde_json::Value::Bool(truncated),
978 );
979 }
980
981 if event == HookEvent::SubagentComplete {
982 payload["status"] = serde_json::Value::String(
983 subagent_completion_status(text).unwrap_or_else(|| "unknown".to_string()),
984 );
985 }
986
987 app.hooks.submit_json_observer(event, context, payload)
988 }
989
990 fn execute_turn_end_observer_hook(
991 app: &App,
992 turn: Option<&ActiveTurnMetadata>,
993 usage: &Usage,
994 billing_surface: Option<&str>,
995 duration: Duration,
996 error: Option<&str>,
997 ) -> Result<(), String> {
998 if !app.hooks.has_hooks_for_event(HookEvent::TurnEnd) {
999 return Ok(());
1000 }
1001
1002 let metadata = turn_end_observer_metadata(turn);
1003 let context = app.base_hook_context();
1004 let payload = crate::hooks::turn_end_payload(TurnEndPayloadInput {
1005 context: &context,
1006 created_at: metadata.created_at,
1007 model_backed: metadata.route.is_some(),
1008 provider: metadata.route.map(|route| route.provider_identity.as_str()),
1009 billing_surface: metadata.route.and(billing_surface),
1010 model: metadata.route.map(|route| route.model.as_str()),
1011 turn_id: metadata.turn_id.as_ref(),
1012 status: app.runtime_turn_status.as_deref().unwrap_or("unknown"),
1013 error,
1014 duration,
1015 usage,
1016 totals: TurnEndTotals {
1017 session_tokens: app.session.total_tokens,
1018 conversation_tokens: app.session.total_conversation_tokens,
1019 input_tokens: app.session.total_input_tokens,
1020 output_tokens: app.session.total_output_tokens,
1021 },
1022 tool_count: app.tool_evidence.len(),
1023 queued_message_count: app.queued_message_count(),
1024 });
1025 app.hooks
1026 .submit_json_observer(HookEvent::TurnEnd, context, payload)
1027 }
1028
1029 fn surface_observer_hook_submission_failure(app: &mut App, error: String) {
1030 app.surface_observer_hook_submission_failure(error);
1031 }
1032
1033 struct TurnEndObserverMetadata<'a> {
1034 turn_id: std::borrow::Cow<'a, str>,
1035 created_at: chrono::DateTime<chrono::Utc>,
1036 route: Option<&'a crate::core::events::TurnRoute>,
1037 }
1038
1039 fn turn_end_observer_metadata(turn: Option<&ActiveTurnMetadata>) -> TurnEndObserverMetadata<'_> {
1040 turn.map_or_else(
1041 || TurnEndObserverMetadata {
1042 // Manual compaction, purge, and shell-only completions predate the
1043 // TurnStarted lifecycle event. Preserve their observer contract
1044 // with a distinct non-model identity instead of borrowing a stale
1045 // model turn id.
1046 turn_id: std::borrow::Cow::Owned(format!("lifecycle_{}", uuid::Uuid::new_v4())),
1047 created_at: chrono::Utc::now(),
1048 route: None,
1049 },
1050 |turn| TurnEndObserverMetadata {
1051 turn_id: std::borrow::Cow::Borrowed(&turn.turn_id),
1052 created_at: turn.created_at,
1053 route: turn.route.as_ref(),
1054 },
1055 )
1056 }
1057
1058 fn bounded_subagent_hook_preview(text: &str) -> (String, bool) {
1059 if text.len() <= SUBAGENT_HOOK_PREVIEW_LIMIT {
1060 return (text.to_string(), false);
1061 }
1062 let safe_end = text
1063 .char_indices()
1064 .take_while(|(idx, ch)| idx + ch.len_utf8() <= SUBAGENT_HOOK_PREVIEW_LIMIT)
1065 .last()
1066 .map(|(idx, ch)| idx + ch.len_utf8())
1067 .unwrap_or(0);
1068 (format!("{}...[truncated]", &text[..safe_end]), true)
1069 }
1070
1071 fn subagent_completion_status(result: &str) -> Option<String> {
1072 const START: &str = "<codewhale:subagent.done>";
1073 const END: &str = "</codewhale:subagent.done>";
1074
1075 if let Some(start) = result.find(START).map(|idx| idx + START.len())
1076 && let Some(end) = result[start..].find(END).map(|idx| idx + start)
1077 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&result[start..end])
1078 && let Some(status) = value.get("status").and_then(serde_json::Value::as_str)
1079 {
1080 return Some(status.to_string());
1081 }
1082
1083 let summary = result.lines().find_map(|line| {
1084 let trimmed = line.trim();
1085 (!trimmed.is_empty()).then_some(trimmed)
1086 })?;
1087 let summary = summary.to_ascii_lowercase();
1088 if matches!(summary.as_str(), "cancelled" | "canceled")
1089 || summary.starts_with("cancelled:")
1090 || summary.starts_with("canceled:")
1091 {
1092 Some("cancelled".to_string())
1093 } else if summary == "failed" || summary.starts_with("failed:") {
1094 Some("failed".to_string())
1095 } else if summary == "interrupted" || summary.starts_with("interrupted:") {
1096 Some("interrupted".to_string())
1097 } else {
1098 None
1099 }
1100 }
1101
1102 fn subagent_failure_notice(result: &str) -> Option<String> {
1103 const START: &str = "<codewhale:subagent.done>";
1104 const END: &str = "</codewhale:subagent.done>";
1105 let start = result.find(START)? + START.len();
1106 let end = result[start..].find(END)? + start;
1107 let value = serde_json::from_str::<serde_json::Value>(&result[start..end]).ok()?;
1108 (value.get("event").and_then(serde_json::Value::as_str) == Some("subagent.failed"))
1109 .then(|| {
1110 let name = value
1111 .get("name")
1112 .and_then(serde_json::Value::as_str)
1113 .unwrap_or("unknown");
1114 let agent_id = value
1115 .get("agent_id")
1116 .and_then(serde_json::Value::as_str)
1117 .unwrap_or("unknown");
1118 let class = value
1119 .get("failure_class")
1120 .and_then(serde_json::Value::as_str)
1121 .unwrap_or("unavailable");
1122 let steps = value
1123 .get("steps")
1124 .and_then(serde_json::Value::as_u64)
1125 .map_or_else(|| "?".to_string(), |steps| steps.to_string());
1126 let elapsed_ms = value
1127 .get("elapsed_ms")
1128 .and_then(serde_json::Value::as_u64)
1129 .map_or_else(|| "?".to_string(), |elapsed| elapsed.to_string());
1130 let transcript_handle = value
1131 .get("transcript_handle")
1132 .and_then(serde_json::Value::as_str)
1133 .unwrap_or("unavailable");
1134 format!(
1135 "{name} ({agent_id}) · {class} · {steps} steps · {elapsed_ms} ms · inspect {transcript_handle}"
1136 )
1137 })
1138 }
1139
1140 fn subagent_status_from_completion_result(result: &str) -> SubAgentStatus {
1141 let reason = result
1142 .lines()
1143 .find_map(|line| {
1144 let trimmed = line.trim();
1145 (!trimmed.is_empty() && !trimmed.starts_with("<codewhale:subagent.done>"))
1146 .then_some(trimmed.to_string())
1147 })
1148 .unwrap_or_else(|| "sub-agent finished".to_string());
1149 match subagent_completion_status(result).as_deref() {
1150 Some("completed") => SubAgentStatus::Completed,
1151 Some("cancelled" | "canceled") => SubAgentStatus::Cancelled,
1152 Some("failed") => SubAgentStatus::Failed(reason),
1153 Some("interrupted") => SubAgentStatus::Interrupted(reason),
1154 Some("budget_exhausted") => SubAgentStatus::BudgetExhausted,
1155 _ => SubAgentStatus::Completed,
1156 }
1157 }
1158
1159 struct TerminalCleanupGuard {
1160 use_alt_screen: bool,
1161 use_mouse_capture: bool,
1162 use_bracketed_paste: bool,
1163 defused: bool,
1164 }
1165
1166 impl Drop for TerminalCleanupGuard {
1167 fn drop(&mut self) {
1168 if self.defused {
1169 return;
1170 }
1171
1172 let mut stdout = io::stdout();
1173 pop_keyboard_enhancement_flags(&mut stdout);
1174 disable_alternate_scroll_mode(&mut stdout);
1175 let _ = execute!(stdout, DisableFocusChange);
1176 let _ = disable_raw_mode();
1177 if self.use_alt_screen {
1178 let _ = execute!(stdout, LeaveAlternateScreen);
1179 }
1180 if self.use_mouse_capture {
1181 let _ = execute!(stdout, DisableMouseCapture);
1182 }
1183 if self.use_bracketed_paste {
1184 disable_bracketed_paste_mode(&mut stdout);
1185 }
1186 let _ = execute!(stdout, crossterm::cursor::Show);
1187 }
1188 }
1189
1190 /// Recognise composer input that is a `# foo` memory quick-add (#492).
1191 ///
1192 /// Returns `true` for inputs that:
1193 /// - start with `#`,
1194 /// - have at least one non-whitespace character after the leading `#`,
1195 /// - are a single line (no embedded `\n`), and
1196 /// - are not a shebang (`#!`) or Markdown heading (`## …`, `### …`).
1197 ///
1198 /// Multi-`#` prefixes are deliberately rejected so users can paste
1199 /// Markdown headings into the composer without triggering the quick-add.
1200 #[must_use]
1201 fn is_memory_quick_add(input: &str) -> bool {
1202 let trimmed = input.trim_start();
1203 if !trimmed.starts_with('#') {
1204 return false;
1205 }
1206 if trimmed.starts_with("##") || trimmed.starts_with("#!") {
1207 return false;
1208 }
1209 if input.contains('\n') {
1210 return false;
1211 }
1212 // Require something after the `#`.
1213 !trimmed.trim_start_matches('#').trim().is_empty()
1214 }
1215
1216 fn should_intercept_memory_quick_add(config: &Config, input: &str) -> bool {
1217 config.memory_enabled() && is_memory_quick_add(input)
1218 }
1219
1220 #[cfg(test)]
1221 mod memory_quick_add_tests {
1222 use super::should_intercept_memory_quick_add;
1223 use crate::config::Config;
1224
1225 #[test]
1226 fn memory_quick_add_interception_requires_memory_opt_in() {
1227 let enabled: Config = toml::from_str(
1228 r#"
1229 [memory]
1230 enabled = true
1231 "#,
1232 )
1233 .expect("parse enabled memory config");
1234 assert!(should_intercept_memory_quick_add(
1235 &enabled,
1236 "# remember this"
1237 ));
1238
1239 let disabled: Config = Config::default();
1240 assert!(!should_intercept_memory_quick_add(
1241 &disabled,
1242 "# remember this"
1243 ));
1244 assert!(!should_intercept_memory_quick_add(
1245 &enabled,
1246 "## Markdown heading"
1247 ));
1248 }
1249 }
1250
1251 fn spawn_tui_engine(config: EngineConfig, api_config: &Config) -> EngineHandle {
1252 let handle = spawn_engine(config, api_config);
1253 // Prime durable agent + coordination state through the same engine event
1254 // used by later refreshes. All TUI engine replacements use this wrapper,
1255 // so workspace switches and provider recovery cannot retain stale Work.
1256 let _ = handle.try_send(Op::ListSubAgents);
1257 handle
1258 }
1259
1260 fn configured_instruction_sources(config: &Config) -> Vec<prompts::InstructionSource> {
1261 config
1262 .instructions_paths()
1263 .into_iter()
1264 .map(Into::into)
1265 .collect()
1266 }
1267
1268 /// Open the exact effective base-prompt preview (#3928).
1269 ///
1270 /// Assembles the prompt through [`build_app_system_prompt_with_goal`] — the same
1271 /// function the dispatch path calls — so the preview is the next turn's bytes,
1272 /// not a reconstruction of them. Nothing is sent and no tool catalog is
1273 /// expanded; the preview is a pure read.
1274 fn preview_effective_base_prompt(app: &mut App, config: &Config) {
1275 use crate::prompts::base_preview;
1276
1277 let prompt = build_app_system_prompt_with_goal(app, config, app.hunt.quarry.as_deref());
1278 let home = codewhale_config::codewhale_home().ok();
1279 let constitution_path = codewhale_config::UserConstitution::path().ok();
1280 let sources = base_preview::PreviewSources {
1281 base_prompt: Some(crate::prompts::effective_base_prompt_source(
1282 home.as_deref(),
1283 )),
1284 user_constitution_path: constitution_path.as_deref(),
1285 workspace: Some(app.workspace.as_path()),
1286 home: home.as_deref(),
1287 };
1288 let report = base_preview::render_report(&base_preview::preview(&prompt, &sources));
1289 let width = app
1290 .viewport
1291 .last_transcript_area
1292 .map(|area| area.width)
1293 .unwrap_or(80);
1294 app.view_stack.push(crate::tui::pager::PagerView::from_text(
1295 crate::prompts::base_preview::PREVIEW_TITLE,
1296 &report,
1297 width.saturating_sub(2),
1298 ));
1299 }
1300
1301 async fn refresh_active_task_panel(app: &mut App, task_manager: &SharedTaskManager) -> bool {
1302 let tasks = task_manager.list_tasks(None).await;
1303 let previously_active_durable_ids = app
1304 .task_panel
1305 .iter()
1306 .filter(|entry| matches!(entry.status.as_str(), "queued" | "running"))
1307 .map(|entry| entry.id.as_str())
1308 .collect::<HashSet<_>>();
1309 let durable_background_completed = newly_completed_id(
1310 previously_active_durable_ids,
1311 tasks
1312 .iter()
1313 .filter(|task| task.status == TaskStatus::Completed)
1314 .map(|task| task.id.as_str()),
1315 );
1316 let mut lifecycle_changed = false;
1317 if let (Some(work), Some(session_id)) = (
1318 app.runtime_services.work.as_ref(),
1319 app.current_session_id.as_deref(),
1320 ) {
1321 for task in &tasks {
1322 let external = format!("task:{}", task.id);
1323 if !work.has_operation_binding(Some(session_id), &external) {
1324 continue;
1325 }
1326 match work.reconcile_operation(
1327 session_id,
1328 task_owner_snapshot(
1329 &task.id,
1330 task.status,
1331 task.lifecycle_seq,
1332 task.created_at,
1333 task.started_at,
1334 task.ended_at,
1335 ),
1336 ) {
1337 Ok(changed) => lifecycle_changed |= changed,
1338 Err(err) => {
1339 tracing::warn!(task_id = %task.id, error = %err, "failed to reconcile durable task lifecycle");
1340 }
1341 }
1342 }
1343 }
1344 if lifecycle_changed && let Err(err) = persist_pending_work_checkpoint(app).await {
1345 tracing::warn!(error = %err, "durable task lifecycle checkpoint remains pending");
1346 }
1347 let session_started_at = app.session_started_at;
1348 let mut entries: Vec<TaskPanelEntry> =
1349 select_work_sidebar_tasks(tasks, session_started_at, app.current_session_id.as_deref())
1350 .into_iter()
1351 .map(task_summary_to_panel_entry)
1352 .collect();
1353
1354 entries.extend(active_rlm_task_entries(app));
1355
1356 // #3804: this is a render-only read of shell jobs and must not block the
1357 // async UI loop on the shell manager's std::sync Mutex. Use try_lock; on
1358 // contention, retain the previous frame's background shell entries so
1359 // running shells don't flicker out of the Work panel. Shell ownership,
1360 // cancellation, approval state, and output capture never depend on this
1361 // refresh succeeding.
1362 let prev_shell_entries: Vec<TaskPanelEntry> = app
1363 .task_panel
1364 .iter()
1365 .filter(|entry| matches!(entry.kind, TaskPanelEntryKind::Background))
1366 .cloned()
1367 .collect();
1368 let prev_shell_ids = prev_shell_entries
1369 .iter()
1370 .map(|entry| entry.id.clone())
1371 .collect::<HashSet<_>>();
1372 let (shell_entries, shell_background_completed): (Vec<TaskPanelEntry>, bool) =
1373 match app.runtime_services.shell_manager.as_ref() {
1374 Some(shell_mgr) => match shell_mgr.try_lock() {
1375 Ok(mut mgr) => {
1376 let jobs = mgr.list_jobs();
1377 let completed = newly_completed_id(
1378 prev_shell_ids.iter().map(String::as_str).collect(),
1379 jobs.iter()
1380 .filter(|job| {
1381 matches!(job.status, crate::tools::shell::ShellStatus::Completed)
1382 })
1383 .map(|job| job.id.as_str()),
1384 );
1385 let entries = jobs
1386 .into_iter()
1387 .filter(|job| {
1388 matches!(job.status, crate::tools::shell::ShellStatus::Running)
1389 })
1390 .map(|job| TaskPanelEntry {
1391 id: job.id,
1392 status: "running".to_string(),
1393 prompt_summary: format!("shell: {}", job.command),
1394 duration_ms: Some(job.elapsed_ms),
1395 kind: TaskPanelEntryKind::Background,
1396 stale: job.stale,
1397 elapsed_since_output_ms: job.elapsed_since_output_ms,
1398 owner_agent_id: job.owner_agent_id,
1399 owner_agent_name: job.owner_agent_name,
1400 current_tool: None,
1401 role: None,
1402 files_touched: 0,
1403 })
1404 .collect();
1405 (entries, completed)
1406 }
1407 // Contended: keep the last known snapshot rather than blocking.
1408 Err(_) => (prev_shell_entries, false),
1409 },
1410 None => (Vec::new(), false),
1411 };
1412 entries.extend(shell_entries);
1413
1414 // Report whether anything visible changed so the idle tick can skip the
1415 // redraw: an unconditional 2.5 s repaint kept the app from ever going
1416 // quiescent (#3757).
1417 let changed = lifecycle_changed || app.task_panel != entries;
1418 app.task_panel = entries;
1419 let tip_shown = (durable_background_completed || shell_background_completed)
1420 && app.maybe_show_behavioral_tip(
1421 crate::tui::behavioral_tips::BehavioralTip::BackgroundJobReceipt,
1422 );
1423 changed || tip_shown
1424 }
1425
1426 fn newly_completed_id<'a>(
1427 previously_active_ids: HashSet<&'a str>,
1428 completed_ids: impl IntoIterator<Item = &'a str>,
1429 ) -> bool {
1430 completed_ids
1431 .into_iter()
1432 .any(|id| previously_active_ids.contains(id))
1433 }
1434
1435 fn refresh_shell_exec_live_output(app: &mut App) -> bool {
1436 let Some(shell_mgr) = app.runtime_services.shell_manager.as_ref().cloned() else {
1437 return false;
1438 };
1439 // #3804: render-only read — try_lock so a contended shell Mutex can never
1440 // block the async UI loop; skip this frame's live-output update on
1441 // contention (the next refresh picks it up).
1442 let jobs = {
1443 let Ok(mut mgr) = shell_mgr.try_lock() else {
1444 return false;
1445 };
1446 mgr.list_jobs()
1447 .into_iter()
1448 .map(|job| (job.id.clone(), job))
1449 .collect::<std::collections::HashMap<_, _>>()
1450 };
1451 let mut changed = false;
1452 for index in 0..app.virtual_cell_count() {
1453 let Some(ShellExecLiveUpdate {
1454 task_id,
1455 status: next_status,
1456 output: next_live,
1457 duration_ms: next_duration,
1458 finalized,
1459 stale_elapsed_since_output_ms,
1460 }) = shell_exec_live_update(app, index, &jobs)
1461 else {
1462 continue;
1463 };
1464 let Some(HistoryCell::Tool(ToolCell::Exec(exec))) = app.cell_at_virtual_index_mut(index)
1465 else {
1466 continue;
1467 };
1468 if exec.output.is_some() || exec.shell_task_id.as_deref() != Some(task_id.as_str()) {
1469 continue;
1470 }
1471 exec.status = next_status;
1472 exec.duration_ms = Some(next_duration);
1473 exec.stale_elapsed_since_output_ms = stale_elapsed_since_output_ms;
1474 if finalized {
1475 exec.output = next_live;
1476 exec.output_summary = exec
1477 .output
1478 .as_deref()
1479 .map(super::history::summarize_tool_output);
1480 exec.live_output = None;
1481 exec.stale_elapsed_since_output_ms = None;
1482 } else {
1483 exec.live_output = next_live;
1484 }
1485 changed = true;
1486 }
1487 changed
1488 }
1489
1490 struct ShellExecLiveUpdate {
1491 task_id: String,
1492 status: ToolStatus,
1493 output: Option<String>,
1494 duration_ms: u64,
1495 finalized: bool,
1496 stale_elapsed_since_output_ms: Option<u64>,
1497 }
1498
1499 fn shell_exec_live_update(
1500 app: &App,
1501 index: usize,
1502 jobs: &std::collections::HashMap<String, ShellJobSnapshot>,
1503 ) -> Option<ShellExecLiveUpdate> {
1504 let HistoryCell::Tool(ToolCell::Exec(exec)) = app.cell_at_virtual_index(index)? else {
1505 return None;
1506 };
1507 if exec.output.is_some() {
1508 return None;
1509 }
1510 let task_id = exec.shell_task_id.as_deref()?;
1511 let Some(job) = jobs.get(task_id) else {
1512 return Some(ShellExecLiveUpdate {
1513 task_id: task_id.to_string(),
1514 status: ToolStatus::Failed,
1515 output: detached_shell_job_output(task_id, exec),
1516 duration_ms: exec.duration_ms.unwrap_or_default(),
1517 finalized: true,
1518 stale_elapsed_since_output_ms: None,
1519 });
1520 };
1521 let next_status = shell_job_tool_status(&job.status);
1522 let next_live = shell_job_live_output(job).or_else(|| exec.live_output.clone());
1523 let finalized = !matches!(job.status, ShellStatus::Running);
1524 let stale_elapsed_since_output_ms = if matches!(job.status, ShellStatus::Running) && job.stale {
1525 Some(job.elapsed_since_output_ms.unwrap_or(0))
1526 } else {
1527 None
1528 };
1529 if exec.status == next_status
1530 && exec.live_output == next_live
1531 && exec.duration_ms == Some(job.elapsed_ms)
1532 && exec.stale_elapsed_since_output_ms == stale_elapsed_since_output_ms
1533 {
1534 return None;
1535 }
1536 Some(ShellExecLiveUpdate {
1537 task_id: task_id.to_string(),
1538 status: next_status,
1539 output: next_live,
1540 duration_ms: job.elapsed_ms,
1541 finalized,
1542 stale_elapsed_since_output_ms,
1543 })
1544 }
1545
1546 fn detached_shell_job_output(task_id: &str, exec: &ExecCell) -> Option<String> {
1547 let mut output = exec.live_output.clone().unwrap_or_default();
1548 if !output.trim().is_empty() {
1549 output.push_str("\n\n");
1550 }
1551 output.push_str(&format!(
1552 "Shell job `{task_id}` is no longer attached to this TUI session."
1553 ));
1554 Some(output)
1555 }
1556
1557 fn shell_job_tool_status(status: &ShellStatus) -> ToolStatus {
1558 match status {
1559 ShellStatus::Running => ToolStatus::Running,
1560 ShellStatus::Completed => ToolStatus::Success,
1561 ShellStatus::Failed | ShellStatus::Killed | ShellStatus::TimedOut => ToolStatus::Failed,
1562 }
1563 }
1564
1565 fn shell_job_live_output(job: &ShellJobSnapshot) -> Option<String> {
1566 match (job.stdout_tail.is_empty(), job.stderr_tail.is_empty()) {
1567 (true, true) => None,
1568 (false, true) => Some(job.stdout_tail.clone()),
1569 (true, false) => Some(format!("STDERR:\n{}", job.stderr_tail)),
1570 (false, false) => Some(format!(
1571 "{}\n\nSTDERR:\n{}",
1572 job.stdout_tail, job.stderr_tail
1573 )),
1574 }
1575 }
1576
1577 fn active_rlm_task_entries(app: &App) -> Vec<TaskPanelEntry> {
1578 let Some(active) = app.active_cell.as_ref() else {
1579 return Vec::new();
1580 };
1581 let duration_ms = app
1582 .turn_started_at
1583 .map(|started| u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX));
1584 active
1585 .entries()
1586 .iter()
1587 .enumerate()
1588 .filter_map(|(idx, entry)| {
1589 let HistoryCell::Tool(ToolCell::Generic(generic)) = entry else {
1590 return None;
1591 };
1592 if !matches!(
1593 generic.name.as_str(),
1594 "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm"
1595 ) || generic.status != ToolStatus::Running
1596 {
1597 return None;
1598 }
1599 let summary = generic
1600 .input_summary
1601 .as_deref()
1602 .filter(|summary| !summary.trim().is_empty())
1603 .unwrap_or("running chunked analysis");
1604 Some(TaskPanelEntry {
1605 id: format!("rlm-{}", idx + 1),
1606 status: "running".to_string(),
1607 prompt_summary: format!("RLM: {summary}"),
1608 duration_ms,
1609 kind: TaskPanelEntryKind::Background,
1610 stale: false,
1611 elapsed_since_output_ms: None,
1612 owner_agent_id: None,
1613 owner_agent_name: None,
1614 current_tool: None,
1615 role: None,
1616 files_touched: 0,
1617 })
1618 })
1619 .collect()
1620 }
1621
1622 /// Minimum interval between balance API fetches to avoid flooding.
1623 const BALANCE_FETCH_COOLDOWN: Duration = Duration::from_secs(60);
1624
1625 /// Shared `reqwest::Client` for balance fetches so connection pools are
1626 /// reused across successive background polls.
1627 static BALANCE_CLIENT: LazyLock<::reqwest::Client> = LazyLock::new(|| {
1628 crate::tls::reqwest_client_builder()
1629 .timeout(Duration::from_secs(10))
1630 .build()
1631 .unwrap_or_default()
1632 });
1633
1634 #[derive(Debug)]
1635 pub(crate) struct CacheWarmupOutcome {
1636 usage: Usage,
1637 provider_identity: String,
1638 model: String,
1639 base_url: String,
1640 inspection: PromptInspection,
1641 }
1642
1643 /// Install a completed constitution draft into the setup wizard (if still on
1644 /// top) and open its ratification preview, or surface a failure. Called from
1645 /// the event loop when the background draft lands, and directly on the
1646 /// pre-spawn provider-construction failure.
1647 fn deliver_constitution_draft_result(
1648 app: &mut App,
1649 model_label: String,
1650 locale: crate::localization::Locale,
1651 outcome: Result<Box<codewhale_config::UserConstitution>, String>,
1652 ) {
1653 match outcome {
1654 Ok(constitution) => {
1655 if app.view_stack.top_kind() == Some(ModalKind::SetupWizard)
1656 && let Some(mut boxed) = app.view_stack.pop()
1657 {
1658 let preview = boxed
1659 .as_any_mut()
1660 .downcast_mut::<crate::tui::setup::SetupWizardView>()
1661 .map(|wizard| wizard.install_model_draft(constitution, model_label.clone()));
1662 app.view_stack.push_boxed(boxed);
1663 if let Some((title, content)) = preview {
1664 open_text_pager(app, title, content);
1665 app.status_message = Some(crate::tui::setup::model_draft_ready_message(
1666 locale,
1667 &model_label,
1668 ));
1669 }
1670 }
1671 }
1672 Err(reason) => {
1673 app.status_message = Some(crate::tui::setup::model_draft_failed_message(
1674 locale,
1675 &model_label,
1676 &reason,
1677 ));
1678 }
1679 }
1680 app.needs_redraw = true;
1681 }
1682
1683 /// Install a completed fleet-profile draft into the wizard (if it is still on
1684 /// top), or surface a failure. Called from the event loop when the
1685 /// background draft lands, and directly on the pre-spawn
1686 /// provider-construction failure.
1687 ///
1688 /// The preview renders inline on the wizard's own Review step — deliberately
1689 /// NOT in a separate pager (#4093): a standalone pager view owns its own
1690 /// `g`/`G` scroll bindings and would swallow the ratify keypress, forcing an
1691 /// Esc-then-g round trip before the user could actually save.
1692 fn deliver_fleet_draft_result(
1693 app: &mut App,
1694 model_label: String,
1695 picked_route: Option<(String, String)>,
1696 reasoning_effort: Option<String>,
1697 outcome: Result<Box<crate::fleet::profile::FleetProfileDraft>, String>,
1698 locale: crate::localization::Locale,
1699 ) {
1700 match outcome {
1701 Ok(draft) => {
1702 if app.view_stack.top_kind() == Some(ModalKind::FleetSetup)
1703 && let Some(mut boxed) = app.view_stack.pop()
1704 {
1705 let installed = boxed
1706 .as_any_mut()
1707 .downcast_mut::<crate::tui::views::fleet_setup::FleetSetupView>()
1708 .map(|wizard| {
1709 wizard.install_model_draft(
1710 draft,
1711 model_label.clone(),
1712 picked_route.clone(),
1713 reasoning_effort.clone(),
1714 )
1715 })
1716 .is_some();
1717 app.view_stack.push_boxed(boxed);
1718 if installed {
1719 app.status_message = Some(match locale {
1720 crate::localization::Locale::ZhHans => {
1721 format!("{model_label} 已起草配置。请查看下方 TOML,然后按 g 保存。")
1722 }
1723 _ => format!(
1724 "{model_label} drafted the profile. Review the TOML below, then press g to save."
1725 ),
1726 });
1727 }
1728 }
1729 }
1730 Err(reason) => {
1731 app.status_message = Some(match locale {
1732 crate::localization::Locale::ZhHans => {
1733 format!("{model_label} 未能起草配置({reason})。按 Enter 仍会插入编写提示。")
1734 }
1735 _ => format!(
1736 "{model_label} could not draft the profile ({reason}). Enter still inserts the authoring prompt."
1737 ),
1738 });
1739 }
1740 }
1741 app.needs_redraw = true;
1742 }
1743
1744 // `format_*` chip/message builders moved to `tui/format_helpers.rs`.
1745
1746 fn is_work_graph_mutation_tool(name: &str) -> bool {
1747 matches!(
1748 name,
1749 "update_plan"
1750 | "work_update"
1751 | "checklist_write"
1752 | "todo_write"
1753 | "checklist_add"
1754 | "todo_add"
1755 | "checklist_update"
1756 | "todo_update"
1757 | "task_create"
1758 | "task_cancel"
1759 // Unified durable-task tool (piagent phase B): covers the
1760 // create/cancel actions the legacy names above carried.
1761 | "tasks"
1762 | "exec_shell"
1763 | "exec_shell_wait"
1764 | "exec_shell_cancel"
1765 | "agent"
1766 | "workflow"
1767 )
1768 }
1769
1770 fn turn_stall_watchdog_timeout(app: &App) -> Duration {
1771 let stream_budget = Duration::from_secs(app.stream_chunk_timeout_secs)
1772 .saturating_add(TURN_STALL_WATCHDOG_GRACE);
1773 TURN_STALL_WATCHDOG_TIMEOUT.max(stream_budget)
1774 }
1775
1776 fn active_turn_has_running_tool(app: &App) -> bool {
1777 app.active_cell.as_ref().is_some_and(|active| {
1778 active.entries().iter().any(|cell| match cell {
1779 HistoryCell::Tool(tool) => tool_cell_is_running(tool),
1780 _ => false,
1781 })
1782 })
1783 }
1784
1785 // Per-turn notification composition (settings, message body, summary)
1786 // moved to `tui/notifications.rs` alongside the dispatch primitives.
1787
1788 async fn tool_result_content_for_api_message(
1789 app: &App,
1790 id: &str,
1791 name: &str,
1792 output: &ToolResult,
1793 ) -> String {
1794 let raw = output.content.trim();
1795 if raw.is_empty() {
1796 return String::new();
1797 }
1798
1799 if matches!(
1800 name,
1801 "run_tests" | "run_verifiers" | "task_gate_run" | "tasks"
1802 ) {
1803 return crate::core::engine::compact_tool_result_for_route(
1804 app.api_provider,
1805 &app.model,
1806 app.active_route_limits,
1807 name,
1808 output,
1809 );
1810 }
1811
1812 if raw.chars().count() > crate::tool_output_receipts::RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS {
1813 let messages = live_tool_receipt_messages(app, id, raw, output.success);
1814 let artifacts = app.session_artifacts.clone();
1815 let raw = raw.to_string();
1816 match tokio::task::spawn_blocking(move || {
1817 compact_live_tool_receipt(messages, artifacts, raw)
1818 })
1819 .await
1820 {
1821 Ok(Some(receipt)) => return receipt,
1822 Ok(None) => {}
1823 Err(err) => {
1824 crate::logging::warn(format!("live tool-output receipt compaction failed: {err}"));
1825 }
1826 }
1827 }
1828
1829 crate::core::engine::compact_tool_result_for_route(
1830 app.api_provider,
1831 &app.model,
1832 app.active_route_limits,
1833 name,
1834 output,
1835 )
1836 }
1837
1838 // Streaming-thinking lifecycle helpers moved to `tui/streaming_thinking.rs`.
1839
1840 const INITIAL_PROMPT_DEFERRED_STATUS: &str = "Initial prompt ready; complete setup to send it";
1841
1842 fn paused_quarry_title(quarry: &str) -> &str {
1843 quarry
1844 .split(['\n', '\r'])
1845 .next()
1846 .map(str::trim)
1847 .filter(|line| !line.is_empty())
1848 .unwrap_or("the paused command")
1849 }
1850
1851 fn is_resume_message(message: &str) -> bool {
1852 let words: Vec<String> = message
1853 .to_ascii_lowercase()
1854 .split(|ch: char| !ch.is_ascii_alphanumeric())
1855 .filter(|word| !word.is_empty())
1856 .map(str::to_string)
1857 .collect();
1858 if words.is_empty() {
1859 return false;
1860 }
1861 let text = words.join(" ");
1862 let has_resume_verb = words
1863 .iter()
1864 .any(|word| matches!(word.as_str(), "continue" | "resume"));
1865 if !has_resume_verb {
1866 return false;
1867 }
1868
1869 let blockers = [
1870 "do not continue",
1871 "do not resume",
1872 "don t continue",
1873 "don t resume",
1874 "dont continue",
1875 "dont resume",
1876 "not continue",
1877 "not resume",
1878 "continue yet",
1879 "resume yet",
1880 "will continue",
1881 "will resume",
1882 "continue tomorrow",
1883 "resume tomorrow",
1884 "continue later",
1885 "resume later",
1886 ];
1887 if blockers.iter().any(|blocker| text.contains(blocker)) {
1888 return false;
1889 }
1890 if matches!(
1891 words.first().map(String::as_str),
1892 Some("how" | "what" | "when" | "where" | "why")
1893 ) {
1894 return false;
1895 }
1896
1897 if words.len() == 1 {
1898 return true;
1899 }
1900
1901 let context_words = [
1902 "please", "now", "paused", "pause", "command", "task", "work", "request", "goal",
1903 "previous", "last", "same", "it", "that", "this", "go", "ahead",
1904 ];
1905 if words
1906 .iter()
1907 .any(|word| context_words.contains(&word.as_str()))
1908 {
1909 return true;
1910 }
1911
1912 text.starts_with("can you continue")
1913 || text.starts_with("can you resume")
1914 || text.starts_with("could you continue")
1915 || text.starts_with("could you resume")
1916 }
1917
1918 fn paused_command_note(title: &str, resume: bool) -> String {
1919 let instruction = if resume {
1920 "The user is resuming that paused command. Continue the paused command."
1921 } else {
1922 "The user is not resuming that paused command. Answer only the new message and do not continue the paused command."
1923 };
1924 format!(
1925 "\n\nCodewhale paused custom slash command context:\n\
1926 Paused custom slash command: {title}\n\
1927 Paused command: {title}\n\
1928 {instruction}"
1929 )
1930 }
1931
1932 #[derive(Debug, Clone)]
1933 enum PausedCommandDispatch {
1934 None,
1935 ClearWithoutQuarry,
1936 Resume { quarry: String, note: String },
1937 Detach { note: String },
1938 }
1939
1940 impl PausedCommandDispatch {
1941 fn note(&self) -> Option<&str> {
1942 match self {
1943 Self::Resume { note, .. } | Self::Detach { note } => Some(note),
1944 Self::None | Self::ClearWithoutQuarry => None,
1945 }
1946 }
1947
1948 fn goal_objective(&self, app: &App) -> Option<String> {
1949 match self {
1950 Self::Resume { quarry, .. } => Some(quarry.clone()),
1951 Self::Detach { .. } | Self::ClearWithoutQuarry => None,
1952 Self::None => app.hunt.quarry.clone(),
1953 }
1954 }
1955
1956 fn apply(self, app: &mut App, engine_handle: &EngineHandle) {
1957 engine_handle.set_paused(false);
1958 match self {
1959 Self::None => {}
1960 Self::ClearWithoutQuarry => {
1961 app.paused = false;
1962 app.pausable = false;
1963 }
1964 Self::Resume { quarry, .. } => {
1965 app.paused = false;
1966 app.paused_quarry = None;
1967 app.hunt.quarry = Some(quarry);
1968 app.pausable = true;
1969 }
1970 Self::Detach { .. } => {
1971 app.paused = false;
1972 app.hunt.quarry = None;
1973 app.hunt.tokens_used = 0;
1974 app.hunt.time_used_seconds = 0;
1975 app.hunt.continuation_count = 0;
1976 }
1977 }
1978 }
1979 }
1980
1981 fn plan_paused_command_message(app: &App, user_message: &str) -> PausedCommandDispatch {
1982 if !app.paused && app.paused_quarry.is_none() {
1983 return PausedCommandDispatch::None;
1984 }
1985
1986 let Some(quarry) = app
1987 .paused_quarry
1988 .clone()
1989 .or_else(|| app.hunt.quarry.clone())
1990 else {
1991 return PausedCommandDispatch::ClearWithoutQuarry;
1992 };
1993 let title = paused_quarry_title(&quarry).to_string();
1994 if is_resume_message(user_message) {
1995 PausedCommandDispatch::Resume {
1996 quarry,
1997 note: paused_command_note(&title, true),
1998 }
1999 } else {
2000 PausedCommandDispatch::Detach {
2001 note: paused_command_note(&title, false),
2002 }
2003 }
2004 }
2005
2006 fn pause_pausable_command(app: &mut App, engine_handle: &EngineHandle) {
2007 app.paused_quarry = app
2008 .paused_quarry
2009 .clone()
2010 .or_else(|| app.hunt.quarry.clone());
2011 app.hunt.quarry = None;
2012 app.hunt.tokens_used = 0;
2013 app.hunt.time_used_seconds = 0;
2014 app.hunt.continuation_count = 0;
2015 app.paused = true;
2016 app.pausable = true;
2017 engine_handle.set_paused(true);
2018 app.status_message = Some(
2019 "Request paused. Send `continue` or `resume` to continue, or Esc to cancel.".to_string(),
2020 );
2021 }
2022
2023 fn clear_paused_command_state(app: &mut App, engine_handle: &EngineHandle) {
2024 app.pausable = false;
2025 app.paused = false;
2026 app.paused_quarry = None;
2027 engine_handle.set_paused(false);
2028 }
2029
2030 fn app_scoped_runtime_config(app: &App, config: &Config) -> (ProviderIdentity, Config) {
2031 let identity = ProviderIdentity {
2032 provider: app.api_provider,
2033 key: app.provider_identity_for_persistence().to_string(),
2034 exact_id: app.provider_id_for_persistence().map(str::to_string),
2035 };
2036 let mut scoped = config.clone();
2037 scoped.scope_to_provider_identity(&identity);
2038 (identity, scoped)
2039 }
2040
2041 #[derive(Debug, Clone, Copy)]
2042 pub(crate) enum DispatchRecovery {
2043 /// Normal immediate composer submit: restore the composer on failure.
2044 Immediate,
2045 /// A queued follow-up that was being edited in the composer.
2046 Draft,
2047 /// A queued follow-up pulled from the queue; re-insert at the prior index.
2048 Queued { restore_index: Option<usize> },
2049 /// Initial `--prompt` / startup input.
2050 Initial,
2051 }
2052
2053 /// Snapshot of App state taken before the sync prepare phase so a failed
2054 /// dispatch can roll back the optimistic history/api_messages changes.
2055 #[derive(Debug, Clone)]
2056 struct UserDispatchSnapshot {
2057 is_loading: bool,
2058 runtime_turn_status: Option<String>,
2059 receipt_text: Option<String>,
2060 receipt_started_at: Option<Instant>,
2061 tool_evidence: Vec<ToolEvidence>,
2062 history_len: usize,
2063 history_revisions_len: usize,
2064 history_version: u64,
2065 next_history_revision: u64,
2066 api_messages_len: usize,
2067 last_send_at: Option<Instant>,
2068 }
2069
2070 /// Data captured synchronously before the async dispatch phase. All values are
2071 /// Send so the spawned task can resolve routes and send without holding `&mut App`.
2072 #[allow(clippy::struct_excessive_bools)]
2073 #[derive(Debug, Clone)]
2074 pub(crate) struct UserDispatchPrepare {
2075 message: QueuedMessage,
2076 content: String,
2077 references: Vec<ContextReference>,
2078 paused_dispatch: PausedCommandDispatch,
2079 app_route_identity: ProviderIdentity,
2080 route_config: Config,
2081 goal_objective: Option<String>,
2082 goal_status: GoalStatus,
2083 goal_token_budget: Option<u32>,
2084 mode: AppMode,
2085 api_provider: ApiProvider,
2086 app_model: String,
2087 auto_model: bool,
2088 reasoning_effort: ReasoningEffort,
2089 allow_shell: bool,
2090 trust_mode: bool,
2091 auto_approve: bool,
2092 approval_mode: ApprovalMode,
2093 translation_enabled: bool,
2094 allowed_tools: Option<Vec<String>>,
2095 hook_executor: Option<Arc<HookExecutor>>,
2096 verbosity: Option<String>,
2097 provenance: UserInputProvenance,
2098 auto_router_context: String,
2099 should_auto_resolve: bool,
2100 auto_compact_user_configured: bool,
2101 auto_compact: bool,
2102 auto_compact_threshold_percent: f64,
2103 snapshot: UserDispatchSnapshot,
2104 message_index: usize,
2105 history_cell: usize,
2106 }
2107
2108 /// Data produced by the async dispatch phase that is needed to apply the
2109 /// post-acceptance mutations to `App`.
2110 #[derive(Debug, Clone)]
2111 pub(crate) struct UserDispatchOutcome {
2112 turn_compaction: CompactionConfig,
2113 effective_provider: ApiProvider,
2114 effective_model: String,
2115 effective_provider_identity: String,
2116 effective_provider_label: String,
2117 effective_reasoning_effort: EffectiveReasoningEffort,
2118 auto_selection: Option<crate::model_routing::AutoRouteSelection>,
2119 }
2120
2121 fn goal_status_from_snapshot(snapshot: &GoalSnapshot) -> Option<GoalStatus> {
2122 match snapshot.status.trim() {
2123 "active" => Some(GoalStatus::Active),
2124 "paused" => Some(GoalStatus::Paused),
2125 "complete" => Some(GoalStatus::Complete),
2126 "blocked" => Some(GoalStatus::Blocked),
2127 _ => None,
2128 }
2129 }
2130
2131 fn is_model_visible_tool_call(id: &str) -> bool {
2132 !id.starts_with(USER_SHELL_TOOL_ID_PREFIX)
2133 }
2134
2135 /// Queue a live compaction update without waiting on the engine mailbox.
2136 ///
2137 /// Config edits are valid while a turn is streaming, but awaiting a bounded
2138 /// engine mailbox from the UI event loop can make the whole TUI appear frozen
2139 /// when the turn is busy. A dropped refresh is safe: the next turn rebuilds
2140 /// its compaction config from `App`, and the status message tells the user
2141 /// whether the update was queued or deferred.
2142 fn try_apply_model_and_compaction_update(
2143 engine_handle: &EngineHandle,
2144 compaction: crate::compaction::CompactionConfig,
2145 mode: AppMode,
2146 route_limits: Option<codewhale_config::route::RouteLimits>,
2147 ) -> bool {
2148 if engine_handle
2149 .try_send(Op::SetModel {
2150 model: compaction.model.clone(),
2151 mode,
2152 route_limits,
2153 })
2154 .is_err()
2155 {
2156 return false;
2157 }
2158 engine_handle
2159 .try_send(Op::SetCompaction { config: compaction })
2160 .is_ok()
2161 }
2162
2163 #[cfg(test)]
2164 mod config_update_tests {
2165 use super::*;
2166 use crate::core::engine::mock_engine_handle;
2167 use crate::core::ops::Op;
2168
2169 #[tokio::test]
2170 async fn live_compaction_update_queues_without_waiting_on_engine() {
2171 let mut mock = mock_engine_handle();
2172 let compaction = crate::compaction::CompactionConfig {
2173 enabled: false,
2174 token_threshold: 123,
2175 model: "deepseek-v4-flash".to_string(),
2176 effective_context_window: Some(128_000),
2177 cache_summary: true,
2178 focus: None,
2179 live_state: None,
2180 runtime_cost_owner: None,
2181 };
2182
2183 assert!(try_apply_model_and_compaction_update(
2184 &mock.handle,
2185 compaction.clone(),
2186 AppMode::Agent,
2187 None,
2188 ));
2189
2190 assert!(matches!(
2191 mock.rx_op.recv().await,
2192 Some(Op::SetModel {
2193 model,
2194 mode: AppMode::Agent,
2195 route_limits: None,
2196 }) if model == compaction.model
2197 ));
2198 assert!(matches!(
2199 mock.rx_op.recv().await,
2200 Some(Op::SetCompaction { config }) if config == compaction
2201 ));
2202 }
2203 }
2204
2205 async fn drain_web_config_events(
2206 web_config_session: &mut Option<WebConfigSession>,
2207 app: &mut App,
2208 config: &mut Config,
2209 engine_handle: &EngineHandle,
2210 ) -> bool {
2211 let Some(session) = web_config_session.as_mut() else {
2212 return true;
2213 };
2214
2215 let mut keep_session = true;
2216 while let Ok(event) = session.receiver.try_recv() {
2217 match event {
2218 WebConfigSessionEvent::Draft(doc) => {
2219 match config_ui::apply_document(doc, app, config, false) {
2220 Ok(outcome) if outcome.changed => {
2221 if outcome.requires_engine_sync {
2222 apply_model_and_compaction_update(
2223 engine_handle,
2224 app.compaction_config(),
2225 app.mode,
2226 app.active_route_limits,
2227 )
2228 .await;
2229 }
2230 app.status_message = Some(format!(
2231 "Web config draft applied: {}",
2232 outcome.final_message
2233 ));
2234 }
2235 Ok(_) => {}
2236 Err(err) => {
2237 app.add_message(HistoryCell::System {
2238 content: format!("Web config draft apply failed: {err}"),
2239 });
2240 }
2241 }
2242 }
2243 WebConfigSessionEvent::Committed(doc) => {
2244 keep_session = false;
2245 match config_ui::apply_document(doc, app, config, true) {
2246 Ok(outcome) => {
2247 if outcome.requires_engine_sync {
2248 apply_model_and_compaction_update(
2249 engine_handle,
2250 app.compaction_config(),
2251 app.mode,
2252 app.active_route_limits,
2253 )
2254 .await;
2255 }
2256 app.add_message(HistoryCell::System {
2257 content: outcome.final_message.clone(),
2258 });
2259 app.status_message = Some(outcome.final_message);
2260 }
2261 Err(err) => {
2262 app.add_message(HistoryCell::System {
2263 content: format!("Web config commit failed: {err}"),
2264 });
2265 }
2266 }
2267 }
2268 WebConfigSessionEvent::Failed(err) => {
2269 keep_session = false;
2270 app.add_message(HistoryCell::System {
2271 content: format!("Web config session failed: {err}"),
2272 });
2273 }
2274 }
2275 }
2276
2277 keep_session
2278 }
2279
2280 /// Tell the operator that an explicit "make this my default" request did not
2281 /// take effect, instead of leaving a normal apply summary that reads like
2282 /// success. Silence here is what made the sticky-default bug so confusing.
2283 fn note_startup_default_not_saved(app: &mut App, save_as_startup_default: bool) {
2284 if !save_as_startup_default {
2285 return;
2286 }
2287 let existing = app.status_message.take();
2288 let note = "Startup default unchanged — the route was not applied.";
2289 app.status_message = Some(match existing {
2290 Some(message) if !message.trim().is_empty() => format!("{message} · {note}"),
2291 _ => note.to_string(),
2292 });
2293 }
2294
2295 pub(crate) struct ProviderFallbackRollback {
2296 identity: ProviderIdentity,
2297 chain: Option<codewhale_config::ProviderChain>,
2298 }
2299
2300 // File-picker relevance scoring moved to `tui/file_picker_relevance.rs`.
2301
2302 #[cfg(test)]
2303 use std::process::{Command, Stdio};
2304
2305 // `ui.rs` had grown past 19k lines. These three modules hold the same code,
2306 // moved verbatim, and are re-exported so every existing path still resolves.
2307 mod apply;
2308 mod event_loop;
2309 mod handlers;
2310
2311 pub(crate) use apply::*;
2312 pub(crate) use event_loop::*;
2313 pub(crate) use handlers::*;
2314 // The crate-wide glob would otherwise narrow this to `pub(crate)`; `tui/mod.rs`
2315 // re-exports it as the binary's entry point.
2316 pub use event_loop::run_tui;
2317
2318 mod dispatch;
2319 mod motion;
2320 mod release_check;
2321 mod terminal;
2322
2323 pub(crate) use dispatch::*;
2324 pub(crate) use motion::*;
2325 pub(crate) use release_check::*;
2326 pub(crate) use terminal::*;
2327
2328 mod frame;
2329 mod overlays;
2330 mod provider_routes;
2331 mod session_state;
2332
2333 pub(crate) use frame::*;
2334 pub(crate) use overlays::*;
2335 pub(crate) use provider_routes::*;
2336 pub(crate) use session_state::*;
2337
2338 #[cfg(test)]
2339 fn spawn_external_url_command(mut command: Command) -> Result<()> {
2340 command
2341 .stdin(Stdio::null())
2342 .stdout(Stdio::null())
2343 .stderr(Stdio::null())
2344 .spawn()
2345 .map(|_| ())
2346 .map_err(|err| anyhow::anyhow!("failed to launch browser command: {err}"))
2347 }
2348
2349 async fn execute_command_input(
2350 terminal: &mut AppTerminal,
2351 app: &mut App,
2352 engine_handle: &mut EngineHandle,
2353 task_manager: &SharedTaskManager,
2354 config: &mut Config,
2355 web_config_session: &mut Option<WebConfigSession>,
2356 input: &str,
2357 ) -> Result<bool> {
2358 let _ = app.note_manual_command_for_tip(input);
2359 if let Some(parsed_index) = parse_queue_send_command(input) {
2360 match parsed_index {
2361 Ok(index) => {
2362 send_queued_message_at_index_now(app, config, engine_handle, index).await?;
2363 }
2364 Err(message) => {
2365 app.status_message = Some(message);
2366 }
2367 }
2368 return Ok(false);
2369 }
2370
2371 let result = commands::execute(input, app);
2372 // After /logout: clear the in-memory api_key fields so the next
2373 // onboarding round entering a new key doesn't see the stale value
2374 // (#343). The on-disk side is handled by clear_api_key() inside
2375 // commands::config::logout.
2376 if input.trim().eq_ignore_ascii_case("/logout") {
2377 // Only clear the active provider's in-memory API key, not every
2378 // provider. The on-disk clear_api_key() inside commands::config::logout
2379 // already removes all saved keys; clearing only the active slot here
2380 // prevents surprising side-effects when the user has multiple providers
2381 // configured.
2382 clear_active_provider_api_key_from_memory(app, config);
2383 app.api_key_env_only = crate::config::active_provider_uses_env_only_api_key(config);
2384 }
2385 apply_command_result(
2386 terminal,
2387 app,
2388 engine_handle,
2389 task_manager,
2390 config,
2391 web_config_session,
2392 result,
2393 )
2394 .await
2395 }
2396
2397 #[derive(Debug, Clone)]
2398 pub(crate) struct SteerPausedSnapshot {
2399 paused: bool,
2400 pausable: bool,
2401 paused_quarry: Option<String>,
2402 quarry: Option<String>,
2403 tokens_used: u64,
2404 time_used_seconds: u64,
2405 continuation_count: u32,
2406 }
2407
2408 fn reject_local_input_while_remote(app: &mut App, input: &str) -> bool {
2409 if !app.remote_control.blocks_local_input() || is_remote_control_command(input) {
2410 return false;
2411 }
2412 app.input = input.to_string();
2413 app.cursor_position = app.input.chars().count();
2414 let status = "Web remote control owns prompts. Use /rc stop to return input to this terminal."
2415 .to_string();
2416 app.status_message = Some(status.clone());
2417 app.push_status_toast(status, StatusToastLevel::Warning, Some(6_000));
2418 true
2419 }
2420
2421 fn is_remote_control_command(input: &str) -> bool {
2422 input.split_whitespace().next().is_some_and(|value| {
2423 value.eq_ignore_ascii_case("/rc") || value.eq_ignore_ascii_case("/remote-control")
2424 })
2425 }
2426
2427 fn use_bundled_constitution(app: &mut App, config: &Config) {
2428 let mut state = crate::tui::setup::load_setup_state_for_app(app, config);
2429 state.complete_constitution_checkpoint(
2430 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
2431 codewhale_config::ConstitutionChoice::Bundled,
2432 );
2433 state.constitution_source = codewhale_config::ConstitutionSource::Bundled;
2434 state.constitution_validity = codewhale_config::ConstitutionValidity::Unknown;
2435 state.constitution_preview_hash = None;
2436 state.set_step(
2437 codewhale_config::SetupStep::Constitution,
2438 codewhale_config::StepEntry::new(
2439 codewhale_config::StepStatus::Verified,
2440 true,
2441 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
2442 )
2443 .with_result("bundled/default constitution"),
2444 );
2445
2446 match state.save() {
2447 Ok(()) => {
2448 app.status_message = Some(
2449 "Using the bundled/default constitution; custom user-global law is inactive."
2450 .to_string(),
2451 );
2452 }
2453 Err(err) => {
2454 app.status_message = Some(format!("Failed to save constitution choice: {err}"));
2455 app.add_message(HistoryCell::System {
2456 content: format!("Failed to save constitution choice: {err}"),
2457 });
2458 }
2459 }
2460 app.needs_redraw = true;
2461 }
2462
2463 fn prepare_config_update_result(
2464 mut result: commands::CommandResult,
2465 persist: bool,
2466 ) -> commands::CommandResult {
2467 // Live previews can fire on every navigation tick. Suppress routine
2468 // confirmations, but preserve errors and AppAction so one canonical path
2469 // remains responsible for both user-visible output and side effects.
2470 if !persist && !result.is_error {
2471 result.message = None;
2472 }
2473 result
2474 }
2475
2476 pub(crate) struct ApprovalDecisionEvent {
2477 tool_id: String,
2478 tool_name: String,
2479 decision: ReviewDecision,
2480 timed_out: bool,
2481 approval_key: String,
2482 approval_grouping_key: String,
2483 persistent_rules: Vec<codewhale_config::ToolAskRule>,
2484 }
2485
2486 struct RuntimePresetFileSnapshot {
2487 path: PathBuf,
2488 contents: Option<Vec<u8>>,
2489 }
2490
2491 impl RuntimePresetFileSnapshot {
2492 fn capture(path: PathBuf) -> Result<Self> {
2493 let contents = match std::fs::read(&path) {
2494 Ok(contents) => Some(contents),
2495 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
2496 Err(error) => {
2497 return Err(error)
2498 .with_context(|| format!("failed to snapshot {}", path.display()));
2499 }
2500 };
2501 Ok(Self { path, contents })
2502 }
2503
2504 fn restore(&self) -> Result<()> {
2505 match &self.contents {
2506 Some(contents) => crate::utils::write_atomic(&self.path, contents)
2507 .with_context(|| format!("failed to restore {}", self.path.display())),
2508 None => match std::fs::remove_file(&self.path) {
2509 Ok(()) => Ok(()),
2510 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
2511 Err(error) => {
2512 Err(error).with_context(|| format!("failed to remove {}", self.path.display()))
2513 }
2514 },
2515 }
2516 }
2517 }
2518
2519 fn runtime_preset_error_with_rollback(
2520 error: anyhow::Error,
2521 snapshots: &[&RuntimePresetFileSnapshot],
2522 ) -> anyhow::Error {
2523 let rollback_errors = snapshots
2524 .iter()
2525 .filter_map(|snapshot| snapshot.restore().err())
2526 .map(|error| format!("{error:#}"))
2527 .collect::<Vec<_>>();
2528 if rollback_errors.is_empty() {
2529 error
2530 } else {
2531 anyhow::anyhow!(
2532 "{error:#}; runtime preset rollback also failed: {}",
2533 rollback_errors.join("; ")
2534 )
2535 }
2536 }
2537
2538 fn mark_active_turn_cancelled_locally(app: &mut App) {
2539 // #2739: every local cancel surface (Esc, Ctrl+C, approval abort, paused
2540 // command abort) must snapshot before it clears turn state. Otherwise
2541 // --continue reloads the previous save and the interrupted turn vanishes.
2542 app.streaming_state.reset();
2543 app.finalize_active_cell_as_interrupted();
2544 app.finalize_streaming_assistant_as_interrupted();
2545 persist_recovery_snapshot(app);
2546 app.is_loading = false;
2547 app.dispatch_started_at = None;
2548 app.turn_started_at = None;
2549 app.turn_last_activity_at = None;
2550 app.runtime_turn_id = None;
2551 app.runtime_turn_status = None;
2552 app.suppress_stream_events_until_turn_complete = true;
2553 crate::retry_status::clear();
2554 crate::tui::notifications::clear_taskbar_progress();
2555 crate::tui::notifications::stop_title_animation_quietly();
2556 }
2557
2558 fn suppress_engine_event_after_local_cancel(event: &EngineEvent) -> bool {
2559 matches!(
2560 event,
2561 EngineEvent::MessageStarted { .. }
2562 | EngineEvent::MessageDelta { .. }
2563 | EngineEvent::MessageComplete { .. }
2564 | EngineEvent::ThinkingStarted { .. }
2565 | EngineEvent::ThinkingDelta { .. }
2566 | EngineEvent::ThinkingComplete { .. }
2567 | EngineEvent::ToolCallStarted { .. }
2568 | EngineEvent::ToolCallHeartbeat
2569 | EngineEvent::ToolCallComplete { .. }
2570 | EngineEvent::ApprovalRequired { .. }
2571 | EngineEvent::UserInputRequired { .. }
2572 | EngineEvent::ElevationRequired { .. }
2573 | EngineEvent::SessionUpdated { .. }
2574 )
2575 }
2576
2577 fn ignore_stale_stream_event_while_idle(event: &EngineEvent) -> bool {
2578 matches!(
2579 event,
2580 EngineEvent::MessageStarted { .. }
2581 | EngineEvent::MessageDelta { .. }
2582 | EngineEvent::MessageComplete { .. }
2583 | EngineEvent::ThinkingStarted { .. }
2584 | EngineEvent::ThinkingDelta { .. }
2585 | EngineEvent::ThinkingComplete { .. }
2586 | EngineEvent::ToolCallStarted { .. }
2587 | EngineEvent::ToolCallHeartbeat
2588 | EngineEvent::ToolCallComplete { .. }
2589 | EngineEvent::ApprovalRequired { .. }
2590 | EngineEvent::UserInputRequired { .. }
2591 | EngineEvent::ElevationRequired { .. }
2592 )
2593 }
2594
2595 type ProviderKeyVerification<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
2596
2597 pub(crate) trait ProviderKeyVerifier {
2598 fn verify<'a>(
2599 &'a self,
2600 provider: ApiProvider,
2601 api_key: &'a str,
2602 base_url: &'a str,
2603 ) -> ProviderKeyVerification<'a>;
2604 }
2605
2606 struct LiveProviderKeyVerifier;
2607
2608 impl ProviderKeyVerifier for LiveProviderKeyVerifier {
2609 fn verify<'a>(
2610 &'a self,
2611 provider: ApiProvider,
2612 api_key: &'a str,
2613 base_url: &'a str,
2614 ) -> ProviderKeyVerification<'a> {
2615 Box::pin(crate::client::verify_provider_api_key(
2616 provider, api_key, base_url,
2617 ))
2618 }
2619 }
2620
2621 pub(crate) fn request_foreground_shell_background(app: &mut App) {
2622 if !app.is_loading {
2623 app.status_message = Some("No foreground shell wait to move to /jobs".to_string());
2624 return;
2625 }
2626 if !active_foreground_shell_running(app) {
2627 // #3032 AC3: name the reason backgrounding is unavailable —
2628 // interactive execs and non-shell blocking tools are visibly running
2629 // but cannot be detached, and a generic shrug reads like a bug.
2630 let reason = if terminal_pause_has_live_owner(app) {
2631 "the running command is interactive"
2632 } else if app
2633 .active_cell
2634 .as_ref()
2635 .is_some_and(|active| !active.is_empty())
2636 {
2637 "the running tool is not a foreground shell command"
2638 } else {
2639 "no foreground shell command is running"
2640 };
2641 app.status_message = Some(format!(
2642 "Cannot move to /jobs: {reason}. Press Ctrl+C to cancel the turn, or wait for completion."
2643 ));
2644 return;
2645 }
2646
2647 match request_active_foreground_shell_background(app) {
2648 Ok(()) => {
2649 app.status_message = Some("Moving current shell command to /jobs...".to_string());
2650 }
2651 Err(err) => {
2652 app.status_message = Some(err.to_string());
2653 }
2654 }
2655 }
2656
2657 fn request_active_foreground_shell_background(app: &App) -> Result<()> {
2658 let shell_manager = app
2659 .runtime_services
2660 .shell_manager
2661 .clone()
2662 .context("No shell session is active.")?;
2663 let mut manager = shell_manager.lock().map_err(|_| {
2664 anyhow::anyhow!("Shell tracking hit an internal error — restart Codewhale to recover.")
2665 })?;
2666 manager.request_foreground_background();
2667 Ok(())
2668 }
2669
2670 pub(crate) fn prefill_jobs_cancel_all_if_tasks_sidebar(app: &mut App) -> bool {
2671 if !app.view_stack.is_empty()
2672 || app.work_surface.panel != crate::tui::work_surface::RailPanel::Tasks
2673 || app.work_surface.last_area.is_none()
2674 || !app
2675 .task_panel
2676 .iter()
2677 .any(|task| task.id.starts_with("shell_") && task.status == "running")
2678 {
2679 return false;
2680 }
2681
2682 app.input = "/jobs cancel-all".to_string();
2683 app.cursor_position = app.input.len();
2684 app.status_message = Some("Press Enter to cancel all running commands".to_string());
2685 true
2686 }
2687
2688 pub(crate) fn active_foreground_shell_running(app: &App) -> bool {
2689 app.active_cell.as_ref().is_some_and(|active| {
2690 active.entries().iter().any(|cell| {
2691 matches!(
2692 cell,
2693 HistoryCell::Tool(ToolCell::Exec(exec))
2694 if exec.status == ToolStatus::Running
2695 && exec.interaction.is_none()
2696 && exec.shell_task_id.is_none()
2697 )
2698 })
2699 })
2700 }
2701
2702 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2703 pub(crate) enum SearchDirection {
2704 Forward,
2705 Backward,
2706 }
2707
2708 #[cfg(test)]
2709 fn maybe_warn_context_pressure(app: &mut App) {
2710 let config = app.compaction_config();
2711 maybe_warn_context_pressure_for_config(app, &config);
2712 }
2713
2714 fn maybe_warn_context_pressure_for_config(
2715 app: &mut App,
2716 config: &crate::compaction::CompactionConfig,
2717 ) {
2718 let max = config.effective_context_window.unwrap_or_else(|| {
2719 crate::route_budget::route_context_window_tokens(
2720 app.api_provider,
2721 app.effective_model_for_budget(),
2722 app.active_route_limits,
2723 )
2724 });
2725 let Some((used, max, percent)) = context_usage_snapshot_for_window(app, max) else {
2726 return;
2727 };
2728
2729 let configured_threshold = app.auto_compact_threshold_percent.clamp(10.0, 100.0);
2730 let warning_threshold = CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT.min(configured_threshold);
2731 let will_auto_compact = config.enabled && used.max(0) as usize >= config.token_threshold;
2732 if percent < warning_threshold && !will_auto_compact {
2733 return;
2734 }
2735
2736 let recommendation = if !config.enabled {
2737 "Consider enabling auto_compact or use /compact."
2738 } else if will_auto_compact {
2739 "Auto-compaction will run before the next send."
2740 } else {
2741 "Auto-compaction is enabled."
2742 };
2743
2744 if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
2745 app.status_message = Some(format!(
2746 "Context critical: {percent:.0}% ({used}/{max} tokens). {recommendation}"
2747 ));
2748 return;
2749 }
2750
2751 if app.status_message.is_none() {
2752 let status_prefix = if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
2753 "Context high"
2754 } else {
2755 "Context building"
2756 };
2757 app.status_message = Some(format!(
2758 "{status_prefix}: {percent:.0}% ({used}/{max} tokens). {recommendation}"
2759 ));
2760 }
2761 }
2762
2763 #[cfg(test)]
2764 fn should_auto_compact_before_send(app: &App) -> bool {
2765 let config = app.compaction_config();
2766 should_auto_compact_before_send_with_config(app, &config)
2767 }
2768
2769 #[cfg(test)]
2770 fn should_auto_compact_before_send_with_config(
2771 app: &App,
2772 config: &crate::compaction::CompactionConfig,
2773 ) -> bool {
2774 if !config.enabled {
2775 return false;
2776 }
2777 // Use the same ceiling-anchored token threshold as the engine. Comparing
2778 // against a raw percentage of the input-plus-output window can delay this
2779 // gate until after the spendable input budget has already been exhausted.
2780 let max = config.effective_context_window.unwrap_or_else(|| {
2781 crate::route_budget::route_context_window_tokens(
2782 app.api_provider,
2783 app.effective_model_for_budget(),
2784 app.active_route_limits,
2785 )
2786 });
2787 context_usage_snapshot_for_window(app, max)
2788 .map(|(used, _, _)| used.max(0) as usize >= config.token_threshold)
2789 .unwrap_or(false)
2790 }
2791
2792 fn clamp_event_poll_timeout(timeout: Duration) -> Duration {
2793 const MIN_EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(1);
2794 timeout.max(MIN_EVENT_POLL_TIMEOUT)
2795 }
2796
2797 /// Decide whether an `AgentComplete` event should fire a subagent-completion
2798 /// desktop notification, per the `[notifications].subagent_completion` mode.
2799 /// `settings()` still has the final say (method=off / condition=never).
2800 fn should_notify_subagent_completion(
2801 mode: crate::config::SubagentCompletionNotification,
2802 has_other_running_subagents: bool,
2803 workflow_tool_running: bool,
2804 ) -> bool {
2805 use crate::config::SubagentCompletionNotification as Mode;
2806 match mode {
2807 Mode::Off => false,
2808 Mode::Always => true,
2809 Mode::FinalOnly => !has_other_running_subagents && !workflow_tool_running,
2810 }
2811 }
2812
2813 // Keyboard-shortcut predicates moved to `tui/key_shortcuts.rs`.
2814
2815 #[derive(Debug, Clone, PartialEq, Eq)]
2816 pub(crate) enum StartupVersionCheckSource {
2817 Disabled,
2818 ConfiguredUrl(String),
2819 ReleaseResolver,
2820 }
2821
2822 /// A newer-stable-release notice, carrying enough context to render both the
2823 /// short transient toast and the durable in-transcript update prompt (#3961).
2824 #[derive(Debug, Clone, PartialEq, Eq)]
2825 pub(crate) struct UpdateNotice {
2826 current: String,
2827 latest: String,
2828 }
2829
2830 impl UpdateNotice {
2831 /// Short line for the transient status toast, naming the command that
2832 /// actually updates *this* install.
2833 fn toast_line(&self, install: InstallMethod) -> String {
2834 format!(
2835 "v{latest} available - run `{command}` and restart",
2836 latest = self.latest,
2837 command = install.update_command()
2838 )
2839 }
2840
2841 /// Compact header chip label shown once the check has landed. Quiet by
2842 /// design: no action verb, no repetition — the toast and transcript
2843 /// notice carry the update instructions (#14).
2844 fn chip_label(&self) -> String {
2845 format!("↑ v{latest}", latest = self.latest)
2846 }
2847
2848 /// Durable, actionable notice pushed into the transcript so it survives the
2849 /// toast TTL. Includes current/latest versions, release notes, the exact
2850 /// update command, and restart guidance.
2851 ///
2852 /// Package-managed installs get their manager's command instead of
2853 /// `codewhale update`, plus an explicit warning: self-updating a binary
2854 /// Homebrew or npm owns leaves the manager's metadata lying about what is
2855 /// on disk, and the next upgrade silently reverts the user.
2856 fn notice_block(&self, install: InstallMethod) -> String {
2857 let action = if install.supports_self_update() {
2858 "Run `codewhale update` (preview with `codewhale update --check`), then restart CodeWhale."
2859 .to_string()
2860 } else {
2861 format!(
2862 "Installed via {label}. Run `{command}`, then restart CodeWhale.\n\
2863 Do not use `codewhale update` here — it would replace a binary {label} manages.",
2864 label = install.label(),
2865 command = install.update_command()
2866 )
2867 };
2868 format!(
2869 "Update available: v{current} -> v{latest}\n\
2870 Release notes: https://github.com/Hmbown/CodeWhale/releases/tag/v{latest}\n\
2871 {action}",
2872 current = self.current,
2873 latest = self.latest
2874 )
2875 }
2876 }
2877
2878 mod activity_detail;
2879
2880 #[cfg(test)]
2881 mod provider_key_validation_tests {
2882 use super::*;
2883 use crate::core::engine::mock_engine_handle;
2884 use ratatui::{buffer::Buffer, layout::Rect};
2885 use std::ffi::OsString;
2886 use tempfile::TempDir;
2887
2888 struct ConfigPathEnvGuard {
2889 _tmp: TempDir,
2890 previous: Option<OsString>,
2891 _lock: crate::test_support::TestEnvLock,
2892 }
2893
2894 impl ConfigPathEnvGuard {
2895 fn new() -> Self {
2896 let lock = crate::test_support::lock_test_env();
2897 let tmp = TempDir::new().expect("config tempdir");
2898 let config_path = tmp.path().join(".codewhale").join("config.toml");
2899 std::fs::create_dir_all(config_path.parent().expect("config parent"))
2900 .expect("config dir");
2901 let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH");
2902 // Safety: test-only environment mutation guarded by a global mutex.
2903 unsafe {
2904 std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path);
2905 }
2906 Self {
2907 _tmp: tmp,
2908 previous,
2909 _lock: lock,
2910 }
2911 }
2912
2913 fn config_path(&self) -> PathBuf {
2914 std::env::var_os("DEEPSEEK_CONFIG_PATH")
2915 .map(PathBuf::from)
2916 .expect("config path set")
2917 }
2918 }
2919
2920 impl Drop for ConfigPathEnvGuard {
2921 fn drop(&mut self) {
2922 // Safety: test-only environment mutation guarded by a global mutex.
2923 unsafe {
2924 if let Some(previous) = self.previous.take() {
2925 std::env::set_var("DEEPSEEK_CONFIG_PATH", previous);
2926 } else {
2927 std::env::remove_var("DEEPSEEK_CONFIG_PATH");
2928 }
2929 }
2930 }
2931 }
2932
2933 fn create_test_app() -> App {
2934 let options = TuiOptions {
2935 start_in_agent_mode: true,
2936 skip_onboarding: false,
2937 ..crate::test_support::test_tui_options(PathBuf::from("."))
2938 };
2939 let mut app = App::new(options, &Config::default());
2940 app.api_provider = ApiProvider::Deepseek;
2941 app.model = "deepseek-v4-pro".to_string();
2942 app.auto_model = false;
2943 app
2944 }
2945
2946 #[test]
2947 fn api_key_live_mirror_revokes_stale_external_credential_consent() {
2948 let external_path = if cfg!(windows) {
2949 PathBuf::from(r"C:\Users\test\grok-auth.json")
2950 } else {
2951 PathBuf::from("/tmp/grok-auth.json")
2952 };
2953 let mut config = Config {
2954 providers: Some(ProvidersConfig {
2955 xai: ProviderConfig {
2956 auth_mode: Some("oauth".to_string()),
2957 external_credentials: Some(
2958 codewhale_config::ExternalCredentialConsentToml::read_only(
2959 codewhale_config::ProviderKind::Xai,
2960 codewhale_config::ExternalCredentialSource::GrokCli,
2961 external_path,
2962 ),
2963 ),
2964 ..Default::default()
2965 },
2966 ..Default::default()
2967 }),
2968 ..Default::default()
2969 };
2970
2971 mirror_saved_api_key_in_config(
2972 &mut config,
2973 ApiProvider::Xai,
2974 "codewhale-owned-api-key".to_string(),
2975 );
2976
2977 let xai = config
2978 .provider_config_for(ApiProvider::Xai)
2979 .expect("xAI live config");
2980 assert_eq!(xai.auth_mode.as_deref(), Some("api_key"));
2981 assert_eq!(xai.api_key.as_deref(), Some("codewhale-owned-api-key"));
2982 assert!(xai.external_credentials.is_none());
2983 }
2984
2985 struct MockProviderKeyVerifier {
2986 result: Result<(), String>,
2987 calls: std::sync::Mutex<Vec<(ApiProvider, String, String)>>,
2988 }
2989
2990 impl MockProviderKeyVerifier {
2991 fn new(result: Result<(), String>) -> Self {
2992 Self {
2993 result,
2994 calls: std::sync::Mutex::new(Vec::new()),
2995 }
2996 }
2997
2998 fn calls(&self) -> Vec<(ApiProvider, String, String)> {
2999 self.calls.lock().expect("calls lock").clone()
3000 }
3001 }
3002
3003 impl ProviderKeyVerifier for MockProviderKeyVerifier {
3004 fn verify<'a>(
3005 &'a self,
3006 provider: ApiProvider,
3007 api_key: &'a str,
3008 base_url: &'a str,
3009 ) -> ProviderKeyVerification<'a> {
3010 self.calls.lock().expect("calls lock").push((
3011 provider,
3012 api_key.to_string(),
3013 base_url.to_string(),
3014 ));
3015 Box::pin(std::future::ready(self.result.clone()))
3016 }
3017 }
3018
3019 fn openrouter_config(base_url: &str) -> Config {
3020 Config {
3021 providers: Some(ProvidersConfig {
3022 openrouter: ProviderConfig {
3023 base_url: Some(base_url.to_string()),
3024 ..ProviderConfig::default()
3025 },
3026 ..ProvidersConfig::default()
3027 }),
3028 ..Config::default()
3029 }
3030 }
3031
3032 fn two_named_custom_routes() -> Config {
3033 Config {
3034 provider: Some("custom-a".to_string()),
3035 providers: Some(ProvidersConfig {
3036 custom: std::collections::HashMap::from([
3037 (
3038 "custom-a".to_string(),
3039 ProviderConfig {
3040 kind: Some("openai-compatible".to_string()),
3041 base_url: Some("http://127.0.0.1:18181/v1".to_string()),
3042 model: Some("model-a".to_string()),
3043 api_key: Some("key-a".to_string()),
3044 ..Default::default()
3045 },
3046 ),
3047 (
3048 "custom-b".to_string(),
3049 ProviderConfig {
3050 kind: Some("openai-compatible".to_string()),
3051 base_url: Some("http://127.0.0.1:18182/v1".to_string()),
3052 model: Some("model-b".to_string()),
3053 ..Default::default()
3054 },
3055 ),
3056 ]),
3057 ..Default::default()
3058 }),
3059 ..Default::default()
3060 }
3061 }
3062
3063 #[test]
3064 fn provider_key_check_classifies_transport_failures_truthfully() {
3065 assert_eq!(
3066 provider_verification_error_category("connection refused"),
3067 crate::error_taxonomy::ErrorCategory::Network
3068 );
3069 assert_eq!(
3070 provider_verification_error_category("request timed out"),
3071 crate::error_taxonomy::ErrorCategory::Timeout
3072 );
3073 assert_eq!(
3074 provider_verification_error_category("HTTP 429 rate limit"),
3075 crate::error_taxonomy::ErrorCategory::RateLimit
3076 );
3077 assert_eq!(
3078 provider_verification_error_category("HTTP 401 unauthorized"),
3079 crate::error_taxonomy::ErrorCategory::Authentication
3080 );
3081 assert_eq!(
3082 provider_verification_error_category("HTTP 403 forbidden"),
3083 crate::error_taxonomy::ErrorCategory::Authorization
3084 );
3085 assert_eq!(
3086 provider_verification_error_category("HTTP 500 upstream failure"),
3087 crate::error_taxonomy::ErrorCategory::Network
3088 );
3089 }
3090
3091 #[tokio::test]
3092 async fn provider_key_submit_opens_model_pick_without_persisting_on_validation_success() {
3093 let config_env = ConfigPathEnvGuard::new();
3094 let mut app = create_test_app();
3095 let mut engine = mock_engine_handle();
3096 let mut config = openrouter_config("https://mock.openrouter.test/v1");
3097 let verifier = MockProviderKeyVerifier::new(Ok(()));
3098 let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None)
3099 .expect("OpenRouter identity");
3100
3101 apply_provider_picker_api_key_with_verifier(
3102 &mut app,
3103 &mut engine.handle,
3104 &mut config,
3105 identity,
3106 "sk-verified".to_string(),
3107 None,
3108 &verifier,
3109 )
3110 .await;
3111
3112 assert_eq!(
3113 verifier.calls(),
3114 vec![(
3115 ApiProvider::Openrouter,
3116 "sk-verified".to_string(),
3117 "https://mock.openrouter.test/v1".to_string()
3118 )]
3119 );
3120 // Validation success must not persist or switch yet (#3875 residual):
3121 // the guided flow continues at model pick first.
3122 assert_eq!(app.api_provider, ApiProvider::Deepseek);
3123 assert_eq!(config.provider.as_deref(), None);
3124 assert_eq!(
3125 config
3126 .providers
3127 .as_ref()
3128 .and_then(|providers| providers.openrouter.api_key.as_deref()),
3129 None
3130 );
3131 let saved = std::fs::read_to_string(config_env.config_path()).unwrap_or_default();
3132 assert!(!saved.contains("sk-verified"));
3133 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
3134 assert!(
3135 app.status_message
3136 .as_deref()
3137 .is_some_and(|status| status.contains("API key verified")),
3138 "status names verification success: {:?}",
3139 app.status_message
3140 );
3141
3142 let picker = app.view_stack.pop().expect("provider picker reopened");
3143 let area = Rect::new(0, 0, 90, 16);
3144 let mut buf = Buffer::empty(area);
3145 picker.render(area, &mut buf);
3146 let rendered = (0..area.height)
3147 .map(|y| {
3148 (0..area.width)
3149 .map(|x| buf[(x, y)].symbol())
3150 .collect::<String>()
3151 })
3152 .collect::<Vec<_>>()
3153 .join("\n");
3154 assert!(
3155 rendered.contains("Default model") || rendered.contains("Pick a default model"),
3156 "expected model-pick stage UI, got:\n{rendered}"
3157 );
3158 }
3159
3160 /// #4526: the wizard's StepFun billing-route choice must be the endpoint
3161 /// the key is probed against, and it must reach disk only once the user
3162 /// confirms — never as a side effect of validation.
3163 #[tokio::test]
3164 async fn stepfun_plan_route_is_validated_before_the_key_is_persisted() {
3165 let config_env = ConfigPathEnvGuard::new();
3166 let mut app = create_test_app();
3167 let mut engine = mock_engine_handle();
3168 let mut config = Config::default();
3169 let verifier = MockProviderKeyVerifier::new(Ok(()));
3170 let identity = picker_provider_identity(&config, ApiProvider::Stepfun, None)
3171 .expect("StepFun identity");
3172
3173 apply_provider_picker_api_key_with_verifier(
3174 &mut app,
3175 &mut engine.handle,
3176 &mut config,
3177 identity,
3178 "step-plan-key".to_string(),
3179 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()),
3180 &verifier,
3181 )
3182 .await;
3183
3184 assert_eq!(
3185 verifier.calls(),
3186 vec![(
3187 ApiProvider::Stepfun,
3188 "step-plan-key".to_string(),
3189 crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()
3190 )],
3191 "the chosen Step Plan endpoint must be the one live-validated"
3192 );
3193 assert_eq!(
3194 config
3195 .providers
3196 .as_ref()
3197 .and_then(|providers| providers.stepfun.base_url.clone()),
3198 None,
3199 "validation must not mutate the live config"
3200 );
3201 let saved = std::fs::read_to_string(config_env.config_path()).unwrap_or_default();
3202 assert!(
3203 !saved.contains("step_plan"),
3204 "nothing persisted yet: {saved}"
3205 );
3206 assert!(!saved.contains("step-plan-key"), "no secret yet: {saved}");
3207 }
3208
3209 /// The confirm stage writes the endpoint into `[providers.stepfun]` and
3210 /// leaves every other provider table alone.
3211 #[tokio::test]
3212 async fn stepfun_setup_confirm_writes_only_the_stepfun_base_url() {
3213 let config_env = ConfigPathEnvGuard::new();
3214 let mut app = create_test_app();
3215 let mut engine = mock_engine_handle();
3216 let mut config = Config::default();
3217 let identity = picker_provider_identity(&config, ApiProvider::Stepfun, None)
3218 .expect("StepFun identity");
3219
3220 apply_provider_picker_setup_confirmed(
3221 &mut app,
3222 &mut engine.handle,
3223 &mut config,
3224 identity,
3225 "step-plan-key".to_string(),
3226 crate::config::DEFAULT_STEPFUN_MODEL.to_string(),
3227 None,
3228 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()),
3229 )
3230 .await;
3231
3232 let saved = std::fs::read_to_string(config_env.config_path()).expect("config written");
3233 let document: toml::Table = toml::from_str(&saved).expect("valid TOML");
3234 let providers = document
3235 .get("providers")
3236 .and_then(toml::Value::as_table)
3237 .expect("providers table");
3238 assert_eq!(
3239 providers
3240 .get("stepfun")
3241 .and_then(|entry| entry.get("base_url"))
3242 .and_then(toml::Value::as_str),
3243 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
3244 );
3245 assert_eq!(
3246 providers.keys().collect::<Vec<_>>(),
3247 vec!["stepfun"],
3248 "the route choice must not touch other provider tables"
3249 );
3250 assert!(
3251 document.get("base_url").is_none(),
3252 "the root base_url must stay untouched: {saved}"
3253 );
3254 assert_eq!(
3255 config
3256 .providers
3257 .as_ref()
3258 .and_then(|providers| providers.stepfun.base_url.as_deref()),
3259 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL),
3260 "the live config mirrors the persisted endpoint"
3261 );
3262 }
3263
3264 #[tokio::test]
3265 async fn replacing_legacy_kimi_import_verifies_and_persists_the_kimi_code_api_key_route() {
3266 let config_env = ConfigPathEnvGuard::new();
3267 std::fs::write(
3268 config_env.config_path(),
3269 r#"# preserve-kimi-comment
3270 [providers.moonshot]
3271 auth_mode = "kimi_oauth"
3272 "#,
3273 )
3274 .expect("seed legacy Kimi import config");
3275 let mut app = create_test_app();
3276 let mut engine = mock_engine_handle();
3277 let mut config = Config {
3278 providers: Some(ProvidersConfig {
3279 moonshot: ProviderConfig {
3280 auth_mode: Some("kimi_oauth".to_string()),
3281 ..ProviderConfig::default()
3282 },
3283 ..ProvidersConfig::default()
3284 }),
3285 ..Config::default()
3286 };
3287 let identity = picker_provider_identity(&config, ApiProvider::Moonshot, None)
3288 .expect("Moonshot identity");
3289 let verifier = MockProviderKeyVerifier::new(Ok(()));
3290
3291 apply_provider_picker_api_key_with_verifier(
3292 &mut app,
3293 &mut engine.handle,
3294 &mut config,
3295 identity.clone(),
3296 "sk-kimi-supported".to_string(),
3297 None,
3298 &verifier,
3299 )
3300 .await;
3301
3302 assert_eq!(
3303 verifier.calls(),
3304 vec![(
3305 ApiProvider::Moonshot,
3306 "sk-kimi-supported".to_string(),
3307 crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string(),
3308 )],
3309 "replacement keys must be verified against Kimi Code, not the ordinary Moonshot API"
3310 );
3311
3312 apply_provider_picker_setup_confirmed(
3313 &mut app,
3314 &mut engine.handle,
3315 &mut config,
3316 identity,
3317 "sk-kimi-supported".to_string(),
3318 crate::config::DEFAULT_KIMI_CODE_MODEL.to_string(),
3319 None,
3320 None,
3321 )
3322 .await;
3323
3324 let moonshot = config
3325 .providers
3326 .as_ref()
3327 .map(|providers| &providers.moonshot)
3328 .expect("in-memory Moonshot config");
3329 assert_eq!(moonshot.auth_mode.as_deref(), Some("api_key"));
3330 assert_eq!(
3331 moonshot.base_url.as_deref(),
3332 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)
3333 );
3334 assert_eq!(moonshot.api_key.as_deref(), Some("sk-kimi-supported"));
3335
3336 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
3337 assert!(saved.contains("# preserve-kimi-comment"));
3338 assert!(saved.contains("auth_mode = \"api_key\""));
3339 assert!(saved.contains(&format!(
3340 "base_url = \"{}\"",
3341 crate::config::DEFAULT_KIMI_CODE_BASE_URL
3342 )));
3343 }
3344
3345 #[tokio::test]
3346 async fn provider_setup_confirm_persists_provider_model_and_preserves_comments() {
3347 let config_env = ConfigPathEnvGuard::new();
3348 // Seed a commented config so the confirm path must preserve it.
3349 std::fs::write(
3350 config_env.config_path(),
3351 r#"# keep-me-comment
3352 [providers.openrouter]
3353 # openrouter-table-comment
3354 base_url = "https://mock.openrouter.test/v1"
3355
3356 [providers.anthropic]
3357 api_key = "fixture-other-provider-key"
3358 "#,
3359 )
3360 .expect("seed config");
3361
3362 let mut app = create_test_app();
3363 let mut engine = mock_engine_handle();
3364 let mut config = openrouter_config("https://mock.openrouter.test/v1");
3365 config
3366 .providers
3367 .get_or_insert_with(ProvidersConfig::default)
3368 .anthropic
3369 .api_key = Some("fixture-other-provider-key".to_string());
3370 let model = "deepseek/deepseek-v4-pro".to_string();
3371 let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None)
3372 .expect("OpenRouter identity");
3373
3374 apply_provider_picker_setup_confirmed(
3375 &mut app,
3376 &mut engine.handle,
3377 &mut config,
3378 identity,
3379 "sk-confirmed".to_string(),
3380 model.clone(),
3381 None,
3382 None,
3383 )
3384 .await;
3385
3386 assert_eq!(app.api_provider, ApiProvider::Openrouter);
3387 assert_eq!(config.provider.as_deref(), Some("openrouter"));
3388 assert_eq!(
3389 config
3390 .providers
3391 .as_ref()
3392 .and_then(|providers| providers.openrouter.api_key.as_deref()),
3393 Some("sk-confirmed")
3394 );
3395 assert_eq!(
3396 config
3397 .providers
3398 .as_ref()
3399 .and_then(|providers| providers.openrouter.model.as_deref()),
3400 Some(model.as_str())
3401 );
3402 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
3403 assert!(
3404 saved.contains("# keep-me-comment"),
3405 "root comment lost:\n{saved}"
3406 );
3407 assert!(
3408 saved.contains("# openrouter-table-comment"),
3409 "table comment lost:\n{saved}"
3410 );
3411 assert!(saved.contains("[providers.openrouter]"));
3412 assert!(saved.contains("api_key = \"sk-confirmed\""));
3413 assert!(saved.contains(&format!("model = \"{model}\"")));
3414 assert!(saved.contains("[providers.anthropic]"));
3415 assert!(saved.contains("api_key = \"fixture-other-provider-key\""));
3416 assert_eq!(
3417 config
3418 .providers
3419 .as_ref()
3420 .and_then(|providers| providers.anthropic.api_key.as_deref()),
3421 Some("fixture-other-provider-key"),
3422 "saving OpenRouter must not overwrite a different provider slot"
3423 );
3424 }
3425
3426 #[tokio::test]
3427 async fn provider_key_submit_reopens_picker_without_persisting_on_validation_failure() {
3428 let config_env = ConfigPathEnvGuard::new();
3429 let mut app = create_test_app();
3430 let mut engine = mock_engine_handle();
3431 let mut config = openrouter_config("https://mock.openrouter.test/v1");
3432 let verifier = MockProviderKeyVerifier::new(Err("HTTP 401: unauthorized".to_string()));
3433 let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None)
3434 .expect("OpenRouter identity");
3435
3436 apply_provider_picker_api_key_with_verifier(
3437 &mut app,
3438 &mut engine.handle,
3439 &mut config,
3440 identity,
3441 "sk-rejected".to_string(),
3442 None,
3443 &verifier,
3444 )
3445 .await;
3446
3447 assert_eq!(app.api_provider, ApiProvider::Deepseek);
3448 assert_eq!(config.provider.as_deref(), None);
3449 assert_eq!(
3450 config
3451 .providers
3452 .as_ref()
3453 .and_then(|providers| providers.openrouter.api_key.as_deref()),
3454 None
3455 );
3456 let saved = std::fs::read_to_string(config_env.config_path()).unwrap_or_default();
3457 assert!(!saved.contains("sk-rejected"));
3458 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker));
3459 assert!(
3460 app.status_message
3461 .as_deref()
3462 .is_some_and(|status| status.contains("API key verification failed")),
3463 "status names validation failure: {:?}",
3464 app.status_message
3465 );
3466
3467 let picker = app.view_stack.pop().expect("provider picker reopened");
3468 let area = Rect::new(0, 0, 90, 14);
3469 let mut buf = Buffer::empty(area);
3470 picker.render(area, &mut buf);
3471 let rendered = (0..area.height)
3472 .map(|y| {
3473 (0..area.width)
3474 .map(|x| buf[(x, y)].symbol())
3475 .collect::<String>()
3476 })
3477 .collect::<Vec<_>>()
3478 .join("\n");
3479 assert!(rendered.contains("Verification failed: HTTP 401: unauthorized"));
3480 }
3481
3482 #[tokio::test]
3483 async fn named_custom_verification_failure_and_dismiss_keep_committed_a_route() {
3484 let _config_env = ConfigPathEnvGuard::new();
3485 let mut app = create_test_app();
3486 app.set_provider_identity(ApiProvider::Custom, "custom-a");
3487 app.set_model_selection("model-a".to_string());
3488 let mut engine = mock_engine_handle();
3489 let mut config = two_named_custom_routes();
3490 let identity = picker_provider_identity(&config, ApiProvider::Custom, Some("custom-b"))
3491 .expect("custom B identity");
3492 let verifier = MockProviderKeyVerifier::new(Err("HTTP 401: unauthorized".to_string()));
3493
3494 apply_provider_picker_api_key_with_verifier(
3495 &mut app,
3496 &mut engine.handle,
3497 &mut config,
3498 identity,
3499 "rejected-b-key".to_string(),
3500 None,
3501 &verifier,
3502 )
3503 .await;
3504
3505 assert_eq!(config.provider.as_deref(), Some("custom-a"));
3506 assert_eq!(app.provider_identity_for_persistence(), "custom-a");
3507 app.view_stack.pop().expect("failed verifier picker");
3508 sync_config_provider_from_app(&mut config, &app);
3509 let route = validated_app_runtime_route(&app, &config).expect("committed A route");
3510 assert_eq!(route.identity.key, "custom-a");
3511 assert_eq!(route.client.base_url(), "http://127.0.0.1:18181/v1");
3512 }
3513
3514 #[tokio::test]
3515 async fn named_custom_setup_persists_exact_provider_table_and_model() {
3516 let config_env = ConfigPathEnvGuard::new();
3517 std::fs::write(
3518 config_env.config_path(),
3519 r#"provider = "custom-a"
3520
3521 [providers.custom-a]
3522 kind = "openai-compatible"
3523 base_url = "http://127.0.0.1:18181/v1"
3524 model = "model-a"
3525
3526 [providers.custom-b]
3527 kind = "openai-compatible"
3528 base_url = "http://127.0.0.1:18182/v1"
3529 model = "model-b"
3530 "#,
3531 )
3532 .expect("seed named custom config");
3533 let mut app = create_test_app();
3534 app.set_provider_identity(ApiProvider::Custom, "custom-a");
3535 app.set_model_selection("model-a".to_string());
3536 let mut engine = mock_engine_handle();
3537 let mut config = two_named_custom_routes();
3538 let identity = picker_provider_identity(&config, ApiProvider::Custom, Some("custom-b"))
3539 .expect("custom B identity");
3540
3541 apply_provider_picker_setup_confirmed(
3542 &mut app,
3543 &mut engine.handle,
3544 &mut config,
3545 identity,
3546 "saved-b-key".to_string(),
3547 "model-b-confirmed".to_string(),
3548 None,
3549 None,
3550 )
3551 .await;
3552
3553 assert_eq!(app.provider_identity_for_persistence(), "custom-b");
3554 assert_eq!(config.provider.as_deref(), Some("custom-b"));
3555 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
3556 assert!(saved.contains("[providers.custom-b]"));
3557 assert!(saved.contains("api_key = \"saved-b-key\""));
3558 assert!(saved.contains("model = \"model-b-confirmed\""));
3559 assert!(!saved.contains("[providers.custom]\n"));
3560 }
3561
3562 #[test]
3563 fn legacy_literal_custom_identity_persistence_stays_root_shaped() {
3564 let config_env = ConfigPathEnvGuard::new();
3565 std::fs::write(
3566 config_env.config_path(),
3567 r#"provider = "custom"
3568 base_url = "http://127.0.0.1:18180/v1"
3569 default_text_model = "legacy-model"
3570 "#,
3571 )
3572 .expect("seed legacy root route");
3573 let config = Config {
3574 provider: Some("custom".to_string()),
3575 base_url: Some("http://127.0.0.1:18180/v1".to_string()),
3576 default_text_model: Some("legacy-model".to_string()),
3577 ..Default::default()
3578 };
3579 let identity = config
3580 .resolve_provider_identity("custom")
3581 .expect("legacy identity");
3582
3583 crate::config::save_api_key_for_identity(&identity, &config, "legacy-saved-key")
3584 .expect("save legacy key");
3585 crate::config::save_provider_model_for_identity(&identity, &config, "legacy-model-updated")
3586 .expect("save legacy model");
3587
3588 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
3589 assert!(saved.contains("api_key = \"legacy-saved-key\""));
3590 assert!(saved.contains("default_text_model = \"legacy-model-updated\""));
3591 assert!(!saved.contains("[providers.custom]"));
3592 let reloaded = Config::load(Some(config_env.config_path()), None).expect("reload legacy");
3593 assert!(reloaded.uses_legacy_literal_custom_route());
3594 assert_eq!(
3595 reloaded
3596 .resolve_provider_identity("custom")
3597 .expect("repeat legacy identity"),
3598 identity
3599 );
3600 let route =
3601 resolve_runtime_route(&reloaded, ApiProvider::Custom, Some("legacy-model-updated"))
3602 .expect("resolve reloaded legacy")
3603 .validate()
3604 .expect("preflight reloaded legacy");
3605 assert_eq!(route.client.base_url(), "http://127.0.0.1:18180/v1");
3606 }
3607
3608 #[test]
3609 fn legacy_active_route_does_not_redirect_named_custom_persistence_to_root() {
3610 let config_env = ConfigPathEnvGuard::new();
3611 std::fs::write(
3612 config_env.config_path(),
3613 r#"provider = "custom"
3614 api_key = "legacy-root-key"
3615 base_url = "http://127.0.0.1:18180/v1"
3616 default_text_model = "legacy-model"
3617
3618 [providers.custom-b]
3619 kind = "openai-compatible"
3620 base_url = "http://127.0.0.1:18182/v1"
3621 model = "model-b"
3622 "#,
3623 )
3624 .expect("seed coexistence config");
3625 let config = Config::load(Some(config_env.config_path()), None).expect("load config");
3626 assert!(config.uses_legacy_literal_custom_route());
3627 let identity = config
3628 .resolve_provider_identity("custom-b")
3629 .expect("named custom identity");
3630
3631 crate::config::save_api_key_for_identity(&identity, &config, "saved-b-key")
3632 .expect("save named custom key");
3633 crate::config::save_provider_model_for_identity(&identity, &config, "model-b-updated")
3634 .expect("save named custom model");
3635
3636 let saved = std::fs::read_to_string(config_env.config_path()).expect("saved config");
3637 assert!(saved.contains("api_key = \"legacy-root-key\""));
3638 assert!(saved.contains("default_text_model = \"legacy-model\""));
3639 assert!(saved.contains("[providers.custom-b]"));
3640 assert!(saved.contains("api_key = \"saved-b-key\""));
3641 assert!(saved.contains("model = \"model-b-updated\""));
3642 }
3643 }
3644
3645 /// Build the foreground receipt only from the immutable route captured when
3646 /// this turn started. The app's selected route may already have changed by the
3647 /// time `TurnComplete` is handled, so it is not accepted as an input here.
3648 fn completed_turn_cost_route_receipt(
3649 completed_turn: Option<&crate::tui::app::ActiveTurnMetadata>,
3650 audit: &crate::pricing::TurnCostAudit,
3651 ) -> Option<String> {
3652 let route = completed_turn?.route.as_ref()?;
3653 Some(route.cost_envelope()?.receipt(audit))
3654 }
3655
3656 #[cfg(test)]
3657 mod tests;
3658
3658 lines RUST