返回 DeepSeek-TUI-2026
ui.rs
根目录 / crates / tui / src / tui / ui.rs
1 //! TUI event loop and rendering logic for `DeepSeek` CLI.
2
3 use std::collections::HashSet;
4 use std::io::{self, Stdout};
5 use std::path::{Path, PathBuf};
6 use std::process::Command;
7 use std::time::{Duration, Instant};
8
9 use anyhow::Result;
10 use crossterm::{
11 event::{
12 self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
13 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
14 MouseEventKind, PopKeyboardEnhancementFlags,
15 },
16 execute,
17 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
18 };
19 use ratatui::{
20 Frame, Terminal,
21 layout::{Constraint, Direction, Layout, Rect},
22 prelude::Widget,
23 style::{Color, Style},
24 text::Span,
25 widgets::Block,
26 };
27 use tracing;
28 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
29
30 use crate::audit::log_sensitive_event;
31 use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler};
32 use crate::client::DeepSeekClient;
33 use crate::commands;
34 use crate::compaction::estimate_input_tokens_conservative;
35 use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL};
36 use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent};
37 use crate::core::coherence::CoherenceState;
38 use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
39 use crate::core::events::Event as EngineEvent;
40 use crate::core::ops::Op;
41 use crate::hooks::HookEvent;
42 use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model};
43 use crate::palette;
44 use crate::prompts;
45 use crate::session_manager::{
46 OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager,
47 create_saved_session_with_mode, update_session,
48 };
49 use crate::task_manager::{
50 NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus,
51 };
52 use crate::tools::spec::RuntimeToolServices;
53 use crate::tools::subagent::SubAgentStatus;
54 use crate::tui::color_compat::ColorCompatBackend;
55 use crate::tui::command_palette::{
56 CommandPaletteView, build_entries as build_command_palette_entries,
57 };
58 use crate::tui::context_inspector::build_context_inspector_text;
59 use crate::tui::context_menu::{ContextMenuEntry, ContextMenuView};
60 use crate::tui::event_broker::EventBroker;
61 use crate::tui::live_transcript::LiveTranscriptOverlay;
62 use crate::tui::mcp_routing::{add_mcp_message, open_mcp_manager_pager};
63 use crate::tui::onboarding;
64 use crate::tui::pager::PagerView;
65 use crate::tui::persistence_actor::{self, PersistRequest};
66 use crate::tui::plan_prompt::PlanPromptView;
67 use crate::tui::scrolling::{ScrollDirection, TranscriptScroll};
68 use crate::tui::selection::TranscriptSelectionPoint;
69 use crate::tui::session_picker::SessionPickerView;
70 use crate::tui::shell_job_routing::{
71 add_shell_job_message, format_shell_job_list, format_shell_poll, open_shell_job_pager,
72 };
73 use crate::tui::subagent_routing::{
74 active_fanout_counts, format_task_list, handle_subagent_mailbox, open_task_pager,
75 reconcile_subagent_activity_state, running_agent_count, sort_subagents_in_place,
76 task_mode_label, task_summary_to_panel_entry,
77 };
78 #[cfg(test)]
79 use crate::tui::tool_routing::exploring_label;
80 use crate::tui::tool_routing::{
81 handle_tool_call_complete, handle_tool_call_started, maybe_add_patch_preview,
82 };
83 use crate::tui::ui_text::{history_cell_to_text, line_to_plain, slice_text, text_display_width};
84 use crate::tui::user_input::UserInputView;
85
86 use super::active_cell::ActiveCell;
87 use super::app::{
88 App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus,
89 StatusToastLevel, SubmitDisposition, TaskPanelEntry, ToolDetailRecord, TuiOptions,
90 };
91 use super::approval::{
92 ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
93 };
94 use super::history::{
95 HistoryCell, ToolCell, ToolStatus, history_cells_from_message, summarize_tool_output,
96 };
97 use super::slash_menu::{
98 apply_slash_menu_selection, try_autocomplete_slash_command, visible_slash_menu_entries,
99 };
100 use super::views::{
101 ConfigView, ContextMenuAction, HelpView, ModalKind, ShellControlView, ViewEvent,
102 };
103 use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview};
104 use super::widgets::{
105 ChatWidget, ComposerWidget, FooterProps, FooterToast, FooterWidget, HeaderData, HeaderWidget,
106 Renderable,
107 };
108
109 // === Constants ===
110
111 /// Upper bound on slash-menu entries returned to the renderer. The composer's
112 /// render path already paginates with center-tracking (see
113 /// `widgets::ComposerWidget::render`), so this only needs to be high enough to
114 /// encompass the full filtered command list — never the visible-row budget.
115 /// Bumped from 6 to 128 to fix #64 (selection couldn't reach commands beyond
116 /// the visible window because the source list itself was capped).
117 const SLASH_MENU_LIMIT: usize = 128;
118 const MENTION_MENU_LIMIT: usize = 6;
119 const MIN_CHAT_HEIGHT: u16 = 3;
120 const MIN_COMPOSER_HEIGHT: u16 = 2;
121 const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
122 const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
123 const UI_IDLE_POLL_MS: u64 = 48;
124 const UI_ACTIVE_POLL_MS: u64 = 24;
125 const WEB_CONFIG_POLL_MS: u64 = 16;
126 // Forced repaint cadence while a turn is live (model loading, compacting,
127 // sub-agents running). Drives the footer water-spout animation as well as
128 // the per-tool spinner pulse — keep this fast enough that the spout reads as
129 // motion (~12 fps) instead of teleport-frames.
130 const UI_STATUS_ANIMATION_MS: u64 = 80;
131 const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15;
132 const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100;
133 const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500;
134
135 type AppTerminal = Terminal<ColorCompatBackend<Stdout>>;
136
137 /// Run the interactive TUI event loop.
138 ///
139 /// # Examples
140 ///
141 /// ```ignore
142 /// # use crate::config::Config;
143 /// # use crate::tui::TuiOptions;
144 /// # async fn example(config: &Config, options: TuiOptions) -> anyhow::Result<()> {
145 /// crate::tui::run_tui(config, options).await
146 /// # }
147 /// ```
148 pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
149 let use_alt_screen = options.use_alt_screen;
150 let use_mouse_capture = options.use_mouse_capture;
151 let use_bracketed_paste = options.use_bracketed_paste;
152
153 // Apply OSC 8 hyperlink toggle from config.
154 //
155 // Default-off on Windows because legacy `cmd.exe` and pre-Win11
156 // PowerShell consoles don't always honor the OSC 8 string
157 // terminator (`ESC \`) cleanly — emitting the escape can leave
158 // stray bytes that eat the leading column of the next line and
159 // duplicate the composer panel during scroll. Reported on a
160 // Windows session (issue forthcoming, screenshot showed
161 // "eepseek-v4-flash" with the leading `d` consumed and three
162 // overlapping composer panels). v0.8.8 also surfaced macOS
163 // corruption ("526sOPEN" instead of "526 OPEN") because OSC 8
164 // wrappers are emitted inside ratatui `Span` content; ratatui's
165 // grapheme filter drops the bare ESC byte but paints every other
166 // byte of the wrapper into a buffer cell, drifting columns. Until
167 // OSC 8 is emitted out-of-band of the buffer pipeline, default off
168 // on every platform; opt back in via `[ui] osc8_links = true`.
169 let osc8_default_on = false;
170 crate::tui::osc8::set_enabled(
171 config
172 .tui
173 .as_ref()
174 .and_then(|tui| tui.osc8_links)
175 .unwrap_or(osc8_default_on),
176 );
177
178 // Terminal probe with timeout to prevent hanging on unresponsive terminals
179 let probe_timeout = terminal_probe_timeout(config);
180 let enable_raw = tokio::task::spawn_blocking(move || {
181 enable_raw_mode().map_err(|e| anyhow::anyhow!("Failed to enable raw mode: {}", e))
182 });
183
184 match tokio::time::timeout(probe_timeout, enable_raw).await {
185 Ok(inner_result) => {
186 inner_result??; // propagate both join and raw-mode errors
187 }
188 Err(_) => {
189 tracing::warn!(
190 "Terminal probe timed out after {}ms - terminal may be unresponsive",
191 probe_timeout.as_millis()
192 );
193 return Err(anyhow::anyhow!(
194 "Terminal probe timed out after {}ms",
195 probe_timeout.as_millis()
196 ));
197 }
198 }
199
200 let mut stdout = io::stdout();
201 if use_alt_screen {
202 execute!(stdout, EnterAlternateScreen)?;
203 }
204 if use_mouse_capture {
205 execute!(stdout, EnableMouseCapture)?;
206 }
207 if use_bracketed_paste {
208 execute!(stdout, EnableBracketedPaste)?;
209 }
210 // #442: opt into the Kitty keyboard protocol's escape-code
211 // disambiguation so terminals that support it (Kitty, Ghostty,
212 // Alacritty 0.13+, WezTerm, recent Konsole, recent xterm) report
213 // unambiguous events for Option/Alt-modified keys, plain Esc, and
214 // multi-byte sequences. Terminals that don't recognise the escape
215 // silently discard it; behaviour is identical to today on legacy
216 // terminals (iTerm2, Terminal.app, Windows 10 conhost).
217 //
218 // Only `DISAMBIGUATE_ESCAPE_CODES` is pushed — the higher tiers
219 // (`REPORT_EVENT_TYPES`, `REPORT_ALL_KEYS_AS_ESCAPE_CODES`) emit
220 // release events that the existing key handlers would mis-route
221 // as duplicate presses. Best-effort: failure to push is logged
222 // and ignored so a quirky terminal can't block startup.
223 if let Err(err) = execute!(
224 stdout,
225 crossterm::event::PushKeyboardEnhancementFlags(
226 crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
227 )
228 ) {
229 tracing::debug!(
230 target: "kitty_keyboard",
231 ?err,
232 "PushKeyboardEnhancementFlags ignored (terminal lacks support)"
233 );
234 }
235 let color_depth = palette::ColorDepth::detect();
236 tracing::debug!(?color_depth, "terminal color depth detected");
237 let backend = ColorCompatBackend::new(stdout, color_depth);
238 let mut terminal = Terminal::new(backend)?;
239 terminal.clear()?;
240 let event_broker = EventBroker::new();
241
242 // Local mutable copy so runtime config flips (e.g. `/provider` switch)
243 // can rebuild the API client without restarting the process.
244 let mut config = config.clone();
245 let config = &mut config;
246 let mut app = App::new(options.clone(), config);
247
248 // Load existing session if resuming.
249 if let Some(ref session_id) = options.resume_session_id
250 && let Ok(manager) = SessionManager::default_location()
251 {
252 // Try to load by prefix or full ID
253 let load_result: std::io::Result<Option<crate::session_manager::SavedSession>> =
254 if session_id == "latest" {
255 // Special case: resume the most recent session in this workspace.
256 match manager.get_latest_session_for_workspace(&options.workspace) {
257 Ok(Some(meta)) => manager.load_session(&meta.id).map(Some),
258 Ok(None) => Ok(None),
259 Err(e) => Err(e),
260 }
261 } else {
262 manager.load_session_by_prefix(session_id).map(Some)
263 };
264
265 match load_result {
266 Ok(Some(saved)) => {
267 app.api_messages.clone_from(&saved.messages);
268 app.model.clone_from(&saved.metadata.model);
269 app.update_model_compaction_budget();
270 app.workspace.clone_from(&saved.metadata.workspace);
271 app.current_session_id = Some(saved.metadata.id.clone());
272 app.session.total_tokens =
273 u32::try_from(saved.metadata.total_tokens).unwrap_or(u32::MAX);
274 app.session.total_conversation_tokens = app.session.total_tokens;
275 app.session.last_prompt_tokens = None;
276 app.session.last_completion_tokens = None;
277 app.session.last_prompt_cache_hit_tokens = None;
278 app.session.last_prompt_cache_miss_tokens = None;
279 app.session.last_reasoning_replay_tokens = None;
280 if let Some(prompt) = saved.system_prompt {
281 app.system_prompt = Some(SystemPrompt::Text(prompt));
282 }
283 // Convert saved messages to HistoryCell format for display
284 app.clear_history();
285 app.push_history_cell(HistoryCell::System {
286 content: format!(
287 "Resumed session: {} ({})",
288 saved.metadata.title,
289 crate::session_manager::truncate_id(&saved.metadata.id),
290 ),
291 });
292
293 for msg in &saved.messages {
294 app.extend_history(history_cells_from_message(msg));
295 }
296 app.mark_history_updated();
297 app.status_message = Some(format!(
298 "Resumed session: {}",
299 crate::session_manager::truncate_id(&saved.metadata.id)
300 ));
301 }
302 Ok(None) => {
303 app.status_message = Some("No sessions found to resume".to_string());
304 }
305 Err(e) => {
306 app.status_message = Some(format!("Failed to load session: {e}"));
307 }
308 }
309 }
310
311 if let Ok(manager) = SessionManager::default_location() {
312 match manager.load_offline_queue_state() {
313 Ok(Some(state)) => {
314 // Only restore queue if session_id matches (or if we're resuming the same session)
315 let should_restore = match (&state.session_id, &app.current_session_id) {
316 (Some(saved_id), Some(current_id)) => saved_id == current_id,
317 (None, _) => false, // Legacy unscoped queues are stale-risky; fail closed.
318 (_, None) => false, // No current session - don't restore
319 };
320
321 if should_restore {
322 app.queued_messages = state
323 .messages
324 .into_iter()
325 .map(queued_session_to_ui)
326 .collect();
327 app.queued_draft = state.draft.map(queued_session_to_ui);
328 if app.status_message.is_none() && app.queued_message_count() > 0 {
329 app.status_message = Some(format!(
330 "Restored {} queued message(s) from previous session — ↑ to edit, Ctrl+X to discard",
331 app.queued_message_count()
332 ));
333 }
334 } else {
335 // Session mismatch - clear the stale queue
336 let _ = manager.clear_offline_queue_state();
337 }
338 }
339 Ok(None) => {}
340 Err(err) => {
341 if app.status_message.is_none() {
342 app.status_message = Some(format!("Failed to restore offline queue: {err}"));
343 }
344 }
345 }
346 }
347
348 let task_manager = TaskManager::start(
349 TaskManagerConfig::from_runtime(
350 config,
351 app.workspace.clone(),
352 Some(app.model.clone()),
353 Some(app.max_subagents.clamp(1, 4)),
354 ),
355 config.clone(),
356 )
357 .await?;
358 let automations = std::sync::Arc::new(tokio::sync::Mutex::new(
359 AutomationManager::default_location()?,
360 ));
361 let automation_cancel = tokio_util::sync::CancellationToken::new();
362 let automation_scheduler = spawn_scheduler(
363 automations.clone(),
364 task_manager.clone(),
365 automation_cancel.clone(),
366 AutomationSchedulerConfig::default(),
367 );
368 let shell_manager = app
369 .runtime_services
370 .shell_manager
371 .clone()
372 .unwrap_or_else(|| crate::tools::shell::new_shared_shell_manager(app.workspace.clone()));
373 app.runtime_services = RuntimeToolServices {
374 shell_manager: Some(shell_manager),
375 task_manager: Some(task_manager.clone()),
376 automations: Some(automations),
377 task_data_dir: Some(task_manager.data_dir()),
378 active_task_id: None,
379 active_thread_id: None,
380 // #456: plumb the App's HookExecutor so `exec_shell` can surface
381 // the configured `shell_env` hooks. Wrapped in Arc once and shared.
382 hook_executor: Some(std::sync::Arc::new(app.hooks.clone())),
383 };
384 refresh_active_task_panel(&mut app, &task_manager).await;
385
386 let engine_config = build_engine_config(&app, config);
387
388 // Spawn the Engine - it will handle all API communication
389 let engine_handle = spawn_engine(engine_config, config);
390
391 if !app.api_messages.is_empty() {
392 let _ = engine_handle
393 .send(Op::SyncSession {
394 messages: app.api_messages.clone(),
395 system_prompt: app.system_prompt.clone(),
396 model: app.model.clone(),
397 workspace: app.workspace.clone(),
398 })
399 .await;
400 }
401
402 // Fire session start hook
403 {
404 let context = app.base_hook_context();
405 let _ = app.execute_hooks(HookEvent::SessionStart, &context);
406 }
407
408 // Spawn the persistence actor so checkpoint/session-save I/O stays off
409 // the UI thread. The actor serialises + writes to disk in a dedicated
410 // task; the UI just `try_send`s a request and returns immediately.
411 if let Ok(persist_manager) = SessionManager::default_location() {
412 let handle = persistence_actor::spawn_persistence_actor(persist_manager);
413 persistence_actor::init_actor(handle);
414 }
415
416 let result = run_event_loop(
417 &mut terminal,
418 &mut app,
419 config,
420 engine_handle,
421 task_manager,
422 &event_broker,
423 )
424 .await;
425 automation_cancel.cancel();
426 automation_scheduler.abort();
427
428 // Fire session end hook
429 {
430 let context = app.base_hook_context();
431 let _ = app.execute_hooks(HookEvent::SessionEnd, &context);
432 }
433
434 // Flush the persistence actor: clear checkpoint + graceful shutdown.
435 persistence_actor::persist(PersistRequest::ClearCheckpoint);
436 persistence_actor::persist(PersistRequest::Shutdown);
437
438 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
439 disable_raw_mode()?;
440 if use_alt_screen {
441 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
442 }
443 if use_mouse_capture {
444 execute!(terminal.backend_mut(), DisableMouseCapture)?;
445 }
446 if use_bracketed_paste {
447 execute!(terminal.backend_mut(), DisableBracketedPaste)?;
448 }
449 terminal.show_cursor()?;
450 drop(terminal);
451
452 if result.is_ok()
453 && let Some(hint) = format_resume_hint(app.current_session_id.as_deref())
454 {
455 println!("{hint}");
456 }
457
458 result
459 }
460
461 fn format_resume_hint(session_id: Option<&str>) -> Option<String> {
462 let session_id = session_id?.trim();
463 if session_id.is_empty() {
464 return None;
465 }
466 Some(format!(
467 "To continue this session, run deepseek resume {session_id}"
468 ))
469 }
470
471 fn terminal_probe_timeout(config: &Config) -> Duration {
472 let timeout_ms = config
473 .tui
474 .as_ref()
475 .and_then(|tui| tui.terminal_probe_timeout_ms)
476 .unwrap_or(DEFAULT_TERMINAL_PROBE_TIMEOUT_MS)
477 .clamp(100, 5_000);
478 Duration::from_millis(timeout_ms)
479 }
480
481 /// Recognise composer input that is a `# foo` memory quick-add (#492).
482 ///
483 /// Returns `true` for inputs that:
484 /// - start with `#`,
485 /// - have at least one non-whitespace character after the leading `#`,
486 /// - are a single line (no embedded `\n`), and
487 /// - are not a shebang (`#!`) or Markdown heading (`## …`, `### …`).
488 ///
489 /// Multi-`#` prefixes are deliberately rejected so users can paste
490 /// Markdown headings into the composer without triggering the quick-add.
491 #[must_use]
492 fn is_memory_quick_add(input: &str) -> bool {
493 let trimmed = input.trim_start();
494 if !trimmed.starts_with('#') {
495 return false;
496 }
497 if trimmed.starts_with("##") || trimmed.starts_with("#!") {
498 return false;
499 }
500 if input.contains('\n') {
501 return false;
502 }
503 // Require something after the `#`.
504 !trimmed.trim_start_matches('#').trim().is_empty()
505 }
506
507 /// Persist a `# foo` quick-add to the memory file and surface a status
508 /// note to the user. Errors land in the same status channel so a missing
509 /// memory directory becomes visible without crashing the composer.
510 fn handle_memory_quick_add(app: &mut App, input: &str, config: &Config) {
511 let path = config.memory_path();
512 match crate::memory::append_entry(&path, input) {
513 Ok(()) => {
514 app.status_message = Some(format!("memory: appended to {}", path.display()));
515 }
516 Err(err) => {
517 app.status_message = Some(format!(
518 "memory: failed to write {}: {}",
519 path.display(),
520 err
521 ));
522 }
523 }
524 }
525
526 fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
527 EngineConfig {
528 model: app.model.clone(),
529 workspace: app.workspace.clone(),
530 allow_shell: app.allow_shell,
531 trust_mode: app.trust_mode,
532 notes_path: config.notes_path(),
533 mcp_config_path: config.mcp_config_path(),
534 skills_dir: app.skills_dir.clone(),
535 instructions: config.instructions_paths(),
536 // Effectively unlimited. V4 has a 1M context window and the user
537 // wants the model running until it's actually done. The previous cap
538 // of 100 hit the ceiling on long multi-step plans (wide refactors,
539 // sub-agent orchestration) and presented as the agent "giving up
540 // mid-task". `u32::MAX` is the type ceiling; users can still
541 // interrupt with Ctrl+C / Esc, and a turn naturally ends when the
542 // model stops emitting tool calls. A real runaway is rare and
543 // human-noticeable; we trust the operator over a hard step cap.
544 max_steps: u32::MAX,
545 max_subagents: app.max_subagents,
546 features: config.features(),
547 compaction: app.compaction_config(),
548 cycle: app.cycle_config(),
549 capacity: crate::core::capacity::CapacityControllerConfig::from_app_config(config),
550 todos: app.todos.clone(),
551 plan_state: app.plan_state.clone(),
552 max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
553 network_policy: config.network.clone().map(|toml_cfg| {
554 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
555 }),
556 snapshots_enabled: config.snapshots_config().enabled,
557 lsp_config: config
558 .lsp
559 .clone()
560 .map(crate::config::LspConfigToml::into_runtime),
561 runtime_services: app.runtime_services.clone(),
562 subagent_model_overrides: config.subagent_model_overrides(),
563 memory_enabled: config.memory_enabled(),
564 memory_path: config.memory_path(),
565 strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
566 goal_objective: app.goal.goal_objective.clone(),
567 locale_tag: app.ui_locale.tag().to_string(),
568 workshop: config.workshop.clone(),
569 }
570 }
571
572 async fn refresh_active_task_panel(app: &mut App, task_manager: &SharedTaskManager) {
573 let tasks = task_manager.list_tasks(None).await;
574 let mut entries: Vec<TaskPanelEntry> = tasks
575 .into_iter()
576 .filter(|task| matches!(task.status, TaskStatus::Queued | TaskStatus::Running))
577 .map(task_summary_to_panel_entry)
578 .collect();
579
580 if let Some(shell_mgr) = app.runtime_services.shell_manager.as_ref()
581 && let Ok(mut mgr) = shell_mgr.lock()
582 {
583 for job in mgr.list_jobs() {
584 if !matches!(job.status, crate::tools::shell::ShellStatus::Running) {
585 continue;
586 }
587 entries.push(TaskPanelEntry {
588 id: job.id,
589 status: "running".to_string(),
590 prompt_summary: format!("shell: {}", job.command),
591 duration_ms: Some(job.elapsed_ms),
592 });
593 }
594 }
595
596 app.task_panel = entries;
597 }
598
599 #[allow(clippy::too_many_lines)]
600 async fn run_event_loop(
601 terminal: &mut AppTerminal,
602 app: &mut App,
603 config: &mut Config,
604 mut engine_handle: EngineHandle,
605 task_manager: SharedTaskManager,
606 event_broker: &EventBroker,
607 ) -> Result<()> {
608 // Track streaming state
609 let mut current_streaming_text = String::new();
610 let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone());
611 let mut last_task_refresh = Instant::now()
612 .checked_sub(Duration::from_secs(2))
613 .unwrap_or_else(Instant::now);
614 let mut last_status_frame = Instant::now()
615 .checked_sub(Duration::from_millis(UI_STATUS_ANIMATION_MS))
616 .unwrap_or_else(Instant::now);
617 // 120 FPS draw cap. Without this we redraw on every SSE chunk during a
618 // long stream — wasted work the user can't perceive. See
619 // `tui::frame_rate_limiter` for the rationale; ports the small piece of
620 // codex's frame coalescing that maps cleanly onto our poll-based loop.
621 let mut frame_rate_limiter = crate::tui::frame_rate_limiter::FrameRateLimiter::default();
622 let mut web_config_session: Option<WebConfigSession> = None;
623 // #376: native-copy escape — hold Shift to bypass alt-screen mouse capture
624 // for terminal-native text selection.
625 let mut shift_bypass_active = false;
626
627 loop {
628 if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await {
629 web_config_session = None;
630 }
631
632 if last_task_refresh.elapsed() >= Duration::from_millis(2500) {
633 refresh_active_task_panel(app, &task_manager).await;
634 last_task_refresh = Instant::now();
635 app.needs_redraw = true;
636 }
637
638 // First, poll for engine events (non-blocking)
639 let mut received_engine_event = false;
640 let mut transcript_batch_updated = false;
641 let mut queued_to_send: Option<QueuedMessage> = None;
642 {
643 let mut rx = engine_handle.rx_event.write().await;
644 while let Ok(event) = rx.try_recv() {
645 received_engine_event = true;
646 match event {
647 EngineEvent::MessageStarted { .. } => {
648 // Assistant text starting after parallel tool work
649 // means the tool group is done. Flush the active
650 // cell first so the message lands BELOW the
651 // committed tool group (Codex pattern: streamed
652 // assistant content always flows after work).
653 app.flush_active_cell();
654 current_streaming_text.clear();
655 app.streaming_state.reset();
656 app.streaming_state.start_text(0, None);
657 app.streaming_message_index = None;
658 }
659 EngineEvent::MessageDelta { content, .. } => {
660 let sanitized = sanitize_stream_chunk(&content);
661 if sanitized.is_empty() {
662 continue;
663 }
664 // First delta of a fresh stream has no streaming
665 // cell yet; flush active so the tool group settles
666 // before the assistant prose appears below it.
667 if app.streaming_message_index.is_none() {
668 app.flush_active_cell();
669 }
670 current_streaming_text.push_str(&sanitized);
671 let index = ensure_streaming_assistant_history_cell(app);
672 app.streaming_state.push_content(0, &sanitized);
673 let committed = app.streaming_state.commit_text(0);
674 if !committed.is_empty() {
675 append_streaming_text(app, index, &committed);
676 transcript_batch_updated = true;
677 }
678 }
679 EngineEvent::MessageComplete { .. } => {
680 if let Some(index) = app.streaming_message_index.take() {
681 let remaining = app.streaming_state.finalize_block_text(0);
682 if !remaining.is_empty() {
683 append_streaming_text(app, index, &remaining);
684 }
685 if let Some(HistoryCell::Assistant { streaming, .. }) =
686 app.history.get_mut(index)
687 {
688 *streaming = false;
689 }
690 // Streaming flag flipped — the cell's compact /
691 // transcript variants render slightly
692 // differently, so bump its revision so the cache
693 // refreshes this row only.
694 app.bump_history_cell(index);
695 transcript_batch_updated = true;
696 }
697
698 let mut blocks = Vec::new();
699 let thinking = app.last_reasoning.take();
700 if let Some(thinking) = thinking {
701 blocks.push(ContentBlock::Thinking { thinking });
702 }
703 if !current_streaming_text.is_empty() {
704 blocks.push(ContentBlock::Text {
705 text: current_streaming_text.clone(),
706 cache_control: None,
707 });
708 }
709 for (id, name, input) in app.pending_tool_uses.drain(..) {
710 blocks.push(ContentBlock::ToolUse {
711 id,
712 name,
713 input,
714 caller: None,
715 });
716 }
717
718 // DeepSeek rejects assistant messages that contain only reasoning blocks.
719 // Keep reasoning in transcript cells, but only persist assistant turns that
720 // include visible text and/or tool calls.
721 let has_sendable_content = blocks.iter().any(|block| {
722 matches!(
723 block,
724 ContentBlock::Text { .. } | ContentBlock::ToolUse { .. }
725 )
726 });
727 if has_sendable_content {
728 app.api_messages.push(Message {
729 role: "assistant".to_string(),
730 content: blocks,
731 });
732 }
733 }
734 EngineEvent::ThinkingStarted { .. } => {
735 // P2.3: thinking lives in the active cell so it groups
736 // visually with the tool calls that follow until the
737 // next assistant prose chunk flushes the group.
738 app.reasoning_buffer.clear();
739 app.reasoning_header = None;
740 app.thinking_started_at = Some(Instant::now());
741 app.streaming_state.reset();
742 app.streaming_state.start_thinking(0, None);
743 let _ = ensure_streaming_thinking_active_entry(app);
744 }
745 EngineEvent::ThinkingDelta { content, .. } => {
746 let sanitized = sanitize_stream_chunk(&content);
747 if sanitized.is_empty() {
748 continue;
749 }
750 app.reasoning_buffer.push_str(&sanitized);
751 if app.reasoning_header.is_none() {
752 app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer);
753 }
754
755 let entry_idx = ensure_streaming_thinking_active_entry(app);
756 app.streaming_state.push_content(0, &sanitized);
757 let committed = app.streaming_state.commit_text(0);
758 if !committed.is_empty() {
759 append_streaming_thinking(app, entry_idx, &committed);
760 transcript_batch_updated = true;
761 }
762 }
763 EngineEvent::ThinkingComplete { .. } => {
764 let duration = app
765 .thinking_started_at
766 .take()
767 .map(|t| t.elapsed().as_secs_f32());
768 let remaining = app.streaming_state.finalize_block_text(0);
769 if finalize_streaming_thinking_active_entry(app, duration, &remaining) {
770 transcript_batch_updated = true;
771 }
772
773 if !app.reasoning_buffer.is_empty() {
774 app.last_reasoning = Some(app.reasoning_buffer.clone());
775 }
776 app.reasoning_buffer.clear();
777 }
778 EngineEvent::ToolCallStarted { id, name, input } => {
779 app.pending_tool_uses
780 .push((id.clone(), name.clone(), input.clone()));
781 // Note this dispatch so the next sub-agent `Started`
782 // mailbox envelope routes into the right card kind
783 // (delegate vs fanout).
784 if matches!(name.as_str(), "agent_spawn" | "rlm" | "delegate") {
785 app.pending_subagent_dispatch = Some(name.clone());
786 if name == "rlm" {
787 // New fanout invocation — children should
788 // group under a fresh card, not the
789 // previous fanout's leftover.
790 app.last_fanout_card_index = None;
791 }
792 }
793 handle_tool_call_started(app, &id, &name, &input);
794 }
795 EngineEvent::ToolCallComplete { id, name, result } => {
796 if name == "update_plan" {
797 app.plan_tool_used_in_turn = true;
798 }
799 let tool_content = match &result {
800 Ok(output) => sanitize_stream_chunk(
801 &crate::core::engine::compact_tool_result_for_context(
802 &app.model, &name, output,
803 ),
804 ),
805 Err(err) => sanitize_stream_chunk(&format!("Error: {err}")),
806 };
807 app.api_messages.push(Message {
808 role: "user".to_string(),
809 content: vec![ContentBlock::ToolResult {
810 tool_use_id: id.clone(),
811 content: tool_content,
812 is_error: None,
813 content_blocks: None,
814 }],
815 });
816 handle_tool_call_complete(app, &id, &name, &result);
817
818 // Immediately refresh the task panel sidebar when a
819 // tool that changes task state completes, so the
820 // Tasks panel stays in sync with tool execution
821 // rather than waiting up to 2.5 s for the periodic
822 // poll. Also merge shell jobs (#373).
823 if matches!(
824 name.as_str(),
825 "agent_spawn"
826 | "agent_cancel"
827 | "todo_write"
828 | "task_shell_start"
829 | "exec_shell"
830 ) {
831 refresh_active_task_panel(app, &task_manager).await;
832 last_task_refresh = Instant::now();
833 }
834 if matches!(
835 name.as_str(),
836 "agent_spawn"
837 | "agent_cancel"
838 | "agent_wait"
839 | "agent_result"
840 | "agent_status"
841 ) {
842 let _ = engine_handle.send(Op::ListSubAgents).await;
843 }
844 }
845 EngineEvent::TurnStarted { turn_id } => {
846 app.is_loading = true;
847 app.offline_mode = false;
848 current_streaming_text.clear();
849 app.streaming_state.reset();
850 app.streaming_message_index = None;
851 app.streaming_thinking_active_entry = None;
852 app.turn_started_at = Some(Instant::now());
853 app.runtime_turn_id = Some(turn_id);
854 app.runtime_turn_status = Some("in_progress".to_string());
855 app.reasoning_buffer.clear();
856 app.reasoning_header = None;
857 app.last_reasoning = None;
858 app.pending_tool_uses.clear();
859 app.plan_tool_used_in_turn = false;
860 last_status_frame = Instant::now();
861 }
862 EngineEvent::TurnComplete {
863 usage,
864 status,
865 error,
866 } => {
867 // Finalize any in-flight tool group. Cancellation
868 // marks still-running entries as Failed so the user
869 // sees they were interrupted rather than the spinner
870 // hanging forever.
871 if matches!(
872 status,
873 crate::core::events::TurnOutcomeStatus::Interrupted
874 | crate::core::events::TurnOutcomeStatus::Failed
875 ) {
876 app.finalize_active_cell_as_interrupted();
877 // Also mark the streaming Assistant cell (if any)
878 // so partial reasoning/text isn't left with a
879 // permanent spinner. Idempotent with the
880 // optimistic call in the Esc handler.
881 app.finalize_streaming_assistant_as_interrupted();
882 } else {
883 app.flush_active_cell();
884 }
885 app.is_loading = false;
886 app.offline_mode = false;
887 app.streaming_state.reset();
888 // Capture elapsed before clearing turn_started_at so
889 // notifications can use the real wall-clock duration.
890 let turn_elapsed =
891 app.turn_started_at.map(|t| t.elapsed()).unwrap_or_default();
892 app.turn_started_at = None;
893 // Roll the just-finished turn's elapsed time into the
894 // cumulative session work-time (#448 follow-up). The
895 // footer's `worked Nh Mm` chip reads this so the
896 // label reflects actual model work, not idle
897 // uptime since launch.
898 app.cumulative_turn_duration =
899 app.cumulative_turn_duration.saturating_add(turn_elapsed);
900 // Stream lock applies per-turn; clear it so the next
901 // turn's chunks pull the view down again until the
902 // user opts out by scrolling up.
903 app.user_scrolled_during_stream = false;
904 app.runtime_turn_status = Some(match status {
905 crate::core::events::TurnOutcomeStatus::Completed => {
906 "completed".to_string()
907 }
908 crate::core::events::TurnOutcomeStatus::Interrupted => {
909 "interrupted".to_string()
910 }
911 crate::core::events::TurnOutcomeStatus::Failed => "failed".to_string(),
912 });
913 if matches!(
914 status,
915 crate::core::events::TurnOutcomeStatus::Interrupted
916 | crate::core::events::TurnOutcomeStatus::Failed
917 ) {
918 let _ = engine_handle.send(Op::ListSubAgents).await;
919 }
920 let turn_tokens = usage.input_tokens + usage.output_tokens;
921 app.session.total_tokens =
922 app.session.total_tokens.saturating_add(turn_tokens);
923 app.session.total_conversation_tokens = app
924 .session
925 .total_conversation_tokens
926 .saturating_add(turn_tokens);
927 app.session.last_prompt_tokens = Some(usage.input_tokens);
928 app.session.last_completion_tokens = Some(usage.output_tokens);
929 app.session.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens;
930 app.session.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens;
931 app.session.last_reasoning_replay_tokens = usage.reasoning_replay_tokens;
932 app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {
933 input_tokens: usage.input_tokens,
934 output_tokens: usage.output_tokens,
935 cache_hit_tokens: usage.prompt_cache_hit_tokens,
936 cache_miss_tokens: usage.prompt_cache_miss_tokens,
937 reasoning_replay_tokens: usage.reasoning_replay_tokens,
938 recorded_at: Instant::now(),
939 });
940 if let Some(error) = error {
941 app.status_message = Some(format!("Turn failed: {error}"));
942 }
943
944 // Update session cost
945 let pricing_model = if app.auto_model {
946 app.last_effective_model.as_deref().unwrap_or(&app.model)
947 } else {
948 &app.model
949 };
950 let turn_cost = crate::pricing::calculate_turn_cost_estimate_from_usage(
951 pricing_model,
952 &usage,
953 );
954 if let Some(cost) = turn_cost {
955 app.accrue_session_cost_estimate(cost);
956 }
957
958 // Emit OSC 9 / BEL desktop notification for long turns.
959 if status == crate::core::events::TurnOutcomeStatus::Completed
960 && let Some((method, threshold, include_summary)) =
961 notification_settings(config)
962 {
963 let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
964 let msg = completed_turn_notification_message(
965 app,
966 &current_streaming_text,
967 include_summary,
968 turn_elapsed,
969 turn_cost,
970 );
971 crate::tui::notifications::notify_done(
972 method,
973 in_tmux,
974 &msg,
975 threshold,
976 turn_elapsed,
977 );
978 }
979
980 // Auto-save completed turn and clear crash checkpoint.
981 // Offloaded to the persistence actor so the UI
982 // stays responsive.
983 if let Ok(manager) = SessionManager::default_location() {
984 let session = build_session_snapshot(app, &manager);
985 app.current_session_id = Some(session.metadata.id.clone());
986 persistence_actor::persist(PersistRequest::SessionSnapshot(session));
987 }
988 persistence_actor::persist(PersistRequest::ClearCheckpoint);
989
990 if app.mode == AppMode::Plan
991 && app.plan_tool_used_in_turn
992 && !app.plan_prompt_pending
993 && app.queued_message_count() == 0
994 && app.queued_draft.is_none()
995 {
996 app.plan_prompt_pending = true;
997 app.add_message(HistoryCell::System {
998 content: plan_next_step_prompt(),
999 });
1000 if app.view_stack.top_kind() != Some(ModalKind::PlanPrompt) {
1001 app.view_stack.push(PlanPromptView::new());
1002 }
1003 }
1004 app.plan_tool_used_in_turn = false;
1005
1006 // Legacy pending-steer recovery. Current keyboard
1007 // handling keeps Esc as cancel-only, but older saved
1008 // state may still carry pending steers.
1009 if status == crate::core::events::TurnOutcomeStatus::Interrupted
1010 && app.submit_pending_steers_after_interrupt
1011 {
1012 if let Some(merged) = merge_pending_steers(&mut *app) {
1013 queued_to_send = Some(merged);
1014 }
1015 } else if status == crate::core::events::TurnOutcomeStatus::Failed
1016 && !app.pending_steers.is_empty()
1017 {
1018 // Hard-fail recovery: if the engine failed before
1019 // a clean Interrupted landed, demote pending
1020 // steers to the visible queue so they're not
1021 // silently lost. User can /queue to inspect.
1022 for msg in app.drain_pending_steers() {
1023 app.queue_message(msg);
1024 }
1025 }
1026
1027 if queued_to_send.is_none() {
1028 queued_to_send = app.pop_queued_message();
1029 }
1030 }
1031 EngineEvent::Error {
1032 envelope,
1033 recoverable: _,
1034 } => {
1035 apply_engine_error_to_app(app, envelope);
1036 }
1037 EngineEvent::Status { message } => {
1038 app.status_message = Some(message);
1039 }
1040 EngineEvent::SessionUpdated {
1041 messages,
1042 system_prompt,
1043 model,
1044 workspace,
1045 } => {
1046 app.api_messages = messages;
1047 app.system_prompt = system_prompt;
1048 if app.auto_model {
1049 app.last_effective_model = Some(model);
1050 } else {
1051 app.model = model;
1052 app.last_effective_model = None;
1053 }
1054 app.update_model_compaction_budget();
1055 app.workspace = workspace;
1056 if (app.is_loading || app.is_compacting)
1057 && let Ok(manager) = SessionManager::default_location()
1058 {
1059 let session = build_session_snapshot(app, &manager);
1060 persistence_actor::persist(PersistRequest::Checkpoint(session));
1061 }
1062 }
1063 EngineEvent::CompactionStarted { message, .. } => {
1064 app.is_compacting = true;
1065 app.status_message = Some(message);
1066 }
1067 EngineEvent::CompactionCompleted { message, .. } => {
1068 app.is_compacting = false;
1069 app.status_message = Some(message);
1070 }
1071 EngineEvent::CompactionFailed { message, .. } => {
1072 app.is_compacting = false;
1073 app.status_message = Some(message);
1074 }
1075 EngineEvent::CycleAdvanced { from, to, briefing } => {
1076 // Mirror the engine-side counter on the UI app state
1077 // so the sidebar / slash commands stay in sync, and
1078 // record the briefing so `/cycle <n>` can show it.
1079 app.cycle_count = to;
1080 let briefing_tokens = briefing.token_estimate;
1081 app.cycle_briefings.push(briefing);
1082 let separator = format!(
1083 "─── cycle {from} → {to} (briefing: {briefing_tokens} tokens) ───"
1084 );
1085 app.add_message(HistoryCell::System { content: separator });
1086 app.status_message = Some(format!(
1087 "↻ context refreshed (cycle {from} → {to}, briefing: {briefing_tokens} tokens carried)"
1088 ));
1089 }
1090 EngineEvent::CoherenceState { state, .. } => {
1091 app.coherence_state = state;
1092 }
1093 EngineEvent::CapacityDecision { .. } => {
1094 // Telemetry-only event. Surface actual interventions and failures
1095 // instead of replacing the footer with no-op guardrail chatter.
1096 }
1097 EngineEvent::CapacityIntervention {
1098 action,
1099 before_prompt_tokens,
1100 after_prompt_tokens,
1101 ..
1102 } => {
1103 app.status_message = Some(format!(
1104 "Capacity intervention: {action} (~{before_prompt_tokens} -> ~{after_prompt_tokens} tokens)"
1105 ));
1106 }
1107 EngineEvent::CapacityMemoryPersistFailed { action, error, .. } => {
1108 app.status_message = Some(format!(
1109 "Capacity memory persist failed ({action}): {error}"
1110 ));
1111 }
1112 EngineEvent::PauseEvents => {
1113 if !event_broker.is_paused() {
1114 pause_terminal(
1115 terminal,
1116 app.use_alt_screen,
1117 app.use_mouse_capture,
1118 app.use_bracketed_paste,
1119 )?;
1120 event_broker.pause_events();
1121 }
1122 }
1123 EngineEvent::ResumeEvents => {
1124 if event_broker.is_paused() {
1125 resume_terminal(
1126 terminal,
1127 app.use_alt_screen,
1128 app.use_mouse_capture,
1129 app.use_bracketed_paste,
1130 )?;
1131 event_broker.resume_events();
1132 }
1133 }
1134 EngineEvent::AgentSpawned { id, prompt } => {
1135 let prompt_summary = summarize_tool_output(&prompt);
1136 app.agent_progress
1137 .insert(id.clone(), format!("starting: {prompt_summary}"));
1138 if app.agent_activity_started_at.is_none() {
1139 app.agent_activity_started_at = Some(Instant::now());
1140 }
1141 app.status_message =
1142 Some(format!("Sub-agent {id} starting: {prompt_summary}"));
1143 let _ = engine_handle.send(Op::ListSubAgents).await;
1144 }
1145 EngineEvent::AgentProgress { id, status } => {
1146 let display = friendly_subagent_progress(app, &id, &status);
1147 if is_noisy_subagent_progress(&status) {
1148 app.agent_progress
1149 .entry(id.clone())
1150 .or_insert_with(|| display.clone());
1151 } else {
1152 app.agent_progress.insert(id.clone(), display.clone());
1153 }
1154 if app.agent_activity_started_at.is_none() {
1155 app.agent_activity_started_at = Some(Instant::now());
1156 }
1157 app.status_message = Some(format!("Sub-agent {id}: {display}"));
1158 }
1159 EngineEvent::AgentComplete { id, result } => {
1160 app.agent_progress.remove(&id);
1161 app.status_message = Some(format!(
1162 "Sub-agent {id} completed: {}",
1163 summarize_tool_output(&result)
1164 ));
1165 let _ = engine_handle.send(Op::ListSubAgents).await;
1166 }
1167 EngineEvent::AgentList { agents } => {
1168 let mut sorted = agents.clone();
1169 sort_subagents_in_place(&mut sorted);
1170 sorted.retain(|a| !a.from_prior_session);
1171 app.subagent_cache = sorted.clone();
1172 reconcile_subagent_activity_state(app);
1173 if app.view_stack.update_subagents(&sorted) {
1174 app.status_message =
1175 Some(format!("Sub-agents: {} total", sorted.len()));
1176 }
1177 // Individual spawn/complete events already log to history;
1178 // full list available via /agents command.
1179 }
1180 EngineEvent::SubAgentMailbox { seq, message } => {
1181 handle_subagent_mailbox(app, seq, &message);
1182 transcript_batch_updated = true;
1183 }
1184 EngineEvent::ApprovalRequired {
1185 id,
1186 tool_name,
1187 description,
1188 approval_key,
1189 } => {
1190 let session_approved =
1191 app.approval_session_approved.contains(&approval_key)
1192 || app.approval_session_approved.contains(&tool_name);
1193 let session_denied = app.approval_session_denied.contains(&approval_key)
1194 || app.approval_session_denied.contains(&tool_name);
1195 if session_denied {
1196 // The user already said no to this exact tool /
1197 // approval key in this session; auto-deny so the
1198 // model's retry loop doesn't keep re-prompting
1199 // (#360).
1200 log_sensitive_event(
1201 "tool.approval.auto_deny_session",
1202 serde_json::json!({
1203 "tool_name": tool_name,
1204 "approval_key": approval_key,
1205 "session_id": app.current_session_id,
1206 }),
1207 );
1208 let _ = engine_handle.deny_tool_call(id.clone()).await;
1209 } else if session_approved || app.approval_mode == ApprovalMode::Auto {
1210 log_sensitive_event(
1211 "tool.approval.auto_approve",
1212 serde_json::json!({
1213 "tool_name": tool_name,
1214 "approval_key": approval_key,
1215 "session_id": app.current_session_id,
1216 "mode": app.mode.label(),
1217 }),
1218 );
1219 let _ = engine_handle.approve_tool_call(id.clone()).await;
1220 } else if app.approval_mode == ApprovalMode::Never {
1221 log_sensitive_event(
1222 "tool.approval.auto_deny",
1223 serde_json::json!({
1224 "tool_name": tool_name,
1225 "session_id": app.current_session_id,
1226 "mode": app.mode.label(),
1227 }),
1228 );
1229 let _ = engine_handle.deny_tool_call(id.clone()).await;
1230 app.status_message =
1231 Some(format!("Blocked tool '{tool_name}' (approval_mode=never)"));
1232 } else {
1233 let tool_input = app
1234 .pending_tool_uses
1235 .iter()
1236 .find(|(tool_id, _, _)| tool_id == &id)
1237 .map(|(_, _, input)| input.clone())
1238 .unwrap_or_else(|| serde_json::json!({}));
1239
1240 if tool_name == "apply_patch" {
1241 maybe_add_patch_preview(app, &tool_input);
1242 }
1243
1244 // Create approval request and show overlay
1245 let request = ApprovalRequest::new(
1246 &id,
1247 &tool_name,
1248 &description,
1249 &tool_input,
1250 &approval_key,
1251 );
1252 log_sensitive_event(
1253 "tool.approval.prompted",
1254 serde_json::json!({
1255 "tool_name": tool_name,
1256 "description": description,
1257 "session_id": app.current_session_id,
1258 "mode": app.mode.label(),
1259 }),
1260 );
1261 app.view_stack.push(ApprovalView::new(request));
1262 app.status_message = Some(format!(
1263 "Approval required for '{tool_name}': {description}"
1264 ));
1265 }
1266 }
1267 EngineEvent::UserInputRequired { id, request } => {
1268 app.view_stack.push(UserInputView::new(id.clone(), request));
1269 app.status_message = Some(
1270 "Action required: answer the popup with 1-4, arrows, or Enter"
1271 .to_string(),
1272 );
1273 }
1274 EngineEvent::ToolCallProgress { id, output } => {
1275 app.status_message =
1276 Some(format!("Tool {id}: {}", summarize_tool_output(&output)));
1277 }
1278 EngineEvent::ElevationRequired {
1279 tool_id,
1280 tool_name,
1281 command,
1282 denial_reason,
1283 blocked_network,
1284 blocked_write,
1285 } => {
1286 // In YOLO mode, auto-elevate to full access
1287 if app.approval_mode == ApprovalMode::Auto {
1288 log_sensitive_event(
1289 "tool.sandbox.auto_elevate",
1290 serde_json::json!({
1291 "tool_name": tool_name,
1292 "tool_id": tool_id,
1293 "reason": denial_reason,
1294 "session_id": app.current_session_id,
1295 }),
1296 );
1297 app.add_message(HistoryCell::System {
1298 content: format!(
1299 "Sandbox denied {tool_name}: {denial_reason} - auto-elevating to full access"
1300 ),
1301 });
1302 // Auto-elevate to full access (no sandbox)
1303 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
1304 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
1305 } else {
1306 log_sensitive_event(
1307 "tool.sandbox.prompt_elevation",
1308 serde_json::json!({
1309 "tool_name": tool_name,
1310 "tool_id": tool_id,
1311 "reason": denial_reason,
1312 "session_id": app.current_session_id,
1313 }),
1314 );
1315 // Show elevation dialog
1316 let request = ElevationRequest::for_shell(
1317 &tool_id,
1318 command.as_deref().unwrap_or(&tool_name),
1319 &denial_reason,
1320 blocked_network,
1321 blocked_write,
1322 );
1323 app.view_stack.push(ElevationView::new(request));
1324 app.status_message =
1325 Some(format!("Sandbox blocked {tool_name}: {denial_reason}"));
1326 }
1327 }
1328 }
1329 }
1330 }
1331 if let Some(index) = app.streaming_message_index {
1332 let committed = app.streaming_state.commit_text(0);
1333 if !committed.is_empty() {
1334 append_streaming_text(app, index, &committed);
1335 transcript_batch_updated = true;
1336 }
1337 } else if let Some(entry_idx) = app.streaming_thinking_active_entry {
1338 let committed = app.streaming_state.commit_text(0);
1339 if !committed.is_empty() {
1340 append_streaming_thinking(app, entry_idx, &committed);
1341 transcript_batch_updated = true;
1342 }
1343 }
1344 if transcript_batch_updated {
1345 app.mark_history_updated();
1346 }
1347 if received_engine_event {
1348 app.needs_redraw = true;
1349 }
1350
1351 if let Some(next) = queued_to_send {
1352 if let Err(err) = dispatch_user_message(app, config, &engine_handle, next.clone()).await
1353 {
1354 app.queue_message(next);
1355 app.status_message = Some(format!(
1356 "Dispatch failed ({err}); kept {} queued message(s)",
1357 app.queued_message_count()
1358 ));
1359 }
1360
1361 app.needs_redraw = true;
1362 }
1363
1364 let queue_state = (app.queued_messages.clone(), app.queued_draft.clone());
1365 if queue_state != last_queue_state {
1366 persist_offline_queue_state(app);
1367 last_queue_state = queue_state;
1368 app.needs_redraw = true;
1369 }
1370
1371 if !app.view_stack.is_empty() {
1372 let events = app.view_stack.tick();
1373 if !events.is_empty() {
1374 app.needs_redraw = true;
1375 }
1376 if handle_view_events(
1377 terminal,
1378 app,
1379 config,
1380 &task_manager,
1381 &mut engine_handle,
1382 &mut web_config_session,
1383 events,
1384 )
1385 .await?
1386 {
1387 return Ok(());
1388 }
1389 }
1390
1391 let has_running_agents = running_agent_count(app) > 0;
1392 if (app.is_loading || has_running_agents || app.is_compacting)
1393 && last_status_frame.elapsed()
1394 >= Duration::from_millis(status_animation_interval_ms(app))
1395 {
1396 if !app.low_motion && history_has_live_motion(&app.history) {
1397 app.mark_history_updated();
1398 }
1399 app.needs_redraw = true;
1400 last_status_frame = Instant::now();
1401 }
1402
1403 if event_broker.is_paused() {
1404 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1405 continue;
1406 }
1407
1408 let now = Instant::now();
1409 app.flush_paste_burst_if_enabled(now);
1410 app.sync_status_message_to_toasts();
1411 // Drain background-LLM cost (compaction summaries, seam
1412 // recompaction, cycle briefings) accumulated since the last
1413 // tick and fold it into the session-cost counter (#526).
1414 // Background callers populate `cost_status::report`; we sweep
1415 // the pool once per loop iteration so the footer chip matches
1416 // the DeepSeek website's billing.
1417 let pending_bg_cost = crate::cost_status::drain();
1418 if pending_bg_cost.is_positive() {
1419 app.accrue_subagent_cost_estimate(pending_bg_cost);
1420 app.needs_redraw = true;
1421 }
1422 // Expire the "Press Ctrl+C again to quit" prompt silently after its
1423 // window. Triggers a redraw if the prompt was visible.
1424 app.tick_quit_armed();
1425 let allow_workspace_context_refresh =
1426 !app.is_loading && !has_running_agents && !app.is_compacting;
1427 refresh_workspace_context_if_needed(app, now, allow_workspace_context_refresh);
1428
1429 // Draw is gated by the frame-rate limiter (120 FPS cap). When a
1430 // redraw is needed but the limiter says we're inside the cooldown
1431 // window, leave `needs_redraw = true` and shorten the poll timeout
1432 // so the loop wakes up exactly when drawing is allowed.
1433
1434 // Sync low-motion flag into the frame-rate limiter and streaming
1435 // chunking policy. Low-motion mode drops the frame cap to 30 FPS
1436 // and forces Smooth-only chunking so the display stays calm.
1437 frame_rate_limiter.set_low_motion(app.low_motion);
1438 app.streaming_state.set_low_motion(app.low_motion);
1439
1440 let draw_wait = if app.needs_redraw {
1441 frame_rate_limiter.time_until_next_draw(now)
1442 } else {
1443 None
1444 };
1445 if app.needs_redraw && draw_wait.is_none() {
1446 terminal.draw(|f| render(f, app))?; // app is &mut
1447 frame_rate_limiter.mark_emitted(Instant::now());
1448 app.needs_redraw = false;
1449 }
1450
1451 let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting {
1452 Duration::from_millis(active_poll_ms(app))
1453 } else {
1454 Duration::from_millis(idle_poll_ms(app))
1455 };
1456 if let Some(until_flush) = app.paste_burst_next_flush_delay_if_enabled(now) {
1457 poll_timeout = poll_timeout.min(until_flush);
1458 }
1459 if let Some(until_draw) = draw_wait {
1460 poll_timeout = poll_timeout.min(until_draw);
1461 }
1462 if web_config_session.is_some() {
1463 poll_timeout = poll_timeout.min(Duration::from_millis(WEB_CONFIG_POLL_MS));
1464 }
1465 // While the quit-confirmation prompt is armed, ensure we wake up to
1466 // expire it on time even if no input event arrives.
1467 if let Some(deadline) = app.quit_armed_until {
1468 let remaining = deadline.saturating_duration_since(now);
1469 poll_timeout = poll_timeout.min(remaining.max(Duration::from_millis(50)));
1470 }
1471 poll_timeout = clamp_event_poll_timeout(poll_timeout);
1472
1473 // #549: this async task also performs a blocking terminal poll. Give
1474 // the engine task a scheduler turn before we block again so an
1475 // interactive submit can reach the API instead of appearing stuck on
1476 // `working.` with no network activity.
1477 tokio::task::yield_now().await;
1478
1479 if event::poll(poll_timeout)? {
1480 let evt = event::read()?;
1481 app.needs_redraw = true;
1482
1483 // Handle bracketed paste events
1484 if let Event::Paste(text) = &evt {
1485 tracing::debug!(
1486 paste_len = text.len(),
1487 preview = %text.chars().take(80).collect::<String>(),
1488 "Received bracketed paste event"
1489 );
1490 if app.onboarding == OnboardingState::ApiKey {
1491 // Paste into API key input
1492 app.insert_api_key_str(text);
1493 sync_api_key_validation_status(app, false);
1494 } else if app.is_history_search_active() {
1495 app.history_search_insert_str(text);
1496 } else if app.view_stack.handle_paste(text) {
1497 // Modal consumed the paste (e.g. provider picker key entry)
1498 } else if !app.view_stack.is_empty() {
1499 // A non-consumed modal is open — don't leak paste into composer
1500 } else {
1501 // Paste into main input
1502 app.insert_paste_text(text);
1503 }
1504 continue;
1505 }
1506
1507 if let Event::Resize(width, height) = evt {
1508 tracing::debug!(
1509 width,
1510 height,
1511 coherence = ?app.coherence_state,
1512 use_alt_screen = app.use_alt_screen,
1513 "Event::Resize received; clearing terminal"
1514 );
1515 // Drain any further Resize events queued in this poll cycle so we
1516 // act on the final size only, then issue a single clear + redraw.
1517 // crossterm coalesces some resize events but rapid drag-resizes
1518 // can still queue several; processing them all here avoids the
1519 // common "stale art on the right edge" symptom (#65) caused by
1520 // the diff renderer skipping cells that match a stale back
1521 // buffer between intermediate sizes.
1522 let mut final_w = width;
1523 let mut final_h = height;
1524 while event::poll(Duration::from_millis(0)).unwrap_or(false) {
1525 match event::read() {
1526 Ok(Event::Resize(w, h)) => {
1527 final_w = w;
1528 final_h = h;
1529 }
1530 Ok(other) => {
1531 // Non-resize event during the drain: we can't
1532 // un-read it. Drop it and let the user re-issue
1533 // — the resize-coalesce window is tiny.
1534 tracing::debug!(
1535 ?other,
1536 "non-resize event during resize coalesce; dropping"
1537 );
1538 break;
1539 }
1540 Err(_) => break,
1541 }
1542 }
1543
1544 // #582: commit the event-reported size to ratatui's
1545 // viewport explicitly before the redraw, instead of
1546 // relying on `crossterm::terminal::size()` which gets
1547 // queried internally during `terminal.draw`. On
1548 // Windows ConHost specifically, `terminal::size()` has
1549 // been observed to return stale dimensions briefly
1550 // during a maximize→windowed transition; the next
1551 // `draw` then paints into a buffer that does not
1552 // match the post-restore viewport, producing the
1553 // unrecoverable black screen reported by @imakid.
1554 // The `Event::Resize` payload itself carries the
1555 // authoritative new size, so we forward it.
1556 if let Err(err) = terminal.resize(Rect::new(0, 0, final_w, final_h)) {
1557 tracing::warn!(
1558 ?err,
1559 final_w,
1560 final_h,
1561 "terminal.resize during Resize event failed; falling back to clear+draw"
1562 );
1563 }
1564
1565 terminal.clear()?;
1566 app.handle_resize(final_w, final_h);
1567 // Draw immediately so the cleared screen gets repainted before
1568 // any other events can interleave. Without this, the next
1569 // iteration's draw can race against fast follow-up input and
1570 // leave the user staring at a blank/partial frame.
1571 terminal.draw(|f| render(f, app))?;
1572 app.needs_redraw = false;
1573 continue;
1574 }
1575
1576 if app.use_mouse_capture
1577 && let Event::Mouse(mouse) = evt
1578 {
1579 // #376: hold Shift to bypass alt-screen mouse capture for
1580 // terminal-native text selection. While bypass is active,
1581 // mouse events pass through to the terminal instead of
1582 // being consumed by the TUI.
1583 if mouse.modifiers.contains(KeyModifiers::SHIFT) {
1584 if !shift_bypass_active {
1585 let _ = execute!(terminal.backend_mut(), DisableMouseCapture);
1586 shift_bypass_active = true;
1587 app.push_status_toast(
1588 "Native selection \u{2014} release Shift to return",
1589 StatusToastLevel::Info,
1590 Some(3_000),
1591 );
1592 }
1593 // Let the terminal handle this mouse event natively.
1594 continue;
1595 }
1596 if shift_bypass_active {
1597 let _ = execute!(terminal.backend_mut(), EnableMouseCapture);
1598 shift_bypass_active = false;
1599 app.push_status_toast(
1600 "Mouse capture restored",
1601 StatusToastLevel::Info,
1602 Some(2_000),
1603 );
1604 }
1605
1606 let events = handle_mouse_event(app, mouse);
1607 if handle_view_events(
1608 terminal,
1609 app,
1610 config,
1611 &task_manager,
1612 &mut engine_handle,
1613 &mut web_config_session,
1614 events,
1615 )
1616 .await?
1617 {
1618 return Ok(());
1619 }
1620 continue;
1621 }
1622
1623 let Event::Key(key) = evt else {
1624 continue;
1625 };
1626
1627 if key.kind != KeyEventKind::Press {
1628 continue;
1629 }
1630
1631 // Handle onboarding flow
1632 if app.onboarding != OnboardingState::None {
1633 match key.code {
1634 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1635 let _ = engine_handle.send(Op::Shutdown).await;
1636 return Ok(());
1637 }
1638 KeyCode::Esc if app.onboarding == OnboardingState::ApiKey => {
1639 app.onboarding = OnboardingState::Welcome;
1640 app.api_key_input.clear();
1641 app.api_key_cursor = 0;
1642 app.status_message = None;
1643 }
1644 KeyCode::Esc if app.onboarding == OnboardingState::Language => {
1645 app.onboarding = OnboardingState::Welcome;
1646 app.status_message = None;
1647 }
1648 // Language picker hotkeys: 1-5 select + persist (#566).
1649 //
1650 // Note: this used to be a single match-guard with `&& let`,
1651 // but `if_let_guard` is a nightly-only feature on Rust
1652 // before 1.94. Rewriting as a plain guard + nested `if let`
1653 // keeps `cargo install` working on stable.
1654 KeyCode::Char(c)
1655 if app.onboarding == OnboardingState::Language && c.is_ascii_digit() =>
1656 {
1657 if let Some((_, tag, _, _)) = onboarding::language::LANGUAGE_OPTIONS
1658 .iter()
1659 .find(|(hotkey, _, _, _)| *hotkey == c)
1660 {
1661 match app.set_locale_from_onboarding(tag) {
1662 Ok(()) => {
1663 app.push_status_toast(
1664 format!("Language set to {tag}"),
1665 StatusToastLevel::Info,
1666 Some(2_500),
1667 );
1668 advance_onboarding_after_language(app);
1669 }
1670 Err(err) => {
1671 app.status_message =
1672 Some(format!("Failed to save locale: {err}"));
1673 }
1674 }
1675 }
1676 }
1677 KeyCode::Enter => match app.onboarding {
1678 OnboardingState::Welcome => {
1679 advance_onboarding_from_welcome(app);
1680 }
1681 OnboardingState::Language => {
1682 // Enter without a digit pick keeps the existing
1683 // setting (which defaults to "auto").
1684 advance_onboarding_after_language(app);
1685 }
1686 OnboardingState::ApiKey => {
1687 let key = app.api_key_input.trim().to_string();
1688 if let ApiKeyValidation::Reject(message) =
1689 validate_api_key_for_onboarding(&key)
1690 {
1691 app.status_message = Some(message);
1692 continue;
1693 }
1694 match app.submit_api_key() {
1695 Ok(saved) => {
1696 // Surface where the key landed so the
1697 // user can verify the shared config
1698 // file path before the welcome
1699 // screen advances. The toast queue
1700 // outlives the onboarding state
1701 // transition, so it stays visible on
1702 // the next screen too.
1703 app.push_status_toast(
1704 format!("API key saved to {}", saved.describe()),
1705 StatusToastLevel::Info,
1706 Some(4_000),
1707 );
1708 app.status_message = None;
1709 // Recreate the engine so it picks up the newly saved key
1710 // without requiring a full process restart.
1711 let _ = engine_handle.send(Op::Shutdown).await;
1712 // Stamp the new key on the long-lived
1713 // `Config` reference so any future clone
1714 // (e.g. a subsequent /provider switch)
1715 // sees it; the explicit-override path
1716 // in `deepseek_api_key` (#343) makes
1717 // this win immediately.
1718 config.api_key = Some(key.clone());
1719 let mut refreshed_config = config.clone();
1720 refreshed_config.api_key = Some(key);
1721 let engine_config = build_engine_config(app, &refreshed_config);
1722 engine_handle = spawn_engine(engine_config, &refreshed_config);
1723 app.offline_mode = false;
1724 app.api_key_env_only = false;
1725
1726 if !app.api_messages.is_empty() {
1727 let _ = engine_handle
1728 .send(Op::SyncSession {
1729 messages: app.api_messages.clone(),
1730 system_prompt: app.system_prompt.clone(),
1731 model: app.model.clone(),
1732 workspace: app.workspace.clone(),
1733 })
1734 .await;
1735 }
1736
1737 advance_onboarding_after_language(app);
1738 }
1739 Err(e) => {
1740 app.status_message = Some(e.to_string());
1741 }
1742 }
1743 }
1744 OnboardingState::TrustDirectory => {}
1745 OnboardingState::Tips => {
1746 app.finish_onboarding();
1747 }
1748 OnboardingState::None => {}
1749 },
1750 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1')
1751 if app.onboarding == OnboardingState::TrustDirectory =>
1752 {
1753 match onboarding::mark_trusted(&app.workspace) {
1754 Ok(_) => {
1755 app.trust_mode = true;
1756 app.status_message = None;
1757 if app.onboarding_workspace_trust_gate {
1758 app.onboarding_workspace_trust_gate = false;
1759 app.onboarding = OnboardingState::None;
1760 } else {
1761 app.onboarding = OnboardingState::Tips;
1762 }
1763 }
1764 Err(err) => {
1765 app.status_message =
1766 Some(format!("Failed to trust workspace: {err}"));
1767 }
1768 }
1769 }
1770 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('2')
1771 if app.onboarding == OnboardingState::TrustDirectory =>
1772 {
1773 let _ = engine_handle.send(Op::Shutdown).await;
1774 return Ok(());
1775 }
1776 KeyCode::Backspace if app.onboarding == OnboardingState::ApiKey => {
1777 app.delete_api_key_char();
1778 sync_api_key_validation_status(app, false);
1779 }
1780 KeyCode::Char('h')
1781 if is_ctrl_h_backspace(&key)
1782 && app.onboarding == OnboardingState::ApiKey =>
1783 {
1784 app.delete_api_key_char();
1785 sync_api_key_validation_status(app, false);
1786 }
1787 _ if is_paste_shortcut(&key) && app.onboarding == OnboardingState::ApiKey => {
1788 // Cmd+V / Ctrl+V paste (bracketed paste handled above)
1789 app.paste_api_key_from_clipboard();
1790 sync_api_key_validation_status(app, false);
1791 }
1792 KeyCode::Char(c)
1793 if app.onboarding == OnboardingState::ApiKey && is_text_input_key(&key) =>
1794 {
1795 app.insert_api_key_char(c);
1796 sync_api_key_validation_status(app, false);
1797 }
1798 _ => {}
1799 }
1800 continue;
1801 }
1802
1803 if key.code == KeyCode::F(1) {
1804 if app.view_stack.top_kind() == Some(ModalKind::Help) {
1805 app.view_stack.pop();
1806 } else {
1807 app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
1808 }
1809 continue;
1810 }
1811
1812 if key.code == KeyCode::Char('/') && key.modifiers.contains(KeyModifiers::CONTROL) {
1813 if app.view_stack.top_kind() == Some(ModalKind::Help) {
1814 app.view_stack.pop();
1815 } else {
1816 app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
1817 }
1818 continue;
1819 }
1820
1821 if key.code == KeyCode::Char('k') && key.modifiers.contains(KeyModifiers::CONTROL) {
1822 // When the composer is the active input target (no modal/pager
1823 // intercepting keys), Ctrl+K performs an emacs-style kill to
1824 // end-of-line. If the kill is a no-op (cursor at end of empty
1825 // input), fall through to the existing command palette.
1826 if app.view_stack.is_empty() && app.kill_to_end_of_line() {
1827 continue;
1828 }
1829 app.view_stack
1830 .push(CommandPaletteView::new(build_command_palette_entries(
1831 app.ui_locale,
1832 &app.skills_dir,
1833 &app.workspace,
1834 &app.mcp_config_path,
1835 app.mcp_snapshot.as_ref(),
1836 )));
1837 continue;
1838 }
1839
1840 // Shifted shortcuts toggle the file-tree pane. Keep plain Ctrl+E
1841 // reserved for the composer end-of-line binding used by shells.
1842 if is_file_tree_toggle_shortcut(&key) {
1843 if let Some(_state) = app.file_tree.as_mut() {
1844 // File tree visible → hide it.
1845 app.file_tree = None;
1846 app.status_message = Some("File tree closed".to_string());
1847 } else {
1848 // Build the file tree from the current workspace.
1849 let state = crate::tui::file_tree::FileTreeState::new(&app.workspace);
1850 app.file_tree = Some(state);
1851 app.status_message = Some(
1852 "File tree: \u{2191}/\u{2193} navigate Enter select Esc close"
1853 .to_string(),
1854 );
1855 }
1856 app.needs_redraw = true;
1857 continue;
1858 }
1859
1860 // Ctrl+P opens the fuzzy file-picker overlay. Bound only when the
1861 // composer is focused (no other modal on top of the stack) and the
1862 // engine is not actively streaming a turn.
1863 if key.code == KeyCode::Char('p')
1864 && key.modifiers.contains(KeyModifiers::CONTROL)
1865 && app.view_stack.is_empty()
1866 && !app.is_loading
1867 {
1868 open_file_picker(app);
1869 continue;
1870 }
1871
1872 if matches!(key.code, KeyCode::Char('b') | KeyCode::Char('B'))
1873 && key.modifiers.contains(KeyModifiers::CONTROL)
1874 && app.view_stack.is_empty()
1875 {
1876 open_shell_control(app);
1877 continue;
1878 }
1879
1880 if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
1881 && key.modifiers.contains(KeyModifiers::ALT)
1882 && !key.modifiers.contains(KeyModifiers::CONTROL)
1883 && !key.modifiers.contains(KeyModifiers::SUPER)
1884 && app.view_stack.is_empty()
1885 {
1886 open_context_inspector(app);
1887 continue;
1888 }
1889
1890 if !app.view_stack.is_empty() {
1891 let events = app.view_stack.handle_key(key);
1892 if handle_view_events(
1893 terminal,
1894 app,
1895 config,
1896 &task_manager,
1897 &mut engine_handle,
1898 &mut web_config_session,
1899 events,
1900 )
1901 .await?
1902 {
1903 return Ok(());
1904 }
1905 continue;
1906 }
1907
1908 // File-tree navigation: intercept keys when the file-tree pane is
1909 // visible so Up/Down/Enter/Esc operate on the tree rather than
1910 // falling through to composer or modal handlers.
1911 if app.file_tree.is_some() {
1912 match key.code {
1913 KeyCode::Up => {
1914 if let Some(state) = app.file_tree.as_mut() {
1915 state.cursor_up();
1916 }
1917 app.needs_redraw = true;
1918 continue;
1919 }
1920 KeyCode::Down => {
1921 if let Some(state) = app.file_tree.as_mut() {
1922 state.cursor_down();
1923 }
1924 app.needs_redraw = true;
1925 continue;
1926 }
1927 KeyCode::Enter => {
1928 if let Some(state) = app.file_tree.as_mut() {
1929 if let Some(rel_path) = state.activate() {
1930 // Insert @path into the composer.
1931 let path_str = rel_path.to_string_lossy().to_string();
1932 app.status_message = Some(format!("Attached @{path_str}"));
1933 app.insert_str(&format!("@{} ", path_str));
1934 } else {
1935 // Directory was expanded/collapsed; rebuild.
1936 app.needs_redraw = true;
1937 }
1938 }
1939 continue;
1940 }
1941 KeyCode::Esc => {
1942 app.file_tree = None;
1943 app.status_message = Some("File tree closed".to_string());
1944 app.needs_redraw = true;
1945 continue;
1946 }
1947 _ => {}
1948 }
1949 }
1950
1951 if app.is_history_search_active() {
1952 handle_history_search_key(app, key);
1953 continue;
1954 }
1955
1956 if matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R'))
1957 && key.modifiers.contains(KeyModifiers::ALT)
1958 && !key.modifiers.contains(KeyModifiers::CONTROL)
1959 && !key.modifiers.contains(KeyModifiers::SUPER)
1960 {
1961 app.start_history_search();
1962 continue;
1963 }
1964
1965 let now = Instant::now();
1966 app.flush_paste_burst_if_enabled(now);
1967
1968 // On Windows, AltGr is delivered as `Ctrl+Alt`; treat
1969 // AltGr-typed chars (e.g. European layouts producing `@`, `\`,
1970 // `|`) as plain text rather than swallowing them as a modified
1971 // shortcut. `key_hint::has_ctrl_or_alt` filters AltGr out.
1972 let has_ctrl_alt_or_super = super::widgets::key_hint::has_ctrl_or_alt(key.modifiers)
1973 || key.modifiers.contains(KeyModifiers::SUPER);
1974 let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super;
1975 let is_enter = matches!(key.code, KeyCode::Enter);
1976
1977 if !is_plain_char
1978 && !is_enter
1979 && let Some(pending) = app.flush_paste_burst_before_modified_input_if_enabled()
1980 {
1981 app.insert_str(&pending);
1982 }
1983
1984 if (is_plain_char || is_enter) && super::paste::handle_paste_burst_key(app, &key, now) {
1985 continue;
1986 }
1987
1988 let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
1989 let slash_menu_open = !slash_menu_entries.is_empty();
1990 if slash_menu_open && app.slash_menu_selected >= slash_menu_entries.len() {
1991 app.slash_menu_selected = slash_menu_entries.len().saturating_sub(1);
1992 }
1993 let mention_menu_entries =
1994 crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT);
1995 let mention_menu_open = !mention_menu_entries.is_empty();
1996 if mention_menu_open && app.mention_menu_selected >= mention_menu_entries.len() {
1997 app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
1998 }
1999
2000 // Cancel a pending Esc-Esc prime as soon as any non-Esc key
2001 // arrives. Without this the prime would hang around for the
2002 // rest of the session and the user's next genuine Esc would
2003 // suddenly skip straight into the backtrack overlay.
2004 if !matches!(key.code, KeyCode::Esc)
2005 && matches!(
2006 app.backtrack.phase,
2007 crate::tui::backtrack::BacktrackPhase::Primed
2008 )
2009 {
2010 app.backtrack.reset();
2011 }
2012
2013 // Global keybindings
2014 match key.code {
2015 KeyCode::Enter
2016 if app.input.is_empty()
2017 && app.viewport.transcript_selection.is_active()
2018 && open_pager_for_selection(app) =>
2019 {
2020 continue;
2021 }
2022 KeyCode::Char('l')
2023 if key.modifiers.is_empty()
2024 && app.input.is_empty()
2025 && open_pager_for_last_message(app) =>
2026 {
2027 continue;
2028 }
2029 KeyCode::Char('v') | KeyCode::Char('V')
2030 if details_shortcut_modifiers(key.modifiers)
2031 && app.input.is_empty()
2032 && open_tool_details_pager(app) =>
2033 {
2034 continue;
2035 }
2036 KeyCode::Char('o')
2037 if key.modifiers == KeyModifiers::CONTROL
2038 && app.input.is_empty()
2039 && open_thinking_pager(app) =>
2040 {
2041 continue;
2042 }
2043 KeyCode::Char('t') | KeyCode::Char('T')
2044 if key.modifiers == KeyModifiers::CONTROL =>
2045 {
2046 toggle_live_transcript_overlay(app);
2047 continue;
2048 }
2049 KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => {
2050 if key.modifiers.contains(KeyModifiers::CONTROL) {
2051 app.set_sidebar_focus(SidebarFocus::Plan);
2052 app.status_message = Some("Sidebar focus: plan".to_string());
2053 } else {
2054 app.set_mode(AppMode::Plan);
2055 }
2056 continue;
2057 }
2058 KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => {
2059 if key.modifiers.contains(KeyModifiers::CONTROL) {
2060 app.set_sidebar_focus(SidebarFocus::Todos);
2061 app.status_message = Some("Sidebar focus: todos".to_string());
2062 } else {
2063 app.set_mode(AppMode::Agent);
2064 }
2065 continue;
2066 }
2067 KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => {
2068 if key.modifiers.contains(KeyModifiers::CONTROL) {
2069 app.set_sidebar_focus(SidebarFocus::Tasks);
2070 app.status_message = Some("Sidebar focus: tasks".to_string());
2071 } else {
2072 app.set_mode(AppMode::Yolo);
2073 }
2074 continue;
2075 }
2076 KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => {
2077 apply_alt_4_shortcut(app, key.modifiers);
2078 continue;
2079 }
2080 KeyCode::Char('!') if key.modifiers.contains(KeyModifiers::ALT) => {
2081 app.set_sidebar_focus(SidebarFocus::Plan);
2082 app.status_message = Some("Sidebar focus: plan".to_string());
2083 continue;
2084 }
2085 KeyCode::Char('@') if key.modifiers.contains(KeyModifiers::ALT) => {
2086 app.set_sidebar_focus(SidebarFocus::Todos);
2087 app.status_message = Some("Sidebar focus: todos".to_string());
2088 continue;
2089 }
2090 KeyCode::Char('#') if key.modifiers.contains(KeyModifiers::ALT) => {
2091 app.set_sidebar_focus(SidebarFocus::Tasks);
2092 app.status_message = Some("Sidebar focus: tasks".to_string());
2093 continue;
2094 }
2095 KeyCode::Char('$') if key.modifiers.contains(KeyModifiers::ALT) => {
2096 app.set_sidebar_focus(SidebarFocus::Agents);
2097 app.status_message = Some("Sidebar focus: agents".to_string());
2098 continue;
2099 }
2100 KeyCode::Char('%') if key.modifiers.contains(KeyModifiers::ALT) => {
2101 app.set_sidebar_focus(SidebarFocus::Context);
2102 app.status_message = Some("Sidebar focus: context".to_string());
2103 continue;
2104 }
2105 KeyCode::Char(')') if key.modifiers.contains(KeyModifiers::ALT) => {
2106 app.set_sidebar_focus(SidebarFocus::Auto);
2107 app.status_message = Some("Sidebar focus: auto".to_string());
2108 continue;
2109 }
2110 KeyCode::Char('0') if key.modifiers.contains(KeyModifiers::ALT) => {
2111 app.set_sidebar_focus(SidebarFocus::Auto);
2112 app.status_message = Some("Sidebar focus: auto".to_string());
2113 continue;
2114 }
2115 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2116 app.view_stack.push(SessionPickerView::new());
2117 continue;
2118 }
2119 KeyCode::Char('c') | KeyCode::Char('C') if is_copy_shortcut(&key) => {
2120 copy_active_selection(app);
2121 }
2122 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2123 // Three behaviors layered on Ctrl+C, in priority order:
2124 // 1. While a turn is in flight, cancel it (unchanged).
2125 // 2. Otherwise, on the first press, arm a 2-second
2126 // "press Ctrl+C again to quit" prompt and stay
2127 // running.
2128 // 3. On the second press while still armed, exit cleanly.
2129 // The prompt expires silently after the window so a
2130 // stray Ctrl+C three seconds later re-arms instead of
2131 // accidentally exiting.
2132 if app.is_loading {
2133 engine_handle.cancel();
2134 app.is_loading = false;
2135 app.streaming_state.reset();
2136 // Optimistically clear the turn-in-progress flag so
2137 // the footer wave animation halts immediately —
2138 // without this, the strip keeps animating until the
2139 // engine eventually emits TurnComplete (#5a). The
2140 // engine's eventual TurnComplete event will overwrite
2141 // with the real outcome ("interrupted").
2142 app.runtime_turn_status = None;
2143 app.status_message = Some("Request cancelled".to_string());
2144 app.disarm_quit();
2145 } else if app.quit_is_armed() {
2146 let _ = engine_handle.send(Op::Shutdown).await;
2147 return Ok(());
2148 } else {
2149 app.arm_quit();
2150 }
2151 }
2152 KeyCode::Char('d')
2153 if key.modifiers.contains(KeyModifiers::CONTROL) && app.input.is_empty() =>
2154 {
2155 let _ = engine_handle.send(Op::Shutdown).await;
2156 return Ok(());
2157 }
2158 // Vim composer mode: Esc from Insert/Visual → Normal.
2159 // This arm runs before the generic Esc handler so Insert mode
2160 // Esc doesn't accidentally cancel an in-flight request.
2161 KeyCode::Esc
2162 if app.composer.vim_enabled
2163 && app.composer.vim_mode != crate::tui::app::VimMode::Normal =>
2164 {
2165 app.vim_enter_normal();
2166 continue;
2167 }
2168 KeyCode::Esc if app.clear_composer_attachment_selection() => {
2169 continue;
2170 }
2171 KeyCode::Esc if mention_menu_open => {
2172 app.mention_menu_hidden = true;
2173 app.mention_menu_selected = 0;
2174 }
2175 KeyCode::Esc => match next_escape_action(app, slash_menu_open) {
2176 EscapeAction::CloseSlashMenu => {
2177 // A popup-style action wins over backtrack — clear
2178 // any prime so a stale Primed state can't jump us
2179 // straight into Selecting on the next Esc.
2180 app.backtrack.reset();
2181 app.close_slash_menu();
2182 }
2183 EscapeAction::CancelRequest => {
2184 app.backtrack.reset();
2185 engine_handle.cancel();
2186 app.is_loading = false;
2187 app.streaming_state.reset();
2188 // Optimistically halt the wave + working label —
2189 // engine's TurnComplete will resync with the real
2190 // outcome. Fixes #5a (wave kept animating after Esc).
2191 app.runtime_turn_status = None;
2192 // Finalize any in-flight tool entries optimistically so
2193 // the composer regains focus and the footer's "tool ...
2194 // · X active" chip clears immediately rather than
2195 // waiting for the engine's TurnComplete echo to drain.
2196 // Idempotent with the TurnComplete handler that runs
2197 // when the engine actually echoes the cancel (#243).
2198 // Background sub-agents continue running — they are
2199 // tracked via `subagent_cache` independently of the
2200 // foreground turn.
2201 app.finalize_active_cell_as_interrupted();
2202 app.finalize_streaming_assistant_as_interrupted();
2203 app.status_message = Some("Request cancelled".to_string());
2204 }
2205 EscapeAction::DiscardQueuedDraft => {
2206 app.backtrack.reset();
2207 app.queued_draft = None;
2208 app.status_message = Some("Stopped editing queued message".to_string());
2209 }
2210 EscapeAction::ClearInput => {
2211 app.backtrack.reset();
2212 app.edit_in_progress = false;
2213 app.clear_input_recoverable();
2214 }
2215 EscapeAction::Noop => {
2216 // Nothing else cares about this Esc — route it
2217 // through the backtrack state machine. While
2218 // streaming or with the live transcript already
2219 // open, fall through silently (#133 acceptance:
2220 // "during streaming Esc-Esc is a silent no-op").
2221 if app.is_loading
2222 || app.view_stack.top_kind() == Some(ModalKind::LiveTranscript)
2223 {
2224 continue;
2225 }
2226 let total = count_user_history_cells(app);
2227 match app.backtrack.handle_esc(total) {
2228 crate::tui::backtrack::EscEffect::None => {}
2229 crate::tui::backtrack::EscEffect::Prime => {
2230 app.status_message =
2231 Some("Press Esc again to backtrack".to_string());
2232 app.needs_redraw = true;
2233 }
2234 crate::tui::backtrack::EscEffect::Cancel => {
2235 app.status_message = Some("Backtrack canceled".to_string());
2236 app.needs_redraw = true;
2237 }
2238 crate::tui::backtrack::EscEffect::OpenOverlay => {
2239 open_backtrack_overlay(app);
2240 }
2241 }
2242 }
2243 },
2244 KeyCode::Up if key.modifiers.contains(KeyModifiers::SUPER) => {
2245 app.scroll_up(app.viewport.last_transcript_visible.max(3));
2246 }
2247 KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
2248 app.scroll_up(3);
2249 }
2250 KeyCode::Up
2251 if key.modifiers.is_empty()
2252 && mention_menu_open
2253 && app.mention_menu_selected > 0 =>
2254 {
2255 app.mention_menu_selected = app.mention_menu_selected.saturating_sub(1);
2256 }
2257 KeyCode::Up
2258 if key.modifiers.is_empty()
2259 && slash_menu_open
2260 && app.slash_menu_selected > 0 =>
2261 {
2262 app.slash_menu_selected = app.slash_menu_selected.saturating_sub(1);
2263 }
2264 KeyCode::Up
2265 if key.modifiers.is_empty()
2266 && app.selected_composer_attachment_index().is_some() =>
2267 {
2268 let _ = app.select_previous_composer_attachment();
2269 }
2270 KeyCode::Up
2271 if key.modifiers.is_empty()
2272 && app.cursor_position == 0
2273 && !mention_menu_open
2274 && !slash_menu_open
2275 && app.composer_attachment_count() > 0 =>
2276 {
2277 let _ = app.select_previous_composer_attachment();
2278 continue;
2279 }
2280 // #85: ↑ edits the most-recent queued message when the composer
2281 // is idle and the pending-input preview is showing queued work.
2282 KeyCode::Up
2283 if key.modifiers.is_empty()
2284 && app.input.is_empty()
2285 && app.cursor_position == 0
2286 && app.queued_draft.is_none()
2287 && !app.queued_messages.is_empty()
2288 && !mention_menu_open
2289 && !slash_menu_open
2290 && app.selected_composer_attachment_index().is_none() =>
2291 {
2292 let _ = app.pop_last_queued_into_draft();
2293 }
2294 KeyCode::Down if key.modifiers.contains(KeyModifiers::SUPER) => {
2295 app.scroll_down(app.viewport.last_transcript_visible.max(3));
2296 }
2297 KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => {
2298 app.scroll_down(3);
2299 }
2300 KeyCode::Down if key.modifiers.is_empty() && mention_menu_open => {
2301 app.mention_menu_selected = (app.mention_menu_selected + 1)
2302 .min(mention_menu_entries.len().saturating_sub(1));
2303 }
2304 KeyCode::Down if key.modifiers.is_empty() && slash_menu_open => {
2305 app.slash_menu_selected = (app.slash_menu_selected + 1)
2306 .min(slash_menu_entries.len().saturating_sub(1));
2307 }
2308 KeyCode::Down
2309 if key.modifiers.is_empty()
2310 && app.selected_composer_attachment_index().is_some() =>
2311 {
2312 let _ = app.select_next_composer_attachment();
2313 }
2314 KeyCode::PageUp => {
2315 let page = app.viewport.last_transcript_visible.max(1);
2316 app.scroll_up(page);
2317 }
2318 KeyCode::PageDown => {
2319 let page = app.viewport.last_transcript_visible.max(1);
2320 app.scroll_down(page);
2321 }
2322 KeyCode::Tab => {
2323 if mention_menu_open
2324 && crate::tui::file_mention::apply_mention_menu_selection(
2325 app,
2326 &mention_menu_entries,
2327 )
2328 {
2329 continue;
2330 }
2331 if slash_menu_open && apply_slash_menu_selection(app, &slash_menu_entries, true)
2332 {
2333 continue;
2334 }
2335 if try_autocomplete_slash_command(app) {
2336 continue;
2337 }
2338 if crate::tui::file_mention::try_autocomplete_file_mention(app) {
2339 continue;
2340 }
2341 if app.is_loading && queue_current_draft_for_next_turn(app) {
2342 continue;
2343 }
2344 let prior_model = app.model.clone();
2345 app.cycle_mode();
2346 if app.model != prior_model {
2347 let _ = engine_handle
2348 .send(Op::SetModel {
2349 model: app.model.clone(),
2350 })
2351 .await;
2352 }
2353 }
2354 KeyCode::BackTab => {
2355 app.cycle_effort();
2356 }
2357 KeyCode::Char('g')
2358 if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open =>
2359 {
2360 if let Some(anchor) =
2361 TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0)
2362 {
2363 app.viewport.transcript_scroll = anchor;
2364 }
2365 }
2366 KeyCode::Char('G')
2367 if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
2368 && app.input.is_empty()
2369 && !slash_menu_open =>
2370 {
2371 app.scroll_to_bottom();
2372 }
2373 KeyCode::Char('[')
2374 if key.modifiers.is_empty()
2375 && app.input.is_empty()
2376 && !slash_menu_open
2377 && !jump_to_adjacent_tool_cell(app, SearchDirection::Backward) =>
2378 {
2379 app.status_message = Some("No previous tool output".to_string());
2380 }
2381 KeyCode::Char(']')
2382 if key.modifiers.is_empty()
2383 && app.input.is_empty()
2384 && !slash_menu_open
2385 && !jump_to_adjacent_tool_cell(app, SearchDirection::Forward) =>
2386 {
2387 app.status_message = Some("No next tool output".to_string());
2388 }
2389 // `?` opens the searchable help overlay (#93). Gated on the
2390 // composer being empty so typing `?` mid-question is treated
2391 // as text. `Shift` is permitted because US layouts produce
2392 // `?` as `Shift+/`. Help-modal toggling lives next to the
2393 // F1 / Ctrl+/ branch above; here we only open.
2394 KeyCode::Char('?')
2395 if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
2396 && app.input.is_empty()
2397 && !slash_menu_open =>
2398 {
2399 if app.view_stack.top_kind() != Some(ModalKind::Help) {
2400 app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
2401 }
2402 continue;
2403 }
2404 // Input handling
2405 _ if is_composer_newline_key(key) => {
2406 app.insert_char('\n');
2407 }
2408 KeyCode::Enter
2409 if mention_menu_open
2410 && crate::tui::file_mention::apply_mention_menu_selection(
2411 app,
2412 &mention_menu_entries,
2413 ) =>
2414 {
2415 continue;
2416 }
2417 // #382: Ctrl+Enter forces a steer into the current turn.
2418 KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
2419 if let Some(input) = app.submit_input() {
2420 if input.starts_with('/') {
2421 if execute_command_input(
2422 terminal,
2423 app,
2424 &mut engine_handle,
2425 &task_manager,
2426 config,
2427 &mut web_config_session,
2428 &input,
2429 )
2430 .await?
2431 {
2432 return Ok(());
2433 }
2434 } else {
2435 let queued = if let Some(mut draft) = app.queued_draft.take() {
2436 draft.display = input;
2437 draft
2438 } else {
2439 build_queued_message(app, input)
2440 };
2441 // Force steer: bypass decide_submit_disposition.
2442 if let Err(err) =
2443 steer_user_message(app, &engine_handle, queued.clone()).await
2444 {
2445 app.queue_message(queued);
2446 app.status_message = Some(format!(
2447 "Steer failed ({err}); queued {} message(s)",
2448 app.queued_message_count()
2449 ));
2450 }
2451 }
2452 }
2453 }
2454 KeyCode::Enter => {
2455 // #573: when the user typed a slash-command prefix that
2456 // the popup is matching (e.g. `/mo` → `/model`), Enter
2457 // should run the *highlighted match* rather than
2458 // sending the literal `/mo` text. Only kick in when the
2459 // popup has at least one entry; otherwise fall through
2460 // to the legacy submit path.
2461 if slash_menu_open
2462 && !slash_menu_entries.is_empty()
2463 && app.input.starts_with('/')
2464 && apply_slash_menu_selection(app, &slash_menu_entries, false)
2465 {
2466 app.close_slash_menu();
2467 }
2468 if let Some(input) = app.submit_input() {
2469 if handle_plan_choice(app, config, &engine_handle, &input).await? {
2470 continue;
2471 }
2472 // `# foo` quick-add (#492) — when memory is enabled,
2473 // a single line starting with `#` (but not `##` /
2474 // `#!` shebangs / Markdown headings the user might
2475 // be pasting in) is intercepted: the text is
2476 // appended to the user memory file and the input
2477 // is consumed without firing a turn. Disabled
2478 // behaviour falls through to normal turn submit.
2479 if config.memory_enabled() && is_memory_quick_add(&input) {
2480 handle_memory_quick_add(app, &input, config);
2481 continue;
2482 }
2483 if input.starts_with('/') {
2484 if execute_command_input(
2485 terminal,
2486 app,
2487 &mut engine_handle,
2488 &task_manager,
2489 config,
2490 &mut web_config_session,
2491 &input,
2492 )
2493 .await?
2494 {
2495 return Ok(());
2496 }
2497 } else {
2498 let queued = if let Some(mut draft) = app.queued_draft.take() {
2499 draft.display = input;
2500 draft
2501 } else {
2502 build_queued_message(app, input)
2503 };
2504 // #383: /edit — if the user invoked /edit to revise
2505 // the last message, undo the last exchange before
2506 // dispatching the replacement. Sync the engine
2507 // session so it also drops the old exchange.
2508 if app.edit_in_progress {
2509 crate::commands::execute("/undo", app);
2510 app.edit_in_progress = false;
2511 let _ = engine_handle
2512 .send(Op::SyncSession {
2513 messages: app.api_messages.clone(),
2514 system_prompt: app.system_prompt.clone(),
2515 model: app.model.clone(),
2516 workspace: app.workspace.clone(),
2517 })
2518 .await;
2519 }
2520 submit_or_steer_message(app, config, &engine_handle, queued).await?;
2521 }
2522 }
2523 }
2524 KeyCode::Backspace
2525 if key.modifiers.contains(KeyModifiers::SUPER)
2526 && !app.remove_selected_composer_attachment() =>
2527 {
2528 app.delete_to_start_of_line();
2529 }
2530 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => {}
2531 KeyCode::Backspace
2532 if key.modifiers.contains(KeyModifiers::ALT)
2533 && !app.remove_selected_composer_attachment() =>
2534 {
2535 app.delete_word_backward();
2536 }
2537 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {}
2538 KeyCode::Backspace
2539 if key.modifiers.contains(KeyModifiers::CONTROL)
2540 && !app.remove_selected_composer_attachment() =>
2541 {
2542 app.delete_word_backward();
2543 }
2544 KeyCode::Backspace if key.modifiers.contains(KeyModifiers::CONTROL) => {}
2545 KeyCode::Delete
2546 if key.modifiers.contains(KeyModifiers::ALT)
2547 && !app.remove_selected_composer_attachment() =>
2548 {
2549 app.delete_word_forward();
2550 }
2551 KeyCode::Delete if key.modifiers.contains(KeyModifiers::ALT) => {}
2552 KeyCode::Delete
2553 if key.modifiers.contains(KeyModifiers::CONTROL)
2554 && !app.remove_selected_composer_attachment() =>
2555 {
2556 app.delete_word_forward();
2557 }
2558 KeyCode::Delete if key.modifiers.contains(KeyModifiers::CONTROL) => {}
2559 KeyCode::Backspace if !app.remove_selected_composer_attachment() => {
2560 app.delete_char();
2561 }
2562 KeyCode::Backspace => {}
2563 KeyCode::Char('h')
2564 if is_ctrl_h_backspace(&key) && !app.remove_selected_composer_attachment() =>
2565 {
2566 app.delete_char();
2567 }
2568 KeyCode::Char('h') if is_ctrl_h_backspace(&key) => {}
2569 KeyCode::Delete if !app.remove_selected_composer_attachment() => {
2570 app.delete_char_forward();
2571 }
2572 KeyCode::Delete => {}
2573 KeyCode::Left => {
2574 app.move_cursor_left();
2575 }
2576 KeyCode::Right => {
2577 app.move_cursor_right();
2578 }
2579 KeyCode::Home if key.modifiers.is_empty() => {
2580 if let Some(anchor) =
2581 TranscriptScroll::anchor_for(app.viewport.transcript_cache.line_meta(), 0)
2582 {
2583 app.viewport.transcript_scroll = anchor;
2584 }
2585 }
2586 KeyCode::End if key.modifiers.is_empty() => {
2587 app.scroll_to_bottom();
2588 }
2589 KeyCode::Home | KeyCode::Char('a')
2590 if key.modifiers.contains(KeyModifiers::CONTROL) =>
2591 {
2592 app.move_cursor_start();
2593 }
2594 KeyCode::End => {
2595 app.move_cursor_end();
2596 }
2597 KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2598 app.move_cursor_end();
2599 }
2600 KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2601 // Ctrl+O: spawn $EDITOR on the composer contents (#91).
2602 // Only fires when no modal is active (the !view_stack
2603 // branch above already returns early in that case) and
2604 // the composer is the focused input target. We accept the
2605 // shortcut whether or not a model turn is streaming —
2606 // editing the buffer never disturbs in-flight work.
2607 let seed = app.input.clone();
2608 match super::external_editor::spawn_editor_for_input(
2609 terminal,
2610 app.use_alt_screen,
2611 app.use_mouse_capture,
2612 app.use_bracketed_paste,
2613 &seed,
2614 ) {
2615 Ok(super::external_editor::EditorOutcome::Edited(new)) => {
2616 app.input = new;
2617 app.move_cursor_end();
2618 let editor = std::env::var("VISUAL")
2619 .ok()
2620 .filter(|s| !s.trim().is_empty())
2621 .or_else(|| {
2622 std::env::var("EDITOR")
2623 .ok()
2624 .filter(|s| !s.trim().is_empty())
2625 })
2626 .unwrap_or_else(|| "vi".to_string());
2627 app.status_message = Some(format!("Edited in {editor}"));
2628 }
2629 Ok(super::external_editor::EditorOutcome::Unchanged) => {
2630 app.status_message = Some("Editor closed (no changes)".to_string());
2631 }
2632 Ok(super::external_editor::EditorOutcome::Cancelled) => {
2633 app.status_message = Some("Editor cancelled".to_string());
2634 }
2635 Err(err) => {
2636 app.status_message = Some(format!("Editor error: {err}"));
2637 }
2638 }
2639 app.needs_redraw = true;
2640 }
2641 KeyCode::Up => {
2642 if key.modifiers.contains(KeyModifiers::CONTROL) {
2643 app.history_up();
2644 } else if should_scroll_with_arrows(app) {
2645 app.scroll_up(1);
2646 } else {
2647 app.history_up();
2648 }
2649 }
2650 KeyCode::Down => {
2651 if key.modifiers.contains(KeyModifiers::CONTROL) {
2652 app.history_down();
2653 } else if should_scroll_with_arrows(app) {
2654 app.scroll_down(1);
2655 } else {
2656 app.history_down();
2657 }
2658 }
2659 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2660 app.clear_input_recoverable();
2661 }
2662 KeyCode::Char('w') | KeyCode::Char('W')
2663 if key.modifiers.contains(KeyModifiers::CONTROL) =>
2664 {
2665 app.delete_word_backward();
2666 }
2667 KeyCode::Char('s') | KeyCode::Char('S')
2668 if key.modifiers == KeyModifiers::CONTROL && !app.input.is_empty() =>
2669 {
2670 // #440: park the current draft to the persistent
2671 // stash and clear the composer. Empty composers
2672 // are a no-op so a stray Ctrl+S can't pollute the
2673 // file. Surface a toast so the user sees the
2674 // confirmation (no-op feels broken otherwise).
2675 crate::composer_stash::push_stash(&app.input);
2676 app.clear_input_recoverable();
2677 app.push_status_toast(
2678 "Draft stashed — `/stash pop` to restore",
2679 StatusToastLevel::Info,
2680 Some(3_000),
2681 );
2682 }
2683 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2684 // #379: context-sensitive Ctrl+Y.
2685 // When the composer has content → emacs-style yank
2686 // from the kill buffer at the cursor.
2687 // When the composer is empty (transcript focus) →
2688 // copy the focused cell text to the system clipboard.
2689 if app.input.is_empty() && app.view_stack.is_empty() {
2690 if copy_focused_cell(app) {
2691 app.push_status_toast(
2692 "Copied to clipboard",
2693 StatusToastLevel::Info,
2694 Some(2_000),
2695 );
2696 } else {
2697 app.status_message = Some("No transcript cell to copy".to_string());
2698 }
2699 } else {
2700 app.yank();
2701 }
2702 }
2703 KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2704 let new_mode = match app.mode {
2705 AppMode::Plan => AppMode::Agent,
2706 _ => AppMode::Plan,
2707 };
2708 app.set_mode(new_mode);
2709 }
2710 _ if is_paste_shortcut(&key) => {
2711 app.paste_from_clipboard();
2712 }
2713 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => {
2714 app.set_mode(AppMode::Agent);
2715 continue;
2716 }
2717 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => {
2718 app.set_mode(AppMode::Yolo);
2719 continue;
2720 }
2721 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => {
2722 app.set_mode(AppMode::Plan);
2723 continue;
2724 }
2725 KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => {
2726 app.set_mode(AppMode::Agent);
2727 continue;
2728 }
2729 KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => {
2730 app.set_mode(AppMode::Yolo);
2731 continue;
2732 }
2733 KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => {
2734 app.set_mode(AppMode::Plan);
2735 continue;
2736 }
2737 KeyCode::Char('v') | KeyCode::Char('V')
2738 if key.modifiers.contains(KeyModifiers::ALT) =>
2739 {
2740 open_tool_details_pager(app);
2741 continue;
2742 }
2743 // Vim composer: Normal-mode motion / operator keys.
2744 // Only fires when vim is enabled, the input is focused (no modal
2745 // open on top), and the key has no modifier (pure char).
2746 KeyCode::Char(c)
2747 if app.vim_is_normal_mode()
2748 && key.modifiers.is_empty()
2749 && !slash_menu_open
2750 && !mention_menu_open
2751 && app.view_stack.is_empty() =>
2752 {
2753 handle_vim_normal_key(app, c);
2754 continue;
2755 }
2756 // Vim composer: in Visual mode plain chars are ignored
2757 // (no text insertion until `i` / `a` enters Insert).
2758 KeyCode::Char(_)
2759 if app.vim_is_visual_mode()
2760 && key.modifiers.is_empty()
2761 && app.view_stack.is_empty() =>
2762 {
2763 // absorb — Visual mode not yet fully implemented
2764 }
2765 KeyCode::Char(c) => {
2766 app.insert_char(c);
2767 }
2768 _ => {}
2769 }
2770
2771 if !is_plain_char && !is_enter {
2772 app.paste_burst.clear_window_after_non_char();
2773 }
2774 }
2775 }
2776 }
2777
2778 /// Handle a plain character key press when the composer is in vim Normal mode.
2779 ///
2780 /// Implements the core set of normal-mode bindings:
2781 /// - `h` / `l` — left / right by character
2782 /// - `j` / `k` — down / up by logical line (falls back to prev/next history)
2783 /// - `w` / `b` — word forward / backward
2784 /// - `0` / `$` — line start / end
2785 /// - `x` — delete character under cursor
2786 /// - `d` (×2) — delete current line (`dd`)
2787 /// - `i` — enter Insert before cursor
2788 /// - `a` — enter Insert after cursor
2789 /// - `o` — open new line below and enter Insert
2790 /// - `v` — enter Visual mode
2791 /// - `G` — move to end of buffer
2792 fn handle_vim_normal_key(app: &mut App, c: char) {
2793 use crate::tui::app::VimMode;
2794
2795 // Handle pending `d` (waiting for second `d` to complete `dd`).
2796 if app.composer.vim_pending_d {
2797 app.composer.vim_pending_d = false;
2798 if c == 'd' {
2799 app.vim_delete_line();
2800 }
2801 // Any other key cancels the pending operator.
2802 return;
2803 }
2804
2805 match c {
2806 'h' => {
2807 app.move_cursor_left();
2808 }
2809 'l' => {
2810 app.move_cursor_right();
2811 }
2812 'j' => {
2813 app.vim_move_down();
2814 }
2815 'k' => {
2816 app.vim_move_up();
2817 }
2818 'w' => {
2819 app.vim_move_word_forward();
2820 }
2821 'b' => {
2822 app.vim_move_word_backward();
2823 }
2824 '0' => {
2825 app.vim_move_line_start();
2826 }
2827 '$' => {
2828 app.vim_move_line_end();
2829 }
2830 'x' => {
2831 app.vim_delete_char_under_cursor();
2832 }
2833 'd' => {
2834 // Start the `dd` operator sequence.
2835 app.composer.vim_pending_d = true;
2836 }
2837 'i' => {
2838 app.vim_enter_insert();
2839 }
2840 'a' => {
2841 app.vim_enter_append();
2842 }
2843 'o' => {
2844 app.vim_open_line_below();
2845 }
2846 'v' => {
2847 app.composer.vim_mode = VimMode::Visual;
2848 app.needs_redraw = true;
2849 }
2850 'G' => {
2851 app.move_cursor_end();
2852 }
2853 _ => {
2854 // Unknown normal-mode key — silently ignored in Normal mode.
2855 }
2856 }
2857 }
2858
2859 fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) {
2860 app.set_sidebar_focus(SidebarFocus::Agents);
2861 app.status_message = Some("Sidebar focus: agents".to_string());
2862 }
2863
2864 async fn fetch_available_models(config: &Config) -> Result<Vec<String>> {
2865 use crate::client::DeepSeekClient;
2866
2867 let client = DeepSeekClient::new(config)?;
2868 let models = tokio::time::timeout(Duration::from_secs(20), client.list_models()).await??;
2869 let mut ids = models.into_iter().map(|model| model.id).collect::<Vec<_>>();
2870 ids.sort();
2871 ids.dedup();
2872 Ok(ids)
2873 }
2874
2875 fn format_available_models_message(current_model: &str, models: &[String]) -> String {
2876 let mut lines = vec![format!("Available models ({})", models.len())];
2877 for model in models {
2878 if model == current_model {
2879 lines.push(format!("* {model} (current)"));
2880 } else {
2881 lines.push(format!(" {model}"));
2882 }
2883 }
2884 lines.join("\n")
2885 }
2886
2887 fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession {
2888 if let Some(ref existing_id) = app.current_session_id
2889 && let Ok(existing) = manager.load_session(existing_id)
2890 {
2891 let mut updated = update_session(
2892 existing,
2893 &app.api_messages,
2894 u64::from(app.session.total_tokens),
2895 app.system_prompt.as_ref(),
2896 );
2897 updated.metadata.mode = Some(app.mode.as_setting().to_string());
2898 updated.context_references = app.session_context_references.clone();
2899 updated
2900 } else {
2901 let mut session = create_saved_session_with_mode(
2902 &app.api_messages,
2903 &app.model,
2904 &app.workspace,
2905 u64::from(app.session.total_tokens),
2906 app.system_prompt.as_ref(),
2907 Some(app.mode.as_setting()),
2908 );
2909 session.context_references = app.session_context_references.clone();
2910 session
2911 }
2912 }
2913
2914 fn queued_ui_to_session(msg: &QueuedMessage) -> QueuedSessionMessage {
2915 QueuedSessionMessage {
2916 display: msg.display.clone(),
2917 skill_instruction: msg.skill_instruction.clone(),
2918 }
2919 }
2920
2921 fn queued_session_to_ui(msg: QueuedSessionMessage) -> QueuedMessage {
2922 QueuedMessage {
2923 display: msg.display,
2924 skill_instruction: msg.skill_instruction,
2925 }
2926 }
2927
2928 /// Translate an `EngineEvent::Error` into UI state updates.
2929 ///
2930 /// The engine's `recoverable` flag (mirrored on `ErrorEnvelope`) decides
2931 /// whether the session flips into offline mode: stream stalls, chunk
2932 /// timeouts, transient network errors, and rate-limit/server hiccups arrive
2933 /// recoverable and must NOT flip into offline. Hard failures (auth, billing,
2934 /// invalid request) arrive non-recoverable; those flip offline so subsequent
2935 /// messages get queued instead of silently lost mid-flight.
2936 ///
2937 /// `severity` drives transcript color: red for `Error`/`Critical`, amber for
2938 /// `Warning`, dim for `Info`.
2939 pub(crate) fn apply_engine_error_to_app(
2940 app: &mut App,
2941 envelope: crate::error_taxonomy::ErrorEnvelope,
2942 ) {
2943 let recoverable = envelope.recoverable;
2944 let message = envelope.message.clone();
2945 let severity = envelope.severity;
2946 app.streaming_state.reset();
2947 app.streaming_message_index = None;
2948 app.streaming_thinking_active_entry = None;
2949
2950 // #455 (observer-only): fire `on_error` hooks so operators can
2951 // page on auth / billing / invalid-request failures without
2952 // tailing the audit log. Read-only — the hook can react but not
2953 // suppress the error from reaching the transcript. Fast-path
2954 // skip when no hooks configured.
2955 if app
2956 .hooks
2957 .has_hooks_for_event(crate::hooks::HookEvent::OnError)
2958 {
2959 let context = app.base_hook_context().with_error(&message);
2960 let _ = app.execute_hooks(crate::hooks::HookEvent::OnError, &context);
2961 }
2962
2963 app.add_message(HistoryCell::Error {
2964 message: message.clone(),
2965 severity,
2966 });
2967 app.is_loading = false;
2968 if matches!(
2969 envelope.category,
2970 crate::error_taxonomy::ErrorCategory::Authentication
2971 ) && app.api_key_env_only
2972 {
2973 app.offline_mode = true;
2974 app.onboarding_needs_api_key = true;
2975 app.onboarding = OnboardingState::ApiKey;
2976 app.status_message = Some(
2977 "The API key from DEEPSEEK_API_KEY was rejected. Paste a valid key to save it to ~/.deepseek/config.toml, or update the environment variable.".to_string(),
2978 );
2979 return;
2980 }
2981 if recoverable {
2982 app.status_message = Some(format!("Connection interrupted: {message}"));
2983 } else {
2984 app.offline_mode = true;
2985 app.status_message = Some(format!(
2986 "Engine error; queued messages stay pending: {message}"
2987 ));
2988 }
2989 }
2990
2991 fn persist_offline_queue_state(app: &App) {
2992 if let Ok(manager) = SessionManager::default_location() {
2993 if app.queued_messages.is_empty() && app.queued_draft.is_none() {
2994 let _ = manager.clear_offline_queue_state();
2995 return;
2996 }
2997 let state = OfflineQueueState {
2998 messages: app
2999 .queued_messages
3000 .iter()
3001 .map(queued_ui_to_session)
3002 .collect(),
3003 draft: app.queued_draft.as_ref().map(queued_ui_to_session),
3004 ..OfflineQueueState::default()
3005 };
3006 let _ = manager.save_offline_queue_state(&state, app.current_session_id.as_deref());
3007 }
3008 }
3009
3010 fn sanitize_stream_chunk(chunk: &str) -> String {
3011 // Keep printable characters and common whitespace; drop control bytes.
3012 chunk
3013 .chars()
3014 .filter(|c| *c == '\n' || *c == '\t' || !c.is_control())
3015 .collect()
3016 }
3017
3018 /// Resolve the effective notification method/threshold/include-summary tuple
3019 /// for a completed turn, taking the high-level
3020 /// `[tui].notification_condition` override into account on top of the
3021 /// lower-level `[notifications]` block.
3022 ///
3023 /// Returns `None` to mean "do not notify" (either because the user set
3024 /// `notification_condition = "never"` or because the resolved method is
3025 /// `Off`).
3026 fn notification_settings(
3027 config: &Config,
3028 ) -> Option<(crate::tui::notifications::Method, Duration, bool)> {
3029 let notif = config.notifications_config();
3030 let method = match notif.method {
3031 crate::config::NotificationMethod::Auto => crate::tui::notifications::Method::Auto,
3032 crate::config::NotificationMethod::Osc9 => crate::tui::notifications::Method::Osc9,
3033 crate::config::NotificationMethod::Bel => crate::tui::notifications::Method::Bel,
3034 crate::config::NotificationMethod::Off => crate::tui::notifications::Method::Off,
3035 };
3036
3037 if let Some(condition) = config
3038 .tui
3039 .as_ref()
3040 .and_then(|tui| tui.notification_condition)
3041 {
3042 match condition {
3043 crate::config::NotificationCondition::Always => {
3044 return Some((method, Duration::ZERO, notif.include_summary));
3045 }
3046 crate::config::NotificationCondition::Never => return None,
3047 }
3048 }
3049
3050 Some((
3051 method,
3052 Duration::from_secs(notif.threshold_secs),
3053 notif.include_summary,
3054 ))
3055 }
3056
3057 /// Build the notification body for a completed turn. Prefers the live
3058 /// streaming text the user just saw; falls back to the latest assistant
3059 /// message in `api_messages` if streaming text is empty (for example, the
3060 /// turn finished entirely through tool output). When `include_summary` is
3061 /// true, an elapsed/cost line is appended.
3062 fn completed_turn_notification_message(
3063 app: &App,
3064 current_streaming_text: &str,
3065 include_summary: bool,
3066 turn_elapsed: Duration,
3067 turn_cost: Option<crate::pricing::CostEstimate>,
3068 ) -> String {
3069 let mut msg = notification_text_summary(current_streaming_text)
3070 .or_else(|| latest_assistant_notification_text(&app.api_messages))
3071 .unwrap_or_else(|| "deepseek: turn complete".to_string());
3072
3073 if include_summary {
3074 let human = crate::tui::notifications::humanize_duration(turn_elapsed);
3075 let summary = match turn_cost {
3076 Some(c) => {
3077 let cost = crate::pricing::format_cost_estimate(c, app.cost_currency);
3078 format!("deepseek: turn complete ({human}, {cost})")
3079 }
3080 None => format!("deepseek: turn complete ({human})"),
3081 };
3082 if msg == "deepseek: turn complete" {
3083 msg = summary;
3084 } else {
3085 msg.push('\n');
3086 msg.push_str(&summary);
3087 }
3088 }
3089
3090 msg
3091 }
3092
3093 fn latest_assistant_notification_text(messages: &[Message]) -> Option<String> {
3094 messages
3095 .iter()
3096 .rev()
3097 .find(|message| message.role == "assistant")
3098 .and_then(|message| {
3099 let text = message
3100 .content
3101 .iter()
3102 .filter_map(|block| match block {
3103 ContentBlock::Text { text, .. } => Some(text.as_str()),
3104 ContentBlock::Thinking { .. }
3105 | ContentBlock::ToolUse { .. }
3106 | ContentBlock::ToolResult { .. }
3107 | ContentBlock::ServerToolUse { .. }
3108 | ContentBlock::ToolSearchToolResult { .. }
3109 | ContentBlock::CodeExecutionToolResult { .. } => None,
3110 })
3111 .collect::<Vec<_>>()
3112 .join("\n");
3113 notification_text_summary(&text)
3114 })
3115 }
3116
3117 fn notification_text_summary(text: &str) -> Option<String> {
3118 const MAX_CHARS: usize = 360;
3119
3120 let sanitized = sanitize_stream_chunk(text);
3121 let collapsed = sanitized
3122 .lines()
3123 .map(str::trim)
3124 .filter(|line| !line.is_empty())
3125 .collect::<Vec<_>>()
3126 .join("\n");
3127 let trimmed = collapsed.trim();
3128 if trimmed.is_empty() {
3129 return None;
3130 }
3131
3132 if let Some((idx, _)) = trimmed.char_indices().nth(MAX_CHARS) {
3133 let mut s = String::with_capacity(idx + 3);
3134 s.push_str(&trimmed[..idx]);
3135 s.push_str("...");
3136 Some(s)
3137 } else {
3138 Some(trimmed.to_string())
3139 }
3140 }
3141
3142 /// Ensure an in-flight streaming Assistant cell exists in history and return
3143 /// its index. Thinking cells go through `ensure_streaming_thinking_active_entry`
3144 /// (active cell) instead.
3145 fn ensure_streaming_assistant_history_cell(app: &mut App) -> usize {
3146 if let Some(index) = app.streaming_message_index {
3147 return index;
3148 }
3149 app.add_message(HistoryCell::Assistant {
3150 content: String::new(),
3151 streaming: true,
3152 });
3153 let index = app.history.len().saturating_sub(1);
3154 app.streaming_message_index = Some(index);
3155 index
3156 }
3157
3158 fn append_streaming_text(app: &mut App, index: usize, text: &str) {
3159 if text.is_empty() {
3160 return;
3161 }
3162 if let Some(HistoryCell::Assistant { content, .. }) = app.history.get_mut(index) {
3163 content.push_str(text);
3164 // Bump only the streaming cell's per-cell revision so the transcript
3165 // cache re-renders just this cell. Without this, the cache would
3166 // either skip the update entirely (now that the global
3167 // history_version is no longer fanned out across every cell) or fall
3168 // back to a full re-wrap of the entire transcript every chunk.
3169 app.bump_history_cell(index);
3170 }
3171 }
3172
3173 /// Ensure an in-flight Thinking entry exists in `active_cell` and return its
3174 /// entry index. If no thinking entry is currently streaming, push a fresh one.
3175 /// P2.3: thinking shares the active cell with subsequent tool calls so the
3176 /// pair render as one logical "Working…" block.
3177 fn ensure_streaming_thinking_active_entry(app: &mut App) -> usize {
3178 if let Some(idx) = app.streaming_thinking_active_entry {
3179 return idx;
3180 }
3181 if app.active_cell.is_none() {
3182 app.active_cell = Some(ActiveCell::new());
3183 }
3184 let active = app.active_cell.as_mut().expect("active_cell just ensured");
3185 let entry_idx = active.push_thinking(HistoryCell::Thinking {
3186 content: String::new(),
3187 streaming: true,
3188 duration_secs: None,
3189 });
3190 app.streaming_thinking_active_entry = Some(entry_idx);
3191 app.bump_active_cell_revision();
3192 entry_idx
3193 }
3194
3195 /// Append text to a streaming Thinking entry inside `active_cell`. Bumps the
3196 /// active-cell revision so the renderer re-draws the live tail.
3197 fn append_streaming_thinking(app: &mut App, entry_idx: usize, text: &str) {
3198 if text.is_empty() {
3199 return;
3200 }
3201 let mutated = if let Some(active) = app.active_cell.as_mut()
3202 && let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
3203 {
3204 content.push_str(text);
3205 true
3206 } else {
3207 false
3208 };
3209 if mutated {
3210 app.bump_active_cell_revision();
3211 }
3212 }
3213
3214 /// Finalize the in-flight thinking entry in `active_cell`: append the
3215 /// collector's remaining buffered text, stop the spinner, and stamp the
3216 /// duration. Returns `true` when a thinking entry was finalized (so the
3217 /// dispatch loop knows the transcript was touched). No-op if no thinking
3218 /// entry is currently streaming.
3219 fn finalize_streaming_thinking_active_entry(
3220 app: &mut App,
3221 duration: Option<f32>,
3222 remaining: &str,
3223 ) -> bool {
3224 let Some(entry_idx) = app.streaming_thinking_active_entry.take() else {
3225 return false;
3226 };
3227 if !remaining.is_empty() {
3228 append_streaming_thinking(app, entry_idx, remaining);
3229 }
3230 if let Some(active) = app.active_cell.as_mut()
3231 && let Some(HistoryCell::Thinking {
3232 streaming,
3233 duration_secs,
3234 ..
3235 }) = active.entry_mut(entry_idx)
3236 {
3237 *streaming = false;
3238 *duration_secs = duration;
3239 }
3240 app.bump_active_cell_revision();
3241 true
3242 }
3243
3244 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3245 enum EscapeAction {
3246 CloseSlashMenu,
3247 CancelRequest,
3248 DiscardQueuedDraft,
3249 ClearInput,
3250 Noop,
3251 }
3252
3253 fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction {
3254 if slash_menu_open {
3255 EscapeAction::CloseSlashMenu
3256 } else if app.is_loading {
3257 EscapeAction::CancelRequest
3258 } else if app.queued_draft.is_some() && app.input.is_empty() {
3259 EscapeAction::DiscardQueuedDraft
3260 } else if !app.input.is_empty() {
3261 EscapeAction::ClearInput
3262 } else {
3263 EscapeAction::Noop
3264 }
3265 }
3266
3267 fn is_composer_newline_key(key: KeyEvent) -> bool {
3268 match key.code {
3269 KeyCode::Char('j') => key.modifiers.contains(KeyModifiers::CONTROL),
3270 KeyCode::Enter => {
3271 key.modifiers.contains(KeyModifiers::ALT)
3272 || (key.modifiers.contains(KeyModifiers::SHIFT)
3273 && !key.modifiers.contains(KeyModifiers::CONTROL))
3274 }
3275 _ => false,
3276 }
3277 }
3278
3279 fn handle_history_search_key(app: &mut App, key: KeyEvent) {
3280 match key.code {
3281 KeyCode::Enter => {
3282 let _ = app.accept_history_search();
3283 }
3284 KeyCode::Esc => {
3285 app.cancel_history_search();
3286 }
3287 KeyCode::Char('c') | KeyCode::Char('C')
3288 if key.modifiers.contains(KeyModifiers::CONTROL) =>
3289 {
3290 app.cancel_history_search();
3291 }
3292 KeyCode::Backspace => {
3293 app.history_search_backspace();
3294 }
3295 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3296 while app
3297 .history_search_query()
3298 .is_some_and(|query| !query.is_empty())
3299 {
3300 app.history_search_backspace();
3301 }
3302 }
3303 KeyCode::Up => {
3304 app.history_search_select_previous();
3305 }
3306 KeyCode::Down => {
3307 app.history_search_select_next();
3308 }
3309 KeyCode::Char(ch)
3310 if key.modifiers.is_empty()
3311 || key.modifiers == KeyModifiers::SHIFT
3312 || key.modifiers == KeyModifiers::NONE =>
3313 {
3314 app.history_search_insert_char(ch);
3315 }
3316 _ => {}
3317 }
3318 }
3319
3320 #[derive(Debug, Clone, PartialEq, Eq)]
3321 enum ApiKeyValidation {
3322 Accept { warning: Option<String> },
3323 Reject(String),
3324 }
3325
3326 fn validate_api_key_for_onboarding(api_key: &str) -> ApiKeyValidation {
3327 let trimmed = api_key.trim();
3328 if trimmed.is_empty() {
3329 return ApiKeyValidation::Reject("API key cannot be empty.".to_string());
3330 }
3331 if trimmed.contains(char::is_whitespace) {
3332 return ApiKeyValidation::Reject(
3333 "API key appears malformed (contains whitespace).".to_string(),
3334 );
3335 }
3336 if trimmed.len() < 16 {
3337 return ApiKeyValidation::Accept {
3338 warning: Some(
3339 "API key looks short. Double-check it, but unusual formats are allowed."
3340 .to_string(),
3341 ),
3342 };
3343 }
3344 if !trimmed.contains('-') {
3345 return ApiKeyValidation::Accept {
3346 warning: Some(
3347 "API key format looks unusual. Check that the full key was copied.".to_string(),
3348 ),
3349 };
3350 }
3351 ApiKeyValidation::Accept { warning: None }
3352 }
3353
3354 fn advance_onboarding_from_welcome(app: &mut App) {
3355 app.status_message = None;
3356 app.onboarding = OnboardingState::Language;
3357 }
3358
3359 fn advance_onboarding_after_language(app: &mut App) {
3360 app.status_message = None;
3361 if app.onboarding_needs_api_key {
3362 app.onboarding = OnboardingState::ApiKey;
3363 } else if !app.trust_mode && onboarding::needs_trust(&app.workspace) {
3364 app.onboarding = OnboardingState::TrustDirectory;
3365 } else {
3366 app.onboarding = OnboardingState::Tips;
3367 }
3368 }
3369
3370 fn sync_api_key_validation_status(app: &mut App, show_empty_error: bool) {
3371 if app.api_key_input.trim().is_empty() && !show_empty_error {
3372 app.status_message = None;
3373 return;
3374 }
3375
3376 match validate_api_key_for_onboarding(&app.api_key_input) {
3377 ApiKeyValidation::Accept { warning } => {
3378 app.status_message = warning;
3379 }
3380 ApiKeyValidation::Reject(message) => {
3381 app.status_message = Some(message);
3382 }
3383 }
3384 }
3385
3386 fn build_queued_message(app: &mut App, input: String) -> QueuedMessage {
3387 let skill_instruction = app.active_skill.take();
3388 QueuedMessage::new(input, skill_instruction)
3389 }
3390
3391 fn queue_current_draft_for_next_turn(app: &mut App) -> bool {
3392 let Some(input) = app.submit_input() else {
3393 return false;
3394 };
3395 let queued = if let Some(mut draft) = app.queued_draft.take() {
3396 draft.display = input;
3397 draft
3398 } else {
3399 build_queued_message(app, input)
3400 };
3401 app.queue_message(queued);
3402 app.status_message = Some(format!(
3403 "{} queued — ↑ to edit, /queue list",
3404 app.queued_message_count()
3405 ));
3406 true
3407 }
3408
3409 fn queued_message_content_for_app(
3410 app: &App,
3411 message: &QueuedMessage,
3412 cwd: Option<PathBuf>,
3413 ) -> String {
3414 // Pass the process CWD explicitly so the resolver's two-pass logic can
3415 // honor the user's launch directory when it differs from `--workspace`
3416 // (issue #101 — file mentions silently routing to the wrong root).
3417 let user_request = crate::tui::file_mention::user_request_with_file_mentions(
3418 &message.display,
3419 &app.workspace,
3420 cwd,
3421 );
3422 if let Some(skill_instruction) = message.skill_instruction.as_ref() {
3423 format!("{skill_instruction}\n\n---\n\nUser request: {user_request}")
3424 } else {
3425 user_request
3426 }
3427 }
3428
3429 async fn dispatch_user_message(
3430 app: &mut App,
3431 config: &Config,
3432 engine_handle: &EngineHandle,
3433 message: QueuedMessage,
3434 ) -> Result<()> {
3435 // #455 (observer-only): fire `message_submit` hooks before
3436 // dispatch. Hooks see the user's display text via the
3437 // `with_message` builder. Read-only — they can log, audit, or
3438 // notify but cannot mutate the message that goes to the engine.
3439 // Fast-path skip when no hooks configured.
3440 if app
3441 .hooks
3442 .has_hooks_for_event(crate::hooks::HookEvent::MessageSubmit)
3443 {
3444 let context = app.base_hook_context().with_message(&message.display);
3445 let _ = app.execute_hooks(crate::hooks::HookEvent::MessageSubmit, &context);
3446 }
3447
3448 // Set immediately to prevent double-dispatch before TurnStarted event arrives.
3449 app.is_loading = true;
3450 app.last_send_at = Some(Instant::now());
3451
3452 let cwd = std::env::current_dir().ok();
3453 let references = crate::tui::file_mention::context_references_from_input(
3454 &message.display,
3455 &app.workspace,
3456 cwd.clone(),
3457 );
3458 let content = queued_message_content_for_app(app, &message, cwd);
3459 let message_index = app.api_messages.len();
3460 app.system_prompt = Some(
3461 prompts::system_prompt_for_mode_with_context_skills_and_session(
3462 app.mode,
3463 &app.workspace,
3464 None,
3465 None,
3466 None,
3467 prompts::PromptSessionContext {
3468 user_memory_block: None,
3469 goal_objective: app.goal.goal_objective.as_deref(),
3470 locale_tag: app.ui_locale.tag(),
3471 },
3472 ),
3473 );
3474 app.add_message(HistoryCell::User {
3475 content: message.display.clone(),
3476 });
3477 let history_cell = app.history.len().saturating_sub(1);
3478 app.record_context_references(history_cell, message_index, references);
3479 app.scroll_to_bottom();
3480 app.api_messages.push(Message {
3481 role: "user".to_string(),
3482 content: vec![ContentBlock::Text {
3483 text: content.clone(),
3484 cache_control: None,
3485 }],
3486 });
3487 maybe_warn_context_pressure(app);
3488 if should_auto_compact_before_send(app) {
3489 app.status_message = Some("Context critical; compacting before send...".to_string());
3490 let _ = engine_handle.send(Op::CompactContext).await;
3491 }
3492 app.session.last_prompt_tokens = None;
3493 app.session.last_completion_tokens = None;
3494 app.session.last_prompt_cache_hit_tokens = None;
3495 app.session.last_prompt_cache_miss_tokens = None;
3496 app.session.last_reasoning_replay_tokens = None;
3497 // Persist immediately so abrupt termination can recover this in-flight turn.
3498 // Offloaded to the persistence actor.
3499 if let Ok(manager) = SessionManager::default_location() {
3500 let session = build_session_snapshot(app, &manager);
3501 persistence_actor::persist(PersistRequest::Checkpoint(session));
3502 }
3503
3504 let auto_selection = if app.auto_model || app.reasoning_effort == ReasoningEffort::Auto {
3505 Some(resolve_auto_model_selection(app, config, &message, &content).await)
3506 } else {
3507 None
3508 };
3509
3510 let effective_model = if app.auto_model {
3511 auto_selection
3512 .as_ref()
3513 .map(|selection| selection.model.clone())
3514 .unwrap_or_else(|| commands::auto_model_heuristic(&message.display, &app.model))
3515 } else {
3516 app.model.clone()
3517 };
3518
3519 let auto_controls_reasoning = app.auto_model || app.reasoning_effort == ReasoningEffort::Auto;
3520 let effective_reasoning_effort = if auto_controls_reasoning {
3521 let effort = auto_selection
3522 .as_ref()
3523 .and_then(|selection| selection.reasoning_effort)
3524 .unwrap_or_else(|| {
3525 normalize_auto_routed_effort(crate::auto_reasoning::select(false, &message.display))
3526 });
3527 app.last_effective_reasoning_effort = Some(effort);
3528 Some(effort.as_setting().to_string())
3529 } else {
3530 app.last_effective_reasoning_effort = None;
3531 app.reasoning_effort.api_value().map(str::to_string)
3532 };
3533
3534 if let Some(selection) = auto_selection.as_ref() {
3535 if app.auto_model {
3536 app.last_effective_model = Some(effective_model.clone());
3537 let mut status = format!(
3538 "Auto model selected: {effective_model} via {}",
3539 selection.source.label()
3540 );
3541 if let Some(effort) = app.last_effective_reasoning_effort {
3542 status.push_str(&format!("; thinking auto: {}", effort.as_setting()));
3543 }
3544 app.status_message = Some(status);
3545 }
3546 } else {
3547 app.last_effective_model = None;
3548 }
3549
3550 if let Err(err) = engine_handle
3551 .send(Op::SendMessage {
3552 content,
3553 mode: app.mode,
3554 model: effective_model,
3555 goal_objective: app.goal.goal_objective.clone(),
3556 reasoning_effort: effective_reasoning_effort,
3557 reasoning_effort_auto: auto_controls_reasoning,
3558 auto_model: app.auto_model,
3559 allow_shell: app.allow_shell,
3560 trust_mode: app.trust_mode,
3561 auto_approve: app.mode == AppMode::Yolo,
3562 approval_mode: app.approval_mode,
3563 })
3564 .await
3565 {
3566 app.is_loading = false;
3567 app.last_send_at = None;
3568 return Err(err);
3569 }
3570
3571 Ok(())
3572 }
3573
3574 async fn resolve_auto_model_selection(
3575 app: &App,
3576 config: &Config,
3577 message: &QueuedMessage,
3578 latest_content: &str,
3579 ) -> commands::AutoRouteSelection {
3580 let latest_request = if latest_content.trim().is_empty() {
3581 message.display.as_str()
3582 } else {
3583 latest_content
3584 };
3585 commands::resolve_auto_route_with_flash(
3586 config,
3587 latest_request,
3588 &recent_auto_router_context(&app.api_messages),
3589 if app.auto_model { "auto" } else { "fixed" },
3590 app.reasoning_effort.as_setting(),
3591 )
3592 .await
3593 }
3594
3595 fn normalize_auto_routed_effort(effort: ReasoningEffort) -> ReasoningEffort {
3596 commands::normalize_auto_route_effort(effort)
3597 }
3598
3599 fn recent_auto_router_context(messages: &[Message]) -> String {
3600 let mut rows = Vec::new();
3601 for message in messages.iter().rev().skip(1) {
3602 if rows.len() >= 6 {
3603 break;
3604 }
3605 let text = content_blocks_text(&message.content);
3606 let text = text.trim();
3607 if text.is_empty() {
3608 continue;
3609 }
3610 rows.push(format!(
3611 "{}: {}",
3612 message.role,
3613 truncate_for_auto_router(text, 900)
3614 ));
3615 }
3616 rows.reverse();
3617 if rows.is_empty() {
3618 "No prior context.".to_string()
3619 } else {
3620 rows.join("\n")
3621 }
3622 }
3623
3624 fn content_blocks_text(blocks: &[ContentBlock]) -> String {
3625 let mut out = String::new();
3626 for block in blocks {
3627 match block {
3628 ContentBlock::Text { text, .. } => {
3629 append_router_text(&mut out, text);
3630 }
3631 ContentBlock::Thinking { thinking } => {
3632 append_router_text(&mut out, thinking);
3633 }
3634 ContentBlock::ToolUse { name, .. } => {
3635 append_router_text(&mut out, &format!("[tool call: {name}]"));
3636 }
3637 ContentBlock::ToolResult { content, .. } => {
3638 append_router_text(&mut out, &format!("[tool result] {content}"));
3639 }
3640 _ => {}
3641 }
3642 }
3643 out
3644 }
3645
3646 fn append_router_text(out: &mut String, text: &str) {
3647 if !out.is_empty() {
3648 out.push('\n');
3649 }
3650 out.push_str(text);
3651 }
3652
3653 fn truncate_for_auto_router(text: &str, max_chars: usize) -> String {
3654 let mut chars = text.chars();
3655 let truncated: String = chars.by_ref().take(max_chars).collect();
3656 if chars.next().is_some() {
3657 format!("{truncated}...")
3658 } else {
3659 truncated
3660 }
3661 }
3662
3663 async fn apply_model_and_compaction_update(
3664 engine_handle: &EngineHandle,
3665 compaction: crate::compaction::CompactionConfig,
3666 ) {
3667 let _ = engine_handle
3668 .send(Op::SetModel {
3669 model: compaction.model.clone(),
3670 })
3671 .await;
3672 let _ = engine_handle
3673 .send(Op::SetCompaction { config: compaction })
3674 .await;
3675 }
3676
3677 async fn drain_web_config_events(
3678 web_config_session: &mut Option<WebConfigSession>,
3679 app: &mut App,
3680 config: &mut Config,
3681 engine_handle: &EngineHandle,
3682 ) -> bool {
3683 let Some(session) = web_config_session.as_mut() else {
3684 return true;
3685 };
3686
3687 let mut keep_session = true;
3688 while let Ok(event) = session.receiver.try_recv() {
3689 match event {
3690 WebConfigSessionEvent::Draft(doc) => {
3691 match config_ui::apply_document(doc, app, config, false) {
3692 Ok(outcome) if outcome.changed => {
3693 if outcome.requires_engine_sync {
3694 apply_model_and_compaction_update(
3695 engine_handle,
3696 app.compaction_config(),
3697 )
3698 .await;
3699 }
3700 app.status_message = Some(format!(
3701 "Web config draft applied: {}",
3702 outcome.final_message
3703 ));
3704 }
3705 Ok(_) => {}
3706 Err(err) => {
3707 app.add_message(HistoryCell::System {
3708 content: format!("Web config draft apply failed: {err}"),
3709 });
3710 }
3711 }
3712 }
3713 WebConfigSessionEvent::Committed(doc) => {
3714 keep_session = false;
3715 match config_ui::apply_document(doc, app, config, true) {
3716 Ok(outcome) => {
3717 if outcome.requires_engine_sync {
3718 apply_model_and_compaction_update(
3719 engine_handle,
3720 app.compaction_config(),
3721 )
3722 .await;
3723 }
3724 app.add_message(HistoryCell::System {
3725 content: outcome.final_message.clone(),
3726 });
3727 app.status_message = Some(outcome.final_message);
3728 }
3729 Err(err) => {
3730 app.add_message(HistoryCell::System {
3731 content: format!("Web config commit failed: {err}"),
3732 });
3733 }
3734 }
3735 }
3736 WebConfigSessionEvent::Failed(err) => {
3737 keep_session = false;
3738 app.add_message(HistoryCell::System {
3739 content: format!("Web config session failed: {err}"),
3740 });
3741 }
3742 }
3743 }
3744
3745 keep_session
3746 }
3747
3748 /// Apply the choice made in the `/model` picker (#39): mutate App state so
3749 /// the next turn uses the new model/effort, persist the selection to
3750 /// `~/.deepseek/settings.toml` so it survives a restart, push the change to
3751 /// the running engine via `Op::SetModel`/`Op::SetCompaction`, and surface
3752 /// a one-line status describing what changed.
3753 async fn apply_model_picker_choice(
3754 app: &mut App,
3755 engine_handle: &EngineHandle,
3756 model: String,
3757 mut effort: crate::tui::app::ReasoningEffort,
3758 previous_model: String,
3759 previous_effort: crate::tui::app::ReasoningEffort,
3760 ) {
3761 let model_is_auto = model.trim().eq_ignore_ascii_case("auto");
3762 if model_is_auto {
3763 effort = ReasoningEffort::Auto;
3764 }
3765 let model_changed = model != previous_model || app.auto_model != model_is_auto;
3766 let effort_changed = effort != previous_effort;
3767 if !model_changed && !effort_changed {
3768 app.status_message = Some(format!(
3769 "Model unchanged: {model} · thinking {}",
3770 effort.short_label()
3771 ));
3772 return;
3773 }
3774
3775 if model_changed {
3776 app.auto_model = model_is_auto;
3777 app.last_effective_model = None;
3778 app.model = model.clone();
3779 app.update_model_compaction_budget();
3780 app.session.last_prompt_tokens = None;
3781 app.session.last_completion_tokens = None;
3782 app.session.last_prompt_cache_hit_tokens = None;
3783 app.session.last_prompt_cache_miss_tokens = None;
3784 app.session.last_reasoning_replay_tokens = None;
3785 }
3786 if effort_changed {
3787 app.reasoning_effort = effort;
3788 app.last_effective_reasoning_effort = None;
3789 }
3790
3791 // Best-effort persist; surface a status warning if the settings file
3792 // can't be written rather than aborting the in-memory change.
3793 let mut persist_warning: Option<String> = None;
3794 match crate::settings::Settings::load() {
3795 Ok(mut settings) => {
3796 if model_changed {
3797 let _ = settings.set("default_model", &model);
3798 }
3799 if effort_changed {
3800 let _ = settings.set("reasoning_effort", effort.as_setting());
3801 }
3802 if let Err(err) = settings.save() {
3803 persist_warning = Some(format!("(not persisted: {err})"));
3804 }
3805 }
3806 Err(err) => {
3807 persist_warning = Some(format!("(not persisted: {err})"));
3808 }
3809 }
3810
3811 if model_changed {
3812 apply_model_and_compaction_update(engine_handle, app.compaction_config()).await;
3813 }
3814
3815 let model_summary = if model_is_auto {
3816 "auto (per-turn model)".to_string()
3817 } else {
3818 model.clone()
3819 };
3820 let previous_effort_summary = previous_effort.short_label();
3821 let effort_summary = if effort == ReasoningEffort::Auto {
3822 "auto (per-turn thinking)".to_string()
3823 } else {
3824 effort.short_label().to_string()
3825 };
3826
3827 let mut summary = match (model_changed, effort_changed) {
3828 (true, true) => format!(
3829 "Model: {previous_model} → {model_summary} · thinking: {previous_effort_summary} → {effort_summary}"
3830 ),
3831 (true, false) => {
3832 format!("Model: {previous_model} → {model_summary} · thinking {effort_summary}")
3833 }
3834 (false, true) => format!(
3835 "Thinking: {previous_effort_summary} → {effort_summary} · model {model_summary}"
3836 ),
3837 (false, false) => unreachable!(),
3838 };
3839 if let Some(warning) = persist_warning {
3840 summary.push(' ');
3841 summary.push_str(&warning);
3842 }
3843 app.status_message = Some(summary);
3844 }
3845
3846 /// Apply a `/provider` switch by mutating the in-memory config, validating
3847 /// that credentials exist for the new provider, then respawning the engine
3848 /// so the API client picks up the new base URL/key. When `model_override`
3849 /// is set, it replaces the active model post-switch (already normalized,
3850 /// will be provider-prefixed by `Config::default_model`).
3851 async fn switch_provider(
3852 app: &mut App,
3853 engine_handle: &mut EngineHandle,
3854 config: &mut Config,
3855 target: ApiProvider,
3856 model_override: Option<String>,
3857 ) {
3858 let previous_provider = app.api_provider;
3859 let previous_model = app.model.clone();
3860 let previous_provider_str = config.provider.clone();
3861 let previous_base_url = config.base_url.clone();
3862 let previous_default_text_model = config.default_text_model.clone();
3863
3864 config.provider = Some(target.as_str().to_string());
3865 if matches!(target, ApiProvider::NvidiaNim)
3866 && config
3867 .base_url
3868 .as_deref()
3869 .map(|base| !base.contains("integrate.api.nvidia.com"))
3870 .unwrap_or(true)
3871 {
3872 config.base_url = Some(DEFAULT_NVIDIA_NIM_BASE_URL.to_string());
3873 }
3874 if matches!(target, ApiProvider::Deepseek)
3875 && config
3876 .base_url
3877 .as_deref()
3878 .map(|base| base.contains("integrate.api.nvidia.com"))
3879 .unwrap_or(false)
3880 {
3881 config.base_url = None;
3882 }
3883 if let Some(ref model) = model_override {
3884 config.default_text_model = Some(model.clone());
3885 }
3886
3887 if let Err(err) = DeepSeekClient::new(config) {
3888 config.provider = previous_provider_str;
3889 config.base_url = previous_base_url;
3890 config.default_text_model = previous_default_text_model;
3891 app.add_message(HistoryCell::System {
3892 content: format!(
3893 "Failed to switch provider to {}: {err}\nProvider unchanged ({}).",
3894 target.as_str(),
3895 previous_provider.as_str()
3896 ),
3897 });
3898 return;
3899 }
3900
3901 let new_model = config.default_model();
3902 app.api_provider = target;
3903 app.model = new_model.clone();
3904 app.update_model_compaction_budget();
3905 app.session.last_prompt_tokens = None;
3906 app.session.last_completion_tokens = None;
3907
3908 let _ = engine_handle.send(Op::Shutdown).await;
3909 let engine_config = build_engine_config(app, config);
3910 *engine_handle = spawn_engine(engine_config, config);
3911
3912 if !app.api_messages.is_empty() {
3913 let _ = engine_handle
3914 .send(Op::SyncSession {
3915 messages: app.api_messages.clone(),
3916 system_prompt: app.system_prompt.clone(),
3917 model: app.model.clone(),
3918 workspace: app.workspace.clone(),
3919 })
3920 .await;
3921 }
3922 let _ = engine_handle
3923 .send(Op::SetCompaction {
3924 config: app.compaction_config(),
3925 })
3926 .await;
3927
3928 app.add_message(HistoryCell::System {
3929 content: format!(
3930 "Provider switched: {} → {}\nModel: {} → {}",
3931 previous_provider.as_str(),
3932 target.as_str(),
3933 previous_model,
3934 new_model
3935 ),
3936 });
3937 app.status_message = Some(format!("Provider: {}", target.as_str()));
3938 }
3939
3940 fn open_text_pager(app: &mut App, title: String, content: String) {
3941 let width = app
3942 .viewport
3943 .last_transcript_area
3944 .map(|area| area.width)
3945 .unwrap_or(80);
3946 app.view_stack.push(PagerView::from_text(
3947 title,
3948 &content,
3949 width.saturating_sub(2),
3950 ));
3951 }
3952
3953 fn open_context_inspector(app: &mut App) {
3954 let width = app
3955 .viewport
3956 .last_transcript_area
3957 .map(|area| area.width)
3958 .unwrap_or(80);
3959 let content = build_context_inspector_text(app);
3960 app.view_stack.push(PagerView::from_text(
3961 "Context inspector",
3962 &content,
3963 width.saturating_sub(2),
3964 ));
3965 }
3966
3967 fn open_file_picker(app: &mut App) {
3968 let relevance = build_file_picker_relevance(app);
3969 app.view_stack
3970 .push(crate::tui::file_picker::FilePickerView::new_with_relevance(
3971 &app.workspace,
3972 relevance,
3973 ));
3974 }
3975
3976 fn build_file_picker_relevance(app: &App) -> crate::tui::file_picker::FilePickerRelevance {
3977 let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();
3978
3979 for path in modified_workspace_paths(&app.workspace) {
3980 relevance.mark_modified(path);
3981 }
3982
3983 for record in app.session_context_references.iter().rev().take(64) {
3984 let reference = &record.reference;
3985 if reference.source != crate::tui::file_mention::ContextReferenceSource::AtMention {
3986 continue;
3987 }
3988 if !matches!(
3989 reference.kind,
3990 crate::tui::file_mention::ContextReferenceKind::File
3991 ) {
3992 continue;
3993 }
3994 for raw in [&reference.target, &reference.label] {
3995 if let Some(path) = workspace_file_candidate(raw, &app.workspace) {
3996 relevance.mark_mentioned(path);
3997 }
3998 }
3999 }
4000
4001 let mut seen_tool_paths = HashSet::new();
4002 for detail in app.active_tool_details.values() {
4003 mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
4004 }
4005 let mut rows: Vec<_> = app.tool_details_by_cell.iter().collect();
4006 rows.sort_by_key(|(idx, _)| std::cmp::Reverse(**idx));
4007 for (_, detail) in rows.into_iter().take(48) {
4008 mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance);
4009 }
4010
4011 relevance
4012 }
4013
4014 fn modified_workspace_paths(workspace: &Path) -> Vec<String> {
4015 let Ok(output) = Command::new("git")
4016 .arg("-C")
4017 .arg(workspace)
4018 .args(["status", "--short", "--untracked-files=normal"])
4019 .output()
4020 else {
4021 return Vec::new();
4022 };
4023 if !output.status.success() {
4024 return Vec::new();
4025 }
4026
4027 String::from_utf8_lossy(&output.stdout)
4028 .lines()
4029 .filter_map(parse_git_status_path)
4030 .filter_map(|path| workspace_file_candidate(&path, workspace))
4031 .collect()
4032 }
4033
4034 fn parse_git_status_path(line: &str) -> Option<String> {
4035 if line.len() < 4 {
4036 return None;
4037 }
4038 let raw = line.get(3..)?.trim();
4039 let raw = raw.rsplit(" -> ").next().unwrap_or(raw).trim();
4040 let raw = raw.trim_matches('"');
4041 if raw.is_empty() {
4042 None
4043 } else {
4044 Some(raw.to_string())
4045 }
4046 }
4047
4048 fn mark_tool_detail_paths(
4049 detail: &ToolDetailRecord,
4050 workspace: &Path,
4051 seen: &mut HashSet<String>,
4052 relevance: &mut crate::tui::file_picker::FilePickerRelevance,
4053 ) {
4054 let mut budget = 256usize;
4055 mark_tool_paths_from_value(&detail.input, workspace, seen, relevance, &mut budget);
4056 if let Some(output) = detail
4057 .output
4058 .as_deref()
4059 .filter(|output| output.len() <= 8_192)
4060 {
4061 mark_tool_paths_from_text(output, workspace, seen, relevance, &mut budget);
4062 }
4063 }
4064
4065 fn mark_tool_paths_from_value(
4066 value: &serde_json::Value,
4067 workspace: &Path,
4068 seen: &mut HashSet<String>,
4069 relevance: &mut crate::tui::file_picker::FilePickerRelevance,
4070 budget: &mut usize,
4071 ) {
4072 if *budget == 0 {
4073 return;
4074 }
4075 match value {
4076 serde_json::Value::String(text) => {
4077 mark_tool_paths_from_text(text, workspace, seen, relevance, budget);
4078 }
4079 serde_json::Value::Array(items) => {
4080 for item in items {
4081 mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
4082 if *budget == 0 {
4083 break;
4084 }
4085 }
4086 }
4087 serde_json::Value::Object(map) => {
4088 for item in map.values() {
4089 mark_tool_paths_from_value(item, workspace, seen, relevance, budget);
4090 if *budget == 0 {
4091 break;
4092 }
4093 }
4094 }
4095 _ => {}
4096 }
4097 }
4098
4099 fn mark_tool_paths_from_text(
4100 text: &str,
4101 workspace: &Path,
4102 seen: &mut HashSet<String>,
4103 relevance: &mut crate::tui::file_picker::FilePickerRelevance,
4104 budget: &mut usize,
4105 ) {
4106 if *budget == 0 || text.len() > 8_192 {
4107 return;
4108 }
4109 if let Some(path) = workspace_file_candidate(text, workspace)
4110 && seen.insert(path.clone())
4111 {
4112 relevance.mark_tool(path);
4113 *budget = (*budget).saturating_sub(1);
4114 }
4115 for token in text.split_whitespace().take(128) {
4116 if *budget == 0 {
4117 break;
4118 }
4119 if let Some(path) = workspace_file_candidate(token, workspace)
4120 && seen.insert(path.clone())
4121 {
4122 relevance.mark_tool(path);
4123 *budget = (*budget).saturating_sub(1);
4124 }
4125 }
4126 }
4127
4128 fn workspace_file_candidate(raw: &str, workspace: &Path) -> Option<String> {
4129 let cleaned = clean_path_token(raw)?;
4130 let path = Path::new(&cleaned);
4131 let absolute = if path.is_absolute() {
4132 PathBuf::from(path)
4133 } else {
4134 workspace.join(path)
4135 };
4136 if !absolute.is_file() {
4137 return None;
4138 }
4139 let rel = absolute.strip_prefix(workspace).ok()?;
4140 workspace_path_to_picker_string(rel)
4141 }
4142
4143 fn clean_path_token(raw: &str) -> Option<String> {
4144 let mut trimmed = raw.trim().trim_matches(|ch: char| {
4145 ch.is_ascii_whitespace()
4146 || matches!(
4147 ch,
4148 '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
4149 )
4150 });
4151 if let Some(stripped) = trimmed.strip_prefix("./") {
4152 trimmed = stripped;
4153 }
4154 if let Some((before, after)) = trimmed.rsplit_once(':')
4155 && !before.is_empty()
4156 && after.chars().all(|ch| ch.is_ascii_digit())
4157 {
4158 trimmed = before;
4159 }
4160 if trimmed.is_empty() {
4161 None
4162 } else {
4163 Some(trimmed.to_string())
4164 }
4165 }
4166
4167 fn workspace_path_to_picker_string(path: &Path) -> Option<String> {
4168 let mut out = String::new();
4169 for (idx, component) in path.components().enumerate() {
4170 if matches!(
4171 component,
4172 std::path::Component::ParentDir
4173 | std::path::Component::RootDir
4174 | std::path::Component::Prefix(_)
4175 ) {
4176 return None;
4177 }
4178 if idx > 0 {
4179 out.push('/');
4180 }
4181 out.push_str(&component.as_os_str().to_string_lossy());
4182 }
4183 if out.is_empty() { None } else { Some(out) }
4184 }
4185
4186 async fn apply_command_result(
4187 terminal: &mut AppTerminal,
4188 app: &mut App,
4189 engine_handle: &mut EngineHandle,
4190 task_manager: &SharedTaskManager,
4191 config: &mut Config,
4192 #[cfg_attr(not(feature = "web"), allow(unused_variables))] web_config_session: &mut Option<
4193 WebConfigSession,
4194 >,
4195 result: commands::CommandResult,
4196 ) -> Result<bool> {
4197 if let Some(msg) = result.message {
4198 app.add_message(HistoryCell::System { content: msg });
4199 }
4200
4201 if let Some(action) = result.action {
4202 match action {
4203 AppAction::Quit => {
4204 let _ = engine_handle.send(Op::Shutdown).await;
4205 return Ok(true);
4206 }
4207 AppAction::SaveSession(path) => {
4208 app.status_message = Some(format!("Session saved to {}", path.display()));
4209 }
4210 AppAction::LoadSession(path) => {
4211 app.status_message = Some(format!("Session loaded from {}", path.display()));
4212 }
4213 AppAction::SyncSession {
4214 messages,
4215 system_prompt,
4216 model,
4217 workspace,
4218 } => {
4219 let is_full_reset = messages.is_empty() && system_prompt.is_none();
4220 let _ = engine_handle
4221 .send(Op::SyncSession {
4222 messages,
4223 system_prompt,
4224 model,
4225 workspace,
4226 })
4227 .await;
4228 let _ = engine_handle
4229 .send(Op::SetCompaction {
4230 config: app.compaction_config(),
4231 })
4232 .await;
4233 if is_full_reset {
4234 if let Ok(manager) = SessionManager::default_location() {
4235 let session = build_session_snapshot(app, &manager);
4236 app.current_session_id = Some(session.metadata.id.clone());
4237 persistence_actor::persist(PersistRequest::SessionSnapshot(session));
4238 }
4239 persistence_actor::persist(PersistRequest::ClearCheckpoint);
4240 }
4241 }
4242 AppAction::SendMessage(content) => {
4243 let queued = build_queued_message(app, content);
4244 submit_or_steer_message(app, config, engine_handle, queued).await?;
4245 }
4246 AppAction::Rlm {
4247 prompt,
4248 model,
4249 child_model,
4250 max_depth,
4251 } => {
4252 app.status_message = Some("RLM turn starting...".to_string());
4253 let _ = engine_handle
4254 .send(Op::Rlm {
4255 content: prompt,
4256 model,
4257 child_model,
4258 max_depth,
4259 })
4260 .await;
4261 }
4262 AppAction::ListSubAgents => {
4263 let _ = engine_handle.send(Op::ListSubAgents).await;
4264 }
4265 AppAction::FetchModels => {
4266 app.status_message = Some("Fetching models...".to_string());
4267 match fetch_available_models(config).await {
4268 Ok(models) => {
4269 app.add_message(HistoryCell::System {
4270 content: format_available_models_message(&app.model, &models),
4271 });
4272 app.status_message = Some(format!("Found {} model(s)", models.len()));
4273 }
4274 Err(error) => {
4275 app.add_message(HistoryCell::System {
4276 content: format!("Failed to fetch models: {error}"),
4277 });
4278 }
4279 }
4280 }
4281 AppAction::SwitchProvider { provider, model } => {
4282 switch_provider(app, engine_handle, config, provider, model).await;
4283 }
4284 AppAction::UpdateCompaction(compaction) => {
4285 apply_model_and_compaction_update(engine_handle, compaction).await;
4286 }
4287 AppAction::OpenConfigEditor(mode) => match mode {
4288 ConfigUiMode::Native => {
4289 if app.view_stack.top_kind() != Some(ModalKind::Config) {
4290 app.view_stack.push(ConfigView::new_for_app(app));
4291 }
4292 }
4293 ConfigUiMode::Tui => {
4294 pause_terminal(
4295 terminal,
4296 app.use_alt_screen,
4297 app.use_mouse_capture,
4298 app.use_bracketed_paste,
4299 )?;
4300 let editor_result = config_ui::run_tui_editor(app, config)
4301 .and_then(|doc| config_ui::apply_document(doc, app, config, true));
4302 resume_terminal(
4303 terminal,
4304 app.use_alt_screen,
4305 app.use_mouse_capture,
4306 app.use_bracketed_paste,
4307 )?;
4308 match editor_result {
4309 Ok(outcome) => {
4310 if outcome.requires_engine_sync {
4311 apply_model_and_compaction_update(
4312 engine_handle,
4313 app.compaction_config(),
4314 )
4315 .await;
4316 }
4317 app.add_message(HistoryCell::System {
4318 content: outcome.final_message.clone(),
4319 });
4320 app.status_message = Some(outcome.final_message);
4321 }
4322 Err(err) => {
4323 app.add_message(HistoryCell::System {
4324 content: format!("Config UI failed: {err}"),
4325 });
4326 }
4327 }
4328 }
4329 ConfigUiMode::Web => {
4330 #[cfg(feature = "web")]
4331 {
4332 let session = config_ui::start_web_editor(app, config).await?;
4333 let url = format!("http://{}", session.addr);
4334 let open_err = config_ui::open_browser(&url).err();
4335 if let Some(err) = open_err {
4336 app.add_message(HistoryCell::System {
4337 content: format!("Failed to open browser automatically: {err}"),
4338 });
4339 }
4340 app.status_message = Some(format!("web ui listen on: {url}"));
4341 *web_config_session = Some(session);
4342 }
4343 #[cfg(not(feature = "web"))]
4344 {
4345 app.add_message(HistoryCell::System {
4346 content: "This build does not include the web config UI.".to_string(),
4347 });
4348 }
4349 }
4350 },
4351 AppAction::OpenConfigView => {
4352 if app.view_stack.top_kind() != Some(ModalKind::Config) {
4353 app.view_stack.push(ConfigView::new_for_app(app));
4354 }
4355 }
4356 AppAction::OpenModelPicker => {
4357 if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
4358 app.view_stack
4359 .push(crate::tui::model_picker::ModelPickerView::new(app));
4360 }
4361 }
4362 AppAction::OpenProviderPicker => {
4363 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
4364 app.view_stack
4365 .push(crate::tui::provider_picker::ProviderPickerView::new(
4366 app.api_provider,
4367 config,
4368 ));
4369 }
4370 }
4371 AppAction::OpenStatusPicker => {
4372 if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) {
4373 app.view_stack
4374 .push(crate::tui::views::status_picker::StatusPickerView::new(
4375 &app.status_items,
4376 ));
4377 }
4378 }
4379 AppAction::OpenContextInspector => {
4380 open_context_inspector(app);
4381 }
4382 AppAction::CompactContext => {
4383 app.status_message = Some("Compacting context...".to_string());
4384 let _ = engine_handle.send(Op::CompactContext).await;
4385 }
4386 AppAction::TaskAdd { prompt } => {
4387 let request = NewTaskRequest {
4388 prompt: prompt.clone(),
4389 model: Some(app.model.clone()),
4390 workspace: Some(app.workspace.clone()),
4391 mode: Some(task_mode_label(app.mode).to_string()),
4392 allow_shell: Some(app.allow_shell),
4393 trust_mode: Some(app.trust_mode),
4394 auto_approve: Some(app.approval_mode == ApprovalMode::Auto),
4395 };
4396 match task_manager.add_task(request).await {
4397 Ok(task) => {
4398 app.add_message(HistoryCell::System {
4399 content: format!(
4400 "Task queued: {} ({})",
4401 task.id,
4402 summarize_tool_output(&task.prompt)
4403 ),
4404 });
4405 app.status_message = Some(format!("Queued {}", task.id));
4406 }
4407 Err(err) => {
4408 app.add_message(HistoryCell::System {
4409 content: format!("Failed to queue task: {err}"),
4410 });
4411 }
4412 }
4413 refresh_active_task_panel(app, task_manager).await;
4414 }
4415 AppAction::TaskList => {
4416 let tasks = task_manager.list_tasks(Some(30)).await;
4417 refresh_active_task_panel(app, task_manager).await;
4418 app.add_message(HistoryCell::System {
4419 content: format_task_list(&tasks),
4420 });
4421 }
4422 AppAction::TaskShow { id } => match task_manager.get_task(&id).await {
4423 Ok(task) => open_task_pager(app, &task),
4424 Err(err) => {
4425 app.add_message(HistoryCell::System {
4426 content: format!("Task lookup failed: {err}"),
4427 });
4428 }
4429 },
4430 AppAction::TaskCancel { id } => {
4431 match task_manager.cancel_task(&id).await {
4432 Ok(task) => {
4433 app.add_message(HistoryCell::System {
4434 content: format!("Task {} status: {:?}", task.id, task.status),
4435 });
4436 }
4437 Err(err) => {
4438 app.add_message(HistoryCell::System {
4439 content: format!("Task cancel failed: {err}"),
4440 });
4441 }
4442 }
4443 refresh_active_task_panel(app, task_manager).await;
4444 }
4445 AppAction::ShellJob(action) => {
4446 handle_shell_job_action(app, action);
4447 }
4448 AppAction::Mcp(action) => {
4449 handle_mcp_ui_action(app, config, action).await;
4450 }
4451 AppAction::SwitchProfile { profile } => {
4452 app.config_profile = Some(profile.clone());
4453 match Config::load(app.config_path.clone(), Some(&profile)) {
4454 Ok(new_config) => {
4455 *config = new_config.clone();
4456 app.api_provider = config.api_provider();
4457 let new_model = config.default_model();
4458 app.model = new_model.clone();
4459 app.update_model_compaction_budget();
4460 app.session.last_prompt_tokens = None;
4461 app.session.last_completion_tokens = None;
4462 // Rebuild the engine with the new config so API key/model/base URL take effect.
4463 let _ = engine_handle.send(Op::Shutdown).await;
4464 let engine_config = build_engine_config(app, config);
4465 *engine_handle = spawn_engine(engine_config, config);
4466 if !app.api_messages.is_empty() {
4467 let _ = engine_handle
4468 .send(Op::SyncSession {
4469 messages: app.api_messages.clone(),
4470 system_prompt: app.system_prompt.clone(),
4471 model: app.model.clone(),
4472 workspace: app.workspace.clone(),
4473 })
4474 .await;
4475 }
4476 app.add_message(HistoryCell::System {
4477 content: format!(
4478 "Switched to profile '{profile}'. Model: {new_model}, Provider: {}",
4479 config.api_provider().as_str()
4480 ),
4481 });
4482 app.status_message = Some(format!("Profile: {profile}"));
4483 }
4484 Err(err) => {
4485 app.config_profile = None;
4486 app.status_message =
4487 Some(format!("Failed to switch to profile '{profile}': {err}"));
4488 }
4489 }
4490 }
4491 AppAction::ShareSession {
4492 history_len: _,
4493 model,
4494 mode,
4495 } => {
4496 let status = if app.api_messages.is_empty() {
4497 "No session content to share.".to_string()
4498 } else {
4499 let history_json = serde_json::to_string_pretty(&app.api_messages)
4500 .unwrap_or_else(|_| "[]".to_string());
4501 match crate::commands::share::perform_share(&history_json, &model, &mode).await
4502 {
4503 Ok(url) => format!("Session shared! URL: {url}"),
4504 Err(err) => format!("Share failed: {err}"),
4505 }
4506 };
4507 app.add_message(HistoryCell::System {
4508 content: status.clone(),
4509 });
4510 app.status_message = Some(status);
4511 }
4512 }
4513 }
4514
4515 Ok(false)
4516 }
4517
4518 async fn handle_mcp_ui_action(
4519 app: &mut App,
4520 config: &Config,
4521 action: crate::tui::app::McpUiAction,
4522 ) {
4523 use crate::mcp::{self, McpWriteStatus};
4524
4525 let path = app.mcp_config_path.clone();
4526 let mut changed = false;
4527 let mut message = None;
4528 let discover = matches!(
4529 action,
4530 crate::tui::app::McpUiAction::Validate | crate::tui::app::McpUiAction::Reload
4531 );
4532
4533 let action_result = match action {
4534 crate::tui::app::McpUiAction::Show => Ok(()),
4535 crate::tui::app::McpUiAction::Init { force } => {
4536 changed = true;
4537 match mcp::init_config(&path, force) {
4538 Ok(McpWriteStatus::Created) => {
4539 message = Some(format!("Created MCP config at {}", path.display()));
4540 Ok(())
4541 }
4542 Ok(McpWriteStatus::Overwritten) => {
4543 message = Some(format!("Overwrote MCP config at {}", path.display()));
4544 Ok(())
4545 }
4546 Ok(McpWriteStatus::SkippedExists) => {
4547 changed = false;
4548 message = Some(format!(
4549 "MCP config already exists at {} (use /mcp init --force to overwrite)",
4550 path.display()
4551 ));
4552 Ok(())
4553 }
4554 Err(err) => Err(err),
4555 }
4556 }
4557 crate::tui::app::McpUiAction::AddStdio {
4558 name,
4559 command,
4560 args,
4561 } => {
4562 changed = true;
4563 mcp::add_server_config(&path, name.clone(), Some(command), None, args)
4564 .map(|()| message = Some(format!("Added MCP stdio server '{name}'")))
4565 }
4566 crate::tui::app::McpUiAction::AddHttp { name, url } => {
4567 changed = true;
4568 mcp::add_server_config(&path, name.clone(), None, Some(url), Vec::new())
4569 .map(|()| message = Some(format!("Added MCP HTTP/SSE server '{name}'")))
4570 }
4571 crate::tui::app::McpUiAction::Enable { name } => {
4572 changed = true;
4573 mcp::set_server_enabled(&path, &name, true)
4574 .map(|()| message = Some(format!("Enabled MCP server '{name}'")))
4575 }
4576 crate::tui::app::McpUiAction::Disable { name } => {
4577 changed = true;
4578 mcp::set_server_enabled(&path, &name, false)
4579 .map(|()| message = Some(format!("Disabled MCP server '{name}'")))
4580 }
4581 crate::tui::app::McpUiAction::Remove { name } => {
4582 changed = true;
4583 mcp::remove_server_config(&path, &name)
4584 .map(|()| message = Some(format!("Removed MCP server '{name}'")))
4585 }
4586 crate::tui::app::McpUiAction::Validate | crate::tui::app::McpUiAction::Reload => Ok(()),
4587 };
4588
4589 if let Err(err) = action_result {
4590 add_mcp_message(app, format!("MCP action failed: {err}"));
4591 return;
4592 }
4593
4594 if changed {
4595 app.mcp_restart_required = true;
4596 }
4597 if let Some(message) = message {
4598 add_mcp_message(app, message);
4599 }
4600
4601 let snapshot_result = if discover {
4602 let network_policy = config.network.clone().map(|toml_cfg| {
4603 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
4604 });
4605 mcp::discover_manager_snapshot(&path, network_policy, app.mcp_restart_required).await
4606 } else {
4607 mcp::manager_snapshot_from_config(&path, app.mcp_restart_required)
4608 };
4609
4610 match snapshot_result {
4611 Ok(snapshot) => {
4612 if discover {
4613 add_mcp_message(
4614 app,
4615 "MCP discovery refreshed for the UI. Restart the TUI after config edits to rebuild the model-visible MCP tool pool.".to_string(),
4616 );
4617 }
4618 // Keep the boot-time MCP-count chip in sync with the live
4619 // snapshot so footers and panels reflect post-/mcp edits
4620 // (#502).
4621 app.mcp_configured_count = snapshot.servers.len();
4622 app.mcp_snapshot = Some(snapshot.clone());
4623 open_mcp_manager_pager(app, &snapshot);
4624 }
4625 Err(err) => add_mcp_message(app, format!("MCP snapshot failed: {err}")),
4626 }
4627 }
4628
4629 fn handle_shell_job_action(app: &mut App, action: crate::tui::app::ShellJobAction) {
4630 let Some(shell_manager) = app.runtime_services.shell_manager.clone() else {
4631 add_shell_job_message(app, "Shell job center is not attached.".to_string());
4632 return;
4633 };
4634
4635 let mut manager = match shell_manager.lock() {
4636 Ok(manager) => manager,
4637 Err(_) => {
4638 add_shell_job_message(app, "Shell job center lock is poisoned.".to_string());
4639 return;
4640 }
4641 };
4642
4643 match action {
4644 crate::tui::app::ShellJobAction::List => {
4645 let jobs = manager.list_jobs();
4646 add_shell_job_message(app, format_shell_job_list(&jobs));
4647 }
4648 crate::tui::app::ShellJobAction::Show { id } => match manager.inspect_job(&id) {
4649 Ok(detail) => open_shell_job_pager(app, &detail),
4650 Err(err) => add_shell_job_message(app, format!("Shell job lookup failed: {err}")),
4651 },
4652 crate::tui::app::ShellJobAction::Poll { id, wait } => {
4653 match manager.poll_delta(&id, wait, if wait { 5_000 } else { 1_000 }) {
4654 Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)),
4655 Err(err) => add_shell_job_message(app, format!("Shell job poll failed: {err}")),
4656 }
4657 }
4658 crate::tui::app::ShellJobAction::SendStdin { id, input, close } => {
4659 match manager.write_stdin(&id, &input, close) {
4660 Ok(()) => match manager.poll_delta(&id, false, 1_000) {
4661 Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)),
4662 Err(err) => {
4663 add_shell_job_message(app, format!("Shell stdin sent; poll failed: {err}"));
4664 }
4665 },
4666 Err(err) => add_shell_job_message(app, format!("Shell stdin failed: {err}")),
4667 }
4668 }
4669 crate::tui::app::ShellJobAction::Cancel { id } => match manager.kill(&id) {
4670 Ok(result) => add_shell_job_message(app, format_shell_poll(&result)),
4671 Err(err) => add_shell_job_message(app, format!("Shell job cancel failed: {err}")),
4672 },
4673 }
4674 }
4675
4676 async fn execute_command_input(
4677 terminal: &mut AppTerminal,
4678 app: &mut App,
4679 engine_handle: &mut EngineHandle,
4680 task_manager: &SharedTaskManager,
4681 config: &mut Config,
4682 web_config_session: &mut Option<WebConfigSession>,
4683 input: &str,
4684 ) -> Result<bool> {
4685 let result = commands::execute(input, app);
4686 // After /logout: clear the in-memory api_key fields so the next
4687 // onboarding round entering a new key doesn't see the stale value
4688 // (#343). The on-disk side is handled by clear_api_key() inside
4689 // commands::config::logout.
4690 if input.trim().eq_ignore_ascii_case("/logout") {
4691 config.api_key = None;
4692 if let Some(providers) = config.providers.as_mut() {
4693 providers.deepseek.api_key = None;
4694 providers.deepseek_cn.api_key = None;
4695 providers.nvidia_nim.api_key = None;
4696 providers.openrouter.api_key = None;
4697 providers.novita.api_key = None;
4698 providers.fireworks.api_key = None;
4699 providers.sglang.api_key = None;
4700 providers.vllm.api_key = None;
4701 }
4702 app.api_key_env_only = crate::config::active_provider_uses_env_only_api_key(config);
4703 }
4704 apply_command_result(
4705 terminal,
4706 app,
4707 engine_handle,
4708 task_manager,
4709 config,
4710 web_config_session,
4711 result,
4712 )
4713 .await
4714 }
4715
4716 async fn steer_user_message(
4717 app: &mut App,
4718 engine_handle: &EngineHandle,
4719 message: QueuedMessage,
4720 ) -> Result<()> {
4721 let cwd = std::env::current_dir().ok();
4722 let references = crate::tui::file_mention::context_references_from_input(
4723 &message.display,
4724 &app.workspace,
4725 cwd.clone(),
4726 );
4727 let content = queued_message_content_for_app(app, &message, cwd);
4728 let message_index = app.api_messages.len();
4729
4730 engine_handle.steer(content.clone()).await?;
4731
4732 // Mirror steer input in local transcript/session state.
4733 app.add_message(HistoryCell::User {
4734 content: format!("+ {}", message.display),
4735 });
4736 let history_cell = app.history.len().saturating_sub(1);
4737 app.record_context_references(history_cell, message_index, references);
4738 app.api_messages.push(Message {
4739 role: "user".to_string(),
4740 content: vec![ContentBlock::Text {
4741 text: content.clone(),
4742 cache_control: None,
4743 }],
4744 });
4745
4746 app.status_message = Some("Steering current turn...".to_string());
4747 Ok(())
4748 }
4749
4750 /// Park a draft on the queued-messages bucket for dispatch after TurnComplete.
4751 /// Unlike a steer, the message is NOT forwarded immediately — it waits for
4752 /// the current turn to finish, then dispatches as a normal user message.
4753 async fn queue_follow_up(app: &mut App, message: QueuedMessage) -> Result<()> {
4754 let display = message.display.clone();
4755 app.queue_message(message);
4756 app.status_message = Some(format!(
4757 "Queued: {} ({} total) — ↑ to edit",
4758 display,
4759 app.queued_message_count()
4760 ));
4761 Ok(())
4762 }
4763
4764 async fn submit_or_steer_message(
4765 app: &mut App,
4766 config: &Config,
4767 engine_handle: &EngineHandle,
4768 message: QueuedMessage,
4769 ) -> Result<()> {
4770 match app.decide_submit_disposition() {
4771 SubmitDisposition::Immediate => {
4772 dispatch_user_message(app, config, engine_handle, message).await
4773 }
4774 SubmitDisposition::Queue => {
4775 let count = app.queued_message_count().saturating_add(1);
4776 app.queue_message(message);
4777 if app.offline_mode {
4778 app.status_message =
4779 Some(format!("Offline: {count} queued — ↑ to edit, /queue list"));
4780 } else {
4781 app.status_message = Some(format!("{count} queued — ↑ to edit, /queue list"));
4782 }
4783 Ok(())
4784 }
4785 // Steer and QueueFollowUp are now only reached via Ctrl+Enter override.
4786 SubmitDisposition::Steer => {
4787 if let Err(err) = steer_user_message(app, engine_handle, message.clone()).await {
4788 app.queue_message(message);
4789 app.status_message = Some(format!(
4790 "Steer failed ({err}); {} queued — ↑ to edit, /queue list",
4791 app.queued_message_count()
4792 ));
4793 } else {
4794 app.push_status_toast(
4795 "Steering into current turn",
4796 StatusToastLevel::Info,
4797 Some(1_500),
4798 );
4799 }
4800 Ok(())
4801 }
4802 SubmitDisposition::QueueFollowUp => queue_follow_up(app, message).await,
4803 }
4804 }
4805
4806 /// Drain `app.pending_steers` into a single `QueuedMessage` ready for
4807 /// `dispatch_user_message`. Returns `None` if the queue was empty (caller
4808 /// then falls back to `app.queued_messages`). Skill instruction is taken
4809 /// from the first message that supplies one — multiple steers shouldn't
4810 /// double-up the system framing.
4811 fn merge_pending_steers(app: &mut App) -> Option<QueuedMessage> {
4812 let drained = app.drain_pending_steers();
4813 if drained.is_empty() {
4814 return None;
4815 }
4816 if drained.len() == 1 {
4817 return drained.into_iter().next();
4818 }
4819 let mut skill_instruction: Option<String> = None;
4820 let mut bodies: Vec<String> = Vec::with_capacity(drained.len());
4821 for msg in drained {
4822 if skill_instruction.is_none() {
4823 skill_instruction = msg.skill_instruction;
4824 }
4825 bodies.push(msg.display);
4826 }
4827 Some(QueuedMessage::new(bodies.join("\n\n"), skill_instruction))
4828 }
4829
4830 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4831 enum PlanChoice {
4832 AcceptAgent,
4833 AcceptYolo,
4834 RevisePlan,
4835 ExitPlan,
4836 }
4837
4838 fn plan_next_step_prompt() -> String {
4839 [
4840 "Action required: choose the next step for this plan.",
4841 " 1) Accept + implement in Agent mode",
4842 " 2) Accept + implement in YOLO mode",
4843 " 3) Revise the plan / ask follow-ups",
4844 " 4) Return to Agent mode without implementing",
4845 "",
4846 "Use the plan confirmation popup, or type 1-4 and press Enter.",
4847 ]
4848 .join("\n")
4849 }
4850
4851 fn plan_choice_from_option(option: usize) -> Option<PlanChoice> {
4852 match option {
4853 1 => Some(PlanChoice::AcceptAgent),
4854 2 => Some(PlanChoice::AcceptYolo),
4855 3 => Some(PlanChoice::RevisePlan),
4856 4 => Some(PlanChoice::ExitPlan),
4857 _ => None,
4858 }
4859 }
4860
4861 fn parse_plan_choice(input: &str) -> Option<PlanChoice> {
4862 // Once the modal is dismissed, only the advertised 1-4 fallback remains active.
4863 // Letter shortcuts stay modal-only so normal messages like "yolo" are not captured.
4864 match input.trim() {
4865 "1" => Some(PlanChoice::AcceptAgent),
4866 "2" => Some(PlanChoice::AcceptYolo),
4867 "3" => Some(PlanChoice::RevisePlan),
4868 "4" => Some(PlanChoice::ExitPlan),
4869 _ => None,
4870 }
4871 }
4872
4873 async fn apply_plan_choice(
4874 app: &mut App,
4875 config: &Config,
4876 engine_handle: &EngineHandle,
4877 choice: PlanChoice,
4878 ) -> Result<()> {
4879 match choice {
4880 PlanChoice::AcceptAgent => {
4881 app.set_mode(AppMode::Agent);
4882 app.add_message(HistoryCell::System {
4883 content: "Plan accepted. Switching to Agent mode and starting implementation."
4884 .to_string(),
4885 });
4886 let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None);
4887 if app.is_loading {
4888 app.queue_message(followup);
4889 app.status_message =
4890 Some("Queued accepted plan execution (agent mode).".to_string());
4891 } else {
4892 dispatch_user_message(app, config, engine_handle, followup).await?;
4893 }
4894 }
4895 PlanChoice::AcceptYolo => {
4896 app.set_mode(AppMode::Yolo);
4897 app.add_message(HistoryCell::System {
4898 content: "Plan accepted. Switching to YOLO mode and starting implementation."
4899 .to_string(),
4900 });
4901 let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None);
4902 if app.is_loading {
4903 app.queue_message(followup);
4904 app.status_message =
4905 Some("Queued accepted plan execution (YOLO mode).".to_string());
4906 } else {
4907 dispatch_user_message(app, config, engine_handle, followup).await?;
4908 }
4909 }
4910 PlanChoice::RevisePlan => {
4911 let prompt = "Revise the plan: ";
4912 app.input = prompt.to_string();
4913 app.cursor_position = prompt.chars().count();
4914 app.status_message = Some("Revise the plan and press Enter.".to_string());
4915 }
4916 PlanChoice::ExitPlan => {
4917 app.set_mode(AppMode::Agent);
4918 app.add_message(HistoryCell::System {
4919 content: "Exited Plan mode. Switched to Agent mode.".to_string(),
4920 });
4921 }
4922 }
4923
4924 Ok(())
4925 }
4926
4927 async fn handle_plan_choice(
4928 app: &mut App,
4929 config: &Config,
4930 engine_handle: &EngineHandle,
4931 input: &str,
4932 ) -> Result<bool> {
4933 if !app.plan_prompt_pending {
4934 return Ok(false);
4935 }
4936
4937 let choice = parse_plan_choice(input);
4938 app.plan_prompt_pending = false;
4939
4940 let Some(choice) = choice else {
4941 return Ok(false);
4942 };
4943
4944 apply_plan_choice(app, config, engine_handle, choice).await?;
4945 Ok(true)
4946 }
4947
4948 /// Build the pending-input preview widget from current `App` state.
4949 ///
4950 /// v0.6.6 (#122) wires all three buckets:
4951 /// - `pending_steers` — typed during a running turn + Esc; held until the
4952 /// abort lands and gets resubmitted as a fresh merged turn.
4953 /// - `rejected_steers` — engine declined a mid-turn steer (scaffolding;
4954 /// no engine path produces these yet but the bucket renders identically).
4955 /// - `queued_messages` — Enter while busy (offline-mode FIFO); drained at
4956 /// end-of-turn.
4957 fn build_pending_input_preview(app: &App) -> PendingInputPreview {
4958 let mut preview = PendingInputPreview::new();
4959 let selected_attachment = app.selected_composer_attachment_index();
4960 let mut attachment_index = 0usize;
4961 preview.context_items = crate::tui::file_mention::pending_context_previews(
4962 &app.input,
4963 &app.workspace,
4964 std::env::current_dir().ok(),
4965 )
4966 .into_iter()
4967 .map(|item| {
4968 let selected = if item.removable {
4969 let selected = selected_attachment == Some(attachment_index);
4970 attachment_index += 1;
4971 selected
4972 } else {
4973 false
4974 };
4975 ContextPreviewItem {
4976 kind: item.kind,
4977 label: item.label,
4978 detail: item.detail,
4979 included: item.included,
4980 removable: item.removable,
4981 selected,
4982 }
4983 })
4984 .collect();
4985 preview.pending_steers = app
4986 .pending_steers
4987 .iter()
4988 .map(|m| m.display.clone())
4989 .collect();
4990 preview.rejected_steers = app.rejected_steers.iter().cloned().collect();
4991 preview.queued_messages = app
4992 .queued_messages
4993 .iter()
4994 .map(|m| m.display.clone())
4995 .collect();
4996 preview
4997 }
4998
4999 fn render(f: &mut Frame, app: &mut App) {
5000 let size = f.area();
5001
5002 // Clear entire area with terminal default background
5003 let background = Block::default().style(Style::default().bg(Color::Reset));
5004 f.render_widget(background, size);
5005
5006 // Show onboarding screen if needed
5007 if app.onboarding != OnboardingState::None {
5008 onboarding::render(f, size, app);
5009 return;
5010 }
5011
5012 let header_height = 1;
5013 let footer_height = 1;
5014 let body_height = size.height.saturating_sub(header_height + footer_height);
5015 let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
5016 let mention_menu_entries =
5017 crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT);
5018 if !mention_menu_entries.is_empty() && app.mention_menu_selected >= mention_menu_entries.len() {
5019 app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
5020 }
5021 let context_usage = context_usage_snapshot(app);
5022 let composer_max_height = body_height
5023 .saturating_sub(MIN_CHAT_HEIGHT)
5024 .max(MIN_COMPOSER_HEIGHT);
5025 let composer_height = {
5026 let composer_widget = ComposerWidget::new(
5027 app,
5028 composer_max_height,
5029 &slash_menu_entries,
5030 &mention_menu_entries,
5031 );
5032 composer_widget.desired_height(size.width)
5033 };
5034
5035 // Pending-input preview (queued / steered messages). Empty when nothing's
5036 // queued, so zero height when idle. Phase 2 of #85 — solves the
5037 // "messages typed during a running turn vanish" complaint by giving the
5038 // user immediate visible feedback above the composer.
5039 let pending_preview = build_pending_input_preview(app);
5040 let preview_height = pending_preview.desired_height(size.width);
5041
5042 let chunks = Layout::default()
5043 .direction(Direction::Vertical)
5044 .constraints([
5045 Constraint::Length(header_height), // Header
5046 Constraint::Min(1), // Chat area
5047 Constraint::Length(preview_height), // Pending input preview (0 if empty)
5048 Constraint::Length(composer_height), // Composer
5049 Constraint::Length(footer_height), // Footer
5050 ])
5051 .split(size);
5052
5053 // Render header
5054 {
5055 let sanitized_context_window = context_usage
5056 .as_ref()
5057 .map(|(_, max, _)| *max)
5058 .or_else(|| crate::models::context_window_for_model(&app.model));
5059 let sanitized_prompt_tokens = context_usage
5060 .as_ref()
5061 .and_then(|(used, _, _)| u32::try_from(*used).ok());
5062 let workspace_name = app
5063 .workspace
5064 .file_name()
5065 .and_then(|value| value.to_str())
5066 .filter(|value| !value.is_empty())
5067 .unwrap_or("workspace");
5068 let model_label = app.model_display_label();
5069 let effort_label = app.reasoning_effort_display_label();
5070 let provider_label = match app.api_provider {
5071 crate::config::ApiProvider::Deepseek => None,
5072 crate::config::ApiProvider::DeepseekCN => None,
5073 crate::config::ApiProvider::NvidiaNim => Some("NIM"),
5074 crate::config::ApiProvider::Openrouter => Some("OR"),
5075 crate::config::ApiProvider::Novita => Some("Novita"),
5076 crate::config::ApiProvider::Fireworks => Some("Fireworks"),
5077 crate::config::ApiProvider::Sglang => Some("SGLang"),
5078 crate::config::ApiProvider::Vllm => Some("vLLM"),
5079 };
5080 let header_data = HeaderData::new(
5081 app.mode,
5082 &model_label,
5083 workspace_name,
5084 app.is_loading,
5085 app.ui_theme.header_bg,
5086 )
5087 .with_usage(
5088 app.session.total_conversation_tokens,
5089 sanitized_context_window,
5090 app.session.session_cost,
5091 sanitized_prompt_tokens,
5092 )
5093 .with_reasoning_effort(Some(&effort_label))
5094 .with_provider(provider_label);
5095 let header_widget = HeaderWidget::new(header_data);
5096 let buf = f.buffer_mut();
5097 header_widget.render(chunks[0], buf);
5098 }
5099
5100 // Render chat + sidebar + optional file-tree pane
5101 {
5102 // Defensive backstop (#400): fill the entire body area with ink
5103 // background before any sub-widgets render, so cells that end up
5104 // uncovered by layout splits (e.g. after file-tree toggle or
5105 // resize) don't retain stale content from a previous frame.
5106 Block::default().render(chunks[1], f.buffer_mut());
5107
5108 let mut sidebar_area = None;
5109
5110 // When the file-tree pane is visible and the terminal is wide
5111 // enough, reserve the left ~25% for the file tree.
5112 let mut chat_area =
5113 if app.file_tree.is_some() && chunks[1].width >= SIDEBAR_VISIBLE_MIN_WIDTH {
5114 let split = Layout::default()
5115 .direction(Direction::Horizontal)
5116 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
5117 .split(chunks[1]);
5118 let tree_area = split[0];
5119 let remaining = split[1];
5120
5121 // Render the file-tree pane.
5122 if let Some(ref mut state) = app.file_tree {
5123 super::file_tree::render_file_tree(f, tree_area, state);
5124 }
5125
5126 remaining
5127 } else {
5128 chunks[1]
5129 };
5130
5131 if chat_area.width >= SIDEBAR_VISIBLE_MIN_WIDTH {
5132 let preferred_sidebar = (u32::from(chat_area.width)
5133 * u32::from(app.sidebar_width_percent.clamp(10, 50))
5134 / 100) as u16;
5135 let sidebar_width = preferred_sidebar
5136 .max(24)
5137 .min(chat_area.width.saturating_sub(40));
5138 if sidebar_width >= 20 {
5139 let split = Layout::default()
5140 .direction(Direction::Horizontal)
5141 .constraints([Constraint::Min(1), Constraint::Length(sidebar_width)])
5142 .split(chat_area);
5143 chat_area = split[0];
5144 sidebar_area = Some(split[1]);
5145 }
5146 }
5147
5148 let chat_widget = ChatWidget::new(app, chat_area);
5149 let buf = f.buffer_mut();
5150 chat_widget.render(chat_area, buf);
5151
5152 if let Some(sidebar_area) = sidebar_area {
5153 super::sidebar::render_sidebar(f, sidebar_area, app);
5154 }
5155 }
5156
5157 // Render pending-input preview (queued/steered messages, if any).
5158 if preview_height > 0 {
5159 let buf = f.buffer_mut();
5160 pending_preview.render(chunks[2], buf);
5161 }
5162
5163 // Render composer
5164 let cursor_pos = {
5165 let composer_widget = ComposerWidget::new(
5166 app,
5167 composer_max_height,
5168 &slash_menu_entries,
5169 &mention_menu_entries,
5170 );
5171 let buf = f.buffer_mut();
5172 composer_widget.render(chunks[3], buf);
5173 composer_widget.cursor_pos(chunks[3])
5174 };
5175 if let Some(cursor_pos) = cursor_pos {
5176 f.set_cursor_position(cursor_pos);
5177 }
5178
5179 // Render footer
5180 render_footer(f, chunks[4], app);
5181 // Toast stack overlay (#439): when multiple status toasts are queued,
5182 // surface the older ones as a 1-2 line strip above the footer so a
5183 // burst of events isn't collapsed to a single visible message.
5184 render_toast_stack_overlay(f, size, chunks[4], app);
5185
5186 if !app.view_stack.is_empty() {
5187 // The live transcript overlay snapshots the app's history + active
5188 // cell on each render so streaming mutations propagate. Other views
5189 // are static and skip this refresh.
5190 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
5191 refresh_live_transcript_overlay(app);
5192 }
5193 let buf = f.buffer_mut();
5194 app.view_stack.render(size, buf);
5195 }
5196 }
5197
5198 /// Pull the latest snapshot of cells / revisions / render options into the
5199 /// live transcript overlay sitting on top of the view stack. No-op if the
5200 /// top view isn't a `LiveTranscriptOverlay`.
5201 fn refresh_live_transcript_overlay(app: &mut App) {
5202 // Pop+push lets us hold &mut to the overlay while also borrowing `app`
5203 // mutably for the snapshot — direct re-borrow through `view_stack`
5204 // would otherwise alias `app`.
5205 let Some(mut overlay) = app.view_stack.pop() else {
5206 return;
5207 };
5208 if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
5209 typed.refresh_from_app(app);
5210 }
5211 app.view_stack.push_boxed(overlay);
5212 }
5213
5214 /// Open the live transcript overlay in backtrack-preview mode (#133).
5215 /// The overlay starts highlighting the most recent user message
5216 /// (`selected_idx = 0`) and routes Left/Right/Enter/Esc through
5217 /// `ViewEvent::Backtrack*` so the main key dispatcher can advance the
5218 /// `BacktrackState` and apply the rewind on confirm.
5219 fn open_backtrack_overlay(app: &mut App) {
5220 let mut overlay = LiveTranscriptOverlay::new();
5221 overlay.refresh_from_app(app);
5222 overlay.set_backtrack_preview(0);
5223 app.view_stack.push(overlay);
5224 app.status_message =
5225 Some("Backtrack: \u{2190}/\u{2192} step Enter rewind Esc cancel".to_string());
5226 app.needs_redraw = true;
5227 }
5228
5229 /// Toggle the live transcript overlay on `Ctrl+T`. Closes the overlay if it's
5230 /// already on top; otherwise pushes a fresh one in sticky-tail mode.
5231 fn toggle_live_transcript_overlay(app: &mut App) {
5232 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
5233 app.view_stack.pop();
5234 app.needs_redraw = true;
5235 return;
5236 }
5237 let mut overlay = LiveTranscriptOverlay::new();
5238 overlay.refresh_from_app(app);
5239 app.view_stack.push(overlay);
5240 app.status_message = Some("Live transcript: tailing (Esc to close)".to_string());
5241 app.needs_redraw = true;
5242 }
5243
5244 async fn handle_view_events(
5245 terminal: &mut AppTerminal,
5246 app: &mut App,
5247 config: &mut Config,
5248 task_manager: &SharedTaskManager,
5249 engine_handle: &mut EngineHandle,
5250 web_config_session: &mut Option<WebConfigSession>,
5251 events: Vec<ViewEvent>,
5252 ) -> Result<bool> {
5253 for event in events {
5254 match event {
5255 ViewEvent::CommandPaletteSelected { action } => match action {
5256 crate::tui::views::CommandPaletteAction::ExecuteCommand { command } => {
5257 if execute_command_input(
5258 terminal,
5259 app,
5260 engine_handle,
5261 task_manager,
5262 config,
5263 &mut *web_config_session,
5264 &command,
5265 )
5266 .await?
5267 {
5268 return Ok(true);
5269 }
5270 }
5271 crate::tui::views::CommandPaletteAction::InsertText { text } => {
5272 app.input = text;
5273 app.cursor_position = app.input.chars().count();
5274 app.status_message = Some(
5275 "Inserted into composer. Finish the input or press Enter.".to_string(),
5276 );
5277 }
5278 crate::tui::views::CommandPaletteAction::OpenTextPager { title, content } => {
5279 open_text_pager(app, title, content);
5280 }
5281 },
5282 ViewEvent::OpenTextPager { title, content } => {
5283 open_text_pager(app, title, content);
5284 }
5285 ViewEvent::ApprovalDecision {
5286 tool_id,
5287 tool_name,
5288 decision,
5289 timed_out,
5290 approval_key,
5291 } => {
5292 if decision == ReviewDecision::ApprovedForSession {
5293 // Store both the tool name (backward compat) and the
5294 // approval key (fingerprint-based).
5295 app.approval_session_approved.insert(tool_name.clone());
5296 app.approval_session_approved.insert(approval_key.clone());
5297 }
5298
5299 match decision {
5300 ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
5301 let _ = engine_handle.approve_tool_call(tool_id).await;
5302 }
5303 ReviewDecision::Denied | ReviewDecision::Abort => {
5304 // Cache the denial so the model retry-loop doesn't
5305 // re-prompt for the same command (#360). Only when
5306 // the user actively denied (not when the timeout
5307 // fired) — a timeout might mean the user stepped
5308 // away rather than refused.
5309 if !timed_out {
5310 app.approval_session_denied.insert(tool_name.clone());
5311 app.approval_session_denied.insert(approval_key);
5312 }
5313 let _ = engine_handle.deny_tool_call(tool_id).await;
5314 }
5315 }
5316
5317 if timed_out {
5318 app.add_message(HistoryCell::System {
5319 content: "Approval request timed out - denied".to_string(),
5320 });
5321 }
5322 }
5323 ViewEvent::ElevationDecision {
5324 tool_id,
5325 tool_name,
5326 option,
5327 } => {
5328 use crate::tui::approval::ElevationOption;
5329 match option {
5330 ElevationOption::Abort => {
5331 let _ = engine_handle.deny_tool_call(tool_id).await;
5332 app.add_message(HistoryCell::System {
5333 content: format!("Sandbox elevation aborted for {tool_name}"),
5334 });
5335 }
5336 ElevationOption::WithNetwork => {
5337 app.add_message(HistoryCell::System {
5338 content: format!("Retrying {tool_name} with network access enabled"),
5339 });
5340 let policy = option.to_policy(&app.workspace);
5341 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
5342 }
5343 ElevationOption::WithWriteAccess(_) => {
5344 app.add_message(HistoryCell::System {
5345 content: format!("Retrying {tool_name} with write access enabled"),
5346 });
5347 let policy = option.to_policy(&app.workspace);
5348 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
5349 }
5350 ElevationOption::FullAccess => {
5351 app.add_message(HistoryCell::System {
5352 content: format!("Retrying {tool_name} with full access (no sandbox)"),
5353 });
5354 let policy = option.to_policy(&app.workspace);
5355 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
5356 }
5357 }
5358 }
5359 ViewEvent::UserInputSubmitted { tool_id, response } => {
5360 let _ = engine_handle.submit_user_input(tool_id, response).await;
5361 }
5362 ViewEvent::UserInputCancelled { tool_id } => {
5363 let _ = engine_handle.cancel_user_input(tool_id).await;
5364 app.add_message(HistoryCell::System {
5365 content: "User input cancelled".to_string(),
5366 });
5367 }
5368 ViewEvent::PlanPromptSelected { option } => {
5369 if app.plan_prompt_pending {
5370 app.plan_prompt_pending = false;
5371 if let Some(choice) = plan_choice_from_option(option)
5372 && let Err(err) =
5373 apply_plan_choice(app, config, engine_handle, choice).await
5374 {
5375 app.status_message = Some(format!("Failed to apply plan selection: {err}"));
5376 }
5377 }
5378 }
5379 ViewEvent::PlanPromptDismissed => {
5380 app.plan_prompt_pending = true;
5381 app.status_message =
5382 Some("Plan prompt closed. Type 1-4 and press Enter to choose.".to_string());
5383 }
5384 ViewEvent::SessionSelected { session_id } => {
5385 let manager = match SessionManager::default_location() {
5386 Ok(manager) => manager,
5387 Err(err) => {
5388 app.status_message =
5389 Some(format!("Failed to open sessions directory: {err}"));
5390 continue;
5391 }
5392 };
5393
5394 match manager.load_session(&session_id) {
5395 Ok(session) => {
5396 apply_loaded_session(app, &session);
5397 let _ = engine_handle
5398 .send(Op::SyncSession {
5399 messages: app.api_messages.clone(),
5400 system_prompt: app.system_prompt.clone(),
5401 model: app.model.clone(),
5402 workspace: app.workspace.clone(),
5403 })
5404 .await;
5405 let _ = engine_handle
5406 .send(Op::SetCompaction {
5407 config: app.compaction_config(),
5408 })
5409 .await;
5410 app.status_message = Some(format!(
5411 "Session loaded (ID: {})",
5412 &session_id[..8.min(session_id.len())]
5413 ));
5414 }
5415 Err(err) => {
5416 app.status_message =
5417 Some(format!("Failed to load session {session_id}: {err}"));
5418 }
5419 }
5420 }
5421 ViewEvent::SessionDeleted { session_id, title } => {
5422 app.status_message = Some(format!(
5423 "Deleted session {} ({})",
5424 &session_id[..8.min(session_id.len())],
5425 title
5426 ));
5427 }
5428 ViewEvent::ConfigUpdated {
5429 key,
5430 value,
5431 persist,
5432 } => {
5433 let result = commands::set_config_value(app, &key, &value, persist);
5434 if let Some(msg) = result.message {
5435 app.add_message(HistoryCell::System { content: msg });
5436 }
5437
5438 if let Some(action) = result.action {
5439 match action {
5440 AppAction::UpdateCompaction(compaction) => {
5441 apply_model_and_compaction_update(engine_handle, compaction).await;
5442 }
5443 AppAction::OpenConfigView => {}
5444 _ => {}
5445 }
5446 }
5447
5448 if app.view_stack.top_kind() == Some(ModalKind::Config) {
5449 app.view_stack.pop();
5450 app.view_stack.push(ConfigView::new_for_app(app));
5451 }
5452 }
5453 ViewEvent::StatusItemsUpdated { items, final_save } => {
5454 // Apply to the live App immediately so the footer reflects
5455 // every keystroke (live preview).
5456 app.status_items = items.clone();
5457 app.needs_redraw = true;
5458 if final_save {
5459 match commands::persist_status_items(&items) {
5460 Ok(path) => {
5461 app.status_message =
5462 Some(format!("Status line saved to {}", path.display()));
5463 }
5464 Err(err) => {
5465 app.add_message(HistoryCell::System {
5466 content: format!("Failed to save status line: {err}"),
5467 });
5468 }
5469 }
5470 }
5471 }
5472 ViewEvent::SubAgentsRefresh => {
5473 app.status_message = Some("Refreshing sub-agents...".to_string());
5474 let _ = engine_handle.send(Op::ListSubAgents).await;
5475 }
5476 ViewEvent::FilePickerSelected { path } => {
5477 // Insert `@<path>` at the composer's cursor with surrounding
5478 // whitespace so the existing `@`-mention parser picks it up.
5479 let cursor = app.cursor_position;
5480 let needs_leading_space = cursor > 0
5481 && !app
5482 .input
5483 .chars()
5484 .nth(cursor.saturating_sub(1))
5485 .is_some_and(|c| c.is_whitespace());
5486 let mut insertion = String::new();
5487 if needs_leading_space {
5488 insertion.push(' ');
5489 }
5490 insertion.push('@');
5491 insertion.push_str(&path);
5492 insertion.push(' ');
5493 app.insert_str(&insertion);
5494 app.status_message = Some(format!("Attached @{path}"));
5495 }
5496 ViewEvent::ModelPickerApplied {
5497 model,
5498 effort,
5499 previous_model,
5500 previous_effort,
5501 } => {
5502 apply_model_picker_choice(
5503 app,
5504 engine_handle,
5505 model,
5506 effort,
5507 previous_model,
5508 previous_effort,
5509 )
5510 .await;
5511 }
5512 ViewEvent::ProviderPickerApplied { provider } => {
5513 switch_provider(app, engine_handle, config, provider, None).await;
5514 }
5515 ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => {
5516 apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await;
5517 }
5518 ViewEvent::BacktrackStep { direction } => {
5519 app.backtrack.step(direction);
5520 if let Some(idx) = app.backtrack.selected_idx() {
5521 update_backtrack_overlay_selection(app, idx);
5522 }
5523 }
5524 ViewEvent::BacktrackConfirm => {
5525 if let Some(depth) = app.backtrack.confirm() {
5526 apply_backtrack(app, depth);
5527 }
5528 }
5529 ViewEvent::BacktrackCancel => {
5530 app.backtrack.reset();
5531 app.status_message = Some("Backtrack canceled".to_string());
5532 app.needs_redraw = true;
5533 }
5534 ViewEvent::ContextMenuSelected { action } => {
5535 handle_context_menu_action(app, action);
5536 }
5537 ViewEvent::ShellControlBackground => {
5538 request_foreground_shell_background(app);
5539 }
5540 ViewEvent::ShellControlCancel => {
5541 app.backtrack.reset();
5542 engine_handle.cancel();
5543 app.is_loading = false;
5544 app.streaming_state.reset();
5545 app.runtime_turn_status = None;
5546 app.finalize_active_cell_as_interrupted();
5547 app.finalize_streaming_assistant_as_interrupted();
5548 app.status_message = Some("Request cancelled".to_string());
5549 }
5550 }
5551 }
5552
5553 Ok(false)
5554 }
5555
5556 /// Push the new `selected_idx` into the live transcript overlay so the
5557 /// highlight follows the user's Left/Right input. No-op if the overlay is
5558 /// no longer on top (e.g. it was closed underneath us).
5559 fn update_backtrack_overlay_selection(app: &mut App, selected_idx: usize) {
5560 if app.view_stack.top_kind() != Some(ModalKind::LiveTranscript) {
5561 return;
5562 }
5563 let Some(mut overlay) = app.view_stack.pop() else {
5564 return;
5565 };
5566 if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
5567 typed.set_backtrack_preview(selected_idx);
5568 }
5569 app.view_stack.push_boxed(overlay);
5570 app.needs_redraw = true;
5571 }
5572
5573 /// Count how many `HistoryCell::User` entries currently live in the
5574 /// transcript. Used by the backtrack state machine to decide whether
5575 /// there's anything to rewind to. Walks `app.history` directly so it
5576 /// stays accurate even mid-stream (the streaming Assistant cell never
5577 /// counts as a user turn).
5578 fn count_user_history_cells(app: &App) -> usize {
5579 app.history
5580 .iter()
5581 .filter(|cell| matches!(cell, HistoryCell::User { .. }))
5582 .count()
5583 }
5584
5585 /// Find the absolute index of the Nth-from-tail `HistoryCell::User` in
5586 /// `app.history`. `depth` of 0 selects the most recent user cell.
5587 /// Returns `None` if `depth` is out of range.
5588 fn find_user_cell_index_from_tail(app: &App, depth: usize) -> Option<usize> {
5589 let mut count = 0usize;
5590 for (idx, cell) in app.history.iter().enumerate().rev() {
5591 if matches!(cell, HistoryCell::User { .. }) {
5592 if count == depth {
5593 return Some(idx);
5594 }
5595 count += 1;
5596 }
5597 }
5598 None
5599 }
5600
5601 /// Apply the user's backtrack selection: trim `app.history` and
5602 /// `app.api_messages` so everything from the chosen user message onward
5603 /// is dropped, populate the composer with the dropped user text, close
5604 /// the overlay, and surface a status hint. The cycle counter is bumped
5605 /// so any persistent indices clear; the engine's in-flight context is
5606 /// re-synced via `Op::SyncSession` so the next turn starts fresh.
5607 fn apply_backtrack(app: &mut App, depth: usize) {
5608 let Some(history_idx) = find_user_cell_index_from_tail(app, depth) else {
5609 app.status_message = Some("Backtrack target no longer present".to_string());
5610 return;
5611 };
5612
5613 // Snapshot the user text before truncating so we can refill the
5614 // composer.
5615 let user_text = match app.history.get(history_idx) {
5616 Some(HistoryCell::User { content }) => content.clone(),
5617 _ => String::new(),
5618 };
5619
5620 // Trim the visible transcript at the chosen user cell. Per-cell
5621 // revisions and tool-cell maps are kept consistent through
5622 // `App::truncate_history_to`.
5623 app.truncate_history_to(history_idx);
5624
5625 // Trim the API-message log at the matching user message. We
5626 // re-walk `api_messages` from the tail, counting role=="user"
5627 // boundaries so the depth aligns with what the model sees on the
5628 // next turn.
5629 let mut user_seen = 0usize;
5630 let mut cut = None;
5631 for (idx, msg) in app.api_messages.iter().enumerate().rev() {
5632 if msg.role == "user" {
5633 if user_seen == depth {
5634 cut = Some(idx);
5635 break;
5636 }
5637 user_seen += 1;
5638 }
5639 }
5640 if let Some(idx) = cut {
5641 app.api_messages.truncate(idx);
5642 }
5643
5644 // Hand the dropped text back to the user so they can edit + resend.
5645 app.input = user_text;
5646 app.cursor_position = app.input.chars().count();
5647
5648 // Close the overlay, refresh sticky-tail flag, and surface a hint.
5649 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
5650 app.view_stack.pop();
5651 }
5652 app.status_message =
5653 Some("Rewound to previous user message — edit and Enter to resend".to_string());
5654 app.scroll_to_bottom();
5655 app.mark_history_updated();
5656 app.needs_redraw = true;
5657 }
5658
5659 /// Persist the typed API key to `~/.deepseek/config.toml`, refresh the
5660 /// in-memory config so the engine can see it, then switch to the provider.
5661 async fn apply_provider_picker_api_key(
5662 app: &mut App,
5663 engine_handle: &mut EngineHandle,
5664 config: &mut Config,
5665 provider: ApiProvider,
5666 api_key: String,
5667 ) {
5668 use crate::config::{ProviderConfig, ProvidersConfig, save_api_key_for};
5669
5670 match save_api_key_for(provider, &api_key) {
5671 Ok(path) => {
5672 app.status_message = Some(format!(
5673 "Saved {} API key to {}",
5674 provider.as_str(),
5675 path.display()
5676 ));
5677 app.api_key_env_only = false;
5678 }
5679 Err(err) => {
5680 app.add_message(HistoryCell::System {
5681 content: format!(
5682 "Failed to save {} API key: {err}\nProvider unchanged.",
5683 provider.as_str()
5684 ),
5685 });
5686 return;
5687 }
5688 }
5689
5690 // Mirror the saved key into the in-memory config so the engine sees it
5691 // immediately without a reload — `save_api_key_for` only touches disk.
5692 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
5693 config.api_key = Some(api_key);
5694 } else {
5695 let providers = config
5696 .providers
5697 .get_or_insert_with(ProvidersConfig::default);
5698 let entry: &mut ProviderConfig = match provider {
5699 ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
5700 // Guarded by the outer `if` above; safety net against refactors.
5701 return;
5702 }
5703 ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
5704 ApiProvider::Openrouter => &mut providers.openrouter,
5705 ApiProvider::Novita => &mut providers.novita,
5706 ApiProvider::Fireworks => &mut providers.fireworks,
5707 ApiProvider::Sglang => &mut providers.sglang,
5708 ApiProvider::Vllm => &mut providers.vllm,
5709 };
5710 entry.api_key = Some(api_key);
5711 }
5712
5713 switch_provider(app, engine_handle, config, provider, None).await;
5714 }
5715
5716 fn apply_loaded_session(app: &mut App, session: &SavedSession) {
5717 app.api_messages.clone_from(&session.messages);
5718 app.clear_history();
5719 app.tool_cells.clear();
5720 app.tool_details_by_cell.clear();
5721 app.active_cell = None;
5722 app.active_tool_details.clear();
5723 app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
5724 app.exploring_cell = None;
5725 app.exploring_entries.clear();
5726 app.ignored_tool_calls.clear();
5727 app.pending_tool_uses.clear();
5728 app.last_exec_wait_command = None;
5729
5730 let messages = app.api_messages.clone();
5731 let mut message_to_cell = std::collections::HashMap::new();
5732 for (message_index, msg) in messages.iter().enumerate() {
5733 let mut cells = history_cells_from_message(msg);
5734 if msg.role == "user"
5735 && session
5736 .context_references
5737 .iter()
5738 .any(|record| record.message_index == message_index)
5739 {
5740 for cell in &mut cells {
5741 if let HistoryCell::User { content } = cell {
5742 *content = compact_user_context_display(content);
5743 }
5744 }
5745 }
5746 let base = app.history.len();
5747 if msg.role == "user"
5748 && let Some(offset) = cells
5749 .iter()
5750 .position(|cell| matches!(cell, HistoryCell::User { .. }))
5751 {
5752 message_to_cell.insert(message_index, base + offset);
5753 }
5754 app.extend_history(cells);
5755 }
5756 app.sync_context_references_from_session(&session.context_references, &message_to_cell);
5757 app.mark_history_updated();
5758 app.viewport.transcript_selection.clear();
5759 app.model.clone_from(&session.metadata.model);
5760 app.update_model_compaction_budget();
5761 app.workspace.clone_from(&session.metadata.workspace);
5762 app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
5763 app.session.total_conversation_tokens = app.session.total_tokens;
5764 app.session.last_prompt_tokens = None;
5765 app.session.last_completion_tokens = None;
5766 app.session.last_prompt_cache_hit_tokens = None;
5767 app.session.last_prompt_cache_miss_tokens = None;
5768 app.current_session_id = Some(session.metadata.id.clone());
5769 app.workspace_context = None;
5770 app.workspace_context_refreshed_at = None;
5771 if let Some(sp) = session.system_prompt.as_ref() {
5772 app.system_prompt = Some(SystemPrompt::Text(sp.clone()));
5773 } else {
5774 app.system_prompt = None;
5775 }
5776 app.scroll_to_bottom();
5777 }
5778
5779 fn compact_user_context_display(content: &str) -> String {
5780 content
5781 .split("\n\n---\n\nLocal context from @mentions:")
5782 .next()
5783 .unwrap_or(content)
5784 .to_string()
5785 }
5786
5787 fn refresh_workspace_context_if_needed(app: &mut App, now: Instant, allow_refresh: bool) {
5788 // Drain the async cell result into the live field first, so the render
5789 // path always reads the latest value (#399 S1).
5790 if let Ok(mut cell) = app.workspace_context_cell.lock()
5791 && let Some(ctx) = cell.take()
5792 {
5793 app.workspace_context = Some(ctx);
5794 }
5795
5796 if app
5797 .workspace_context_refreshed_at
5798 .is_some_and(|refreshed_at| {
5799 now.duration_since(refreshed_at) < Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS)
5800 })
5801 {
5802 return;
5803 }
5804
5805 if !allow_refresh {
5806 return;
5807 }
5808
5809 // Offload git query to a background thread when a Tokio runtime is
5810 // available. Fall back to synchronous execution for tests and other
5811 // non-async contexts (#399 S1).
5812 if let Ok(handle) = tokio::runtime::Handle::try_current() {
5813 let ctx = app.workspace_context_cell.clone();
5814 let workspace = app.workspace.clone();
5815 handle.spawn_blocking(move || {
5816 let result = collect_workspace_context(&workspace);
5817 if let Ok(mut guard) = ctx.lock() {
5818 *guard = result;
5819 }
5820 });
5821 } else {
5822 // No runtime — run synchronously so tests and one-shot callers
5823 // still get a result immediately.
5824 app.workspace_context = collect_workspace_context(&app.workspace);
5825 }
5826 app.workspace_context_refreshed_at = Some(now);
5827 }
5828
5829 #[derive(Debug, Default, Clone, Copy)]
5830 struct WorkspaceChangeSummary {
5831 staged: usize,
5832 modified: usize,
5833 untracked: usize,
5834 conflicts: usize,
5835 }
5836
5837 impl WorkspaceChangeSummary {
5838 fn is_clean(&self) -> bool {
5839 self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0
5840 }
5841 }
5842
5843 fn collect_workspace_context(workspace: &Path) -> Option<String> {
5844 let branch = workspace_git_branch(workspace)?;
5845 let summary = workspace_git_change_summary(workspace)?;
5846
5847 let mut parts = Vec::new();
5848 if summary.staged > 0 {
5849 parts.push(format!("{} staged", summary.staged));
5850 }
5851 if summary.modified > 0 {
5852 parts.push(format!("{} modified", summary.modified));
5853 }
5854 if summary.untracked > 0 {
5855 parts.push(format!("{} untracked", summary.untracked));
5856 }
5857 if summary.conflicts > 0 {
5858 parts.push(format!("{} conflicts", summary.conflicts));
5859 }
5860
5861 let status = if summary.is_clean() {
5862 "clean".to_string()
5863 } else {
5864 parts.join(", ")
5865 };
5866
5867 Some(format!("{branch} | {status}"))
5868 }
5869
5870 fn workspace_git_branch(workspace: &Path) -> Option<String> {
5871 let branch = run_git_query(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
5872 let branch = branch.trim().to_string();
5873 if branch == "HEAD" || branch.is_empty() {
5874 let short_hash = run_git_query(workspace, &["rev-parse", "--short", "HEAD"]).ok()?;
5875 let short_hash = short_hash.trim();
5876 if short_hash.is_empty() {
5877 return None;
5878 }
5879 return Some(format!("detached:{short_hash}"));
5880 }
5881 Some(branch)
5882 }
5883
5884 fn workspace_git_change_summary(workspace: &Path) -> Option<WorkspaceChangeSummary> {
5885 let status = run_git_query(
5886 workspace,
5887 &["status", "--short", "--untracked-files=normal"],
5888 )
5889 .ok()?;
5890
5891 if status.trim().is_empty() {
5892 return Some(WorkspaceChangeSummary::default());
5893 }
5894
5895 let mut summary = WorkspaceChangeSummary::default();
5896 for line in status.lines() {
5897 if line.trim().is_empty() {
5898 continue;
5899 }
5900
5901 let mut chars = line.chars();
5902 let staged = chars.next()?;
5903 let modified = chars.next().unwrap_or(' ');
5904
5905 if staged == ' ' && modified == ' ' {
5906 continue;
5907 }
5908 if staged == '?' && modified == '?' {
5909 summary.untracked = summary.untracked.saturating_add(1);
5910 continue;
5911 }
5912
5913 if staged == 'U' || modified == 'U' {
5914 summary.conflicts = summary.conflicts.saturating_add(1);
5915 }
5916 if staged != ' ' && staged != '?' {
5917 summary.staged = summary.staged.saturating_add(1);
5918 }
5919 if modified != ' ' && modified != '?' {
5920 summary.modified = summary.modified.saturating_add(1);
5921 }
5922 }
5923
5924 Some(summary)
5925 }
5926
5927 fn run_git_query(workspace: &Path, args: &[&str]) -> std::io::Result<String> {
5928 let output = Command::new("git")
5929 .args(args)
5930 .current_dir(workspace)
5931 .output()?;
5932 if !output.status.success() {
5933 return Err(std::io::Error::other("git command failed"));
5934 }
5935 Ok(String::from_utf8_lossy(&output.stdout).to_string())
5936 }
5937
5938 fn pause_terminal(
5939 terminal: &mut AppTerminal,
5940 use_alt_screen: bool,
5941 use_mouse_capture: bool,
5942 use_bracketed_paste: bool,
5943 ) -> Result<()> {
5944 // #443: pop keyboard enhancement flags before handing the terminal
5945 // to a child process so it doesn't inherit a half-configured input
5946 // mode. Best-effort — terminals that didn't accept the flags
5947 // silently ignore the pop. Matches the shutdown and panic paths.
5948 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
5949 disable_raw_mode()?;
5950 if use_alt_screen {
5951 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
5952 }
5953 if use_mouse_capture {
5954 execute!(terminal.backend_mut(), DisableMouseCapture)?;
5955 }
5956 if use_bracketed_paste {
5957 execute!(terminal.backend_mut(), DisableBracketedPaste)?;
5958 }
5959 Ok(())
5960 }
5961
5962 fn resume_terminal(
5963 terminal: &mut AppTerminal,
5964 use_alt_screen: bool,
5965 use_mouse_capture: bool,
5966 use_bracketed_paste: bool,
5967 ) -> Result<()> {
5968 enable_raw_mode()?;
5969 if use_alt_screen {
5970 execute!(terminal.backend_mut(), EnterAlternateScreen)?;
5971 }
5972 if use_mouse_capture {
5973 execute!(terminal.backend_mut(), EnableMouseCapture)?;
5974 }
5975 if use_bracketed_paste {
5976 execute!(terminal.backend_mut(), EnableBracketedPaste)?;
5977 }
5978 terminal.clear()?;
5979 Ok(())
5980 }
5981
5982 fn status_color(level: StatusToastLevel) -> ratatui::style::Color {
5983 match level {
5984 StatusToastLevel::Info => palette::DEEPSEEK_SKY,
5985 StatusToastLevel::Success => palette::STATUS_SUCCESS,
5986 StatusToastLevel::Warning => palette::STATUS_WARNING,
5987 StatusToastLevel::Error => palette::STATUS_ERROR,
5988 }
5989 }
5990
5991 /// Maximum stacked toasts rendered above the footer (#439). The footer line
5992 /// itself stays the most-recent; this overlay surfaces up to two older
5993 /// queued toasts so a burst of status events isn't dropped silently.
5994 const TOAST_STACK_MAX_VISIBLE: usize = 3;
5995
5996 /// Render up to `TOAST_STACK_MAX_VISIBLE - 1` *additional* toasts as an
5997 /// overlay just above the footer when multiple are active. The most recent
5998 /// toast continues to render in the footer line itself; this strip is for
5999 /// the older entries the user would otherwise miss when statuses arrive in
6000 /// bursts.
6001 fn render_toast_stack_overlay(f: &mut Frame, full_area: Rect, footer_area: Rect, app: &mut App) {
6002 let toasts = app.active_status_toasts(TOAST_STACK_MAX_VISIBLE);
6003 if toasts.len() < 2 || footer_area.y == 0 {
6004 return;
6005 }
6006 // Drop the most recent (rendered inline by the footer), keep the rest.
6007 let extra = toasts.len() - 1;
6008 let stack_height = extra.min(TOAST_STACK_MAX_VISIBLE - 1) as u16;
6009 let max_above = footer_area.y.min(full_area.height);
6010 if stack_height == 0 || max_above == 0 {
6011 return;
6012 }
6013 let height = stack_height.min(max_above);
6014 let stack_area = Rect {
6015 x: full_area.x,
6016 y: footer_area.y.saturating_sub(height),
6017 width: full_area.width,
6018 height,
6019 };
6020 // Iterate oldest-first so the freshest *non-inline* toast is closest to
6021 // the footer (visually nearest the most-recent message in the line below).
6022 let visible = &toasts[..extra];
6023 for (i, toast) in visible.iter().take(height as usize).enumerate() {
6024 let row_y = stack_area.y + i as u16;
6025 let row = Rect {
6026 x: stack_area.x,
6027 y: row_y,
6028 width: stack_area.width,
6029 height: 1,
6030 };
6031 let style = ratatui::style::Style::default()
6032 .fg(status_color(toast.level))
6033 .add_modifier(ratatui::style::Modifier::DIM);
6034 let line = ratatui::text::Line::styled(format!(" {} ", toast.text), style);
6035 f.render_widget(ratatui::widgets::Paragraph::new(line), row);
6036 }
6037 }
6038
6039 fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
6040 if area.width == 0 || area.height == 0 {
6041 return;
6042 }
6043
6044 // Pull in the toast first so we don't re-borrow `app` mutably mid-build,
6045 // then build the FooterProps once. The widget itself is a pure render —
6046 // it owns no `App` knowledge; all width-aware layout lives in the widget.
6047 //
6048 // The quit-confirmation prompt takes precedence over normal status toasts
6049 // because it represents a transient instruction the user must respond to
6050 // within ~2s. Mirrors codex-rs's `FooterMode::QuitShortcutReminder`.
6051 let quit_prompt = if app.quit_is_armed() {
6052 Some(FooterToast {
6053 text: crate::localization::tr(
6054 app.ui_locale,
6055 crate::localization::MessageId::FooterPressCtrlCAgain,
6056 )
6057 .to_string(),
6058 color: palette::STATUS_WARNING,
6059 })
6060 } else {
6061 None
6062 };
6063 let toast = quit_prompt.or_else(|| {
6064 app.active_status_toast().map(|toast| FooterToast {
6065 text: toast.text,
6066 color: status_color(toast.level),
6067 })
6068 });
6069
6070 // Drive every cluster from the user's configured `status_items`. Mode
6071 // and Model are always rendered by `FooterProps` itself (their position
6072 // is structural — cluster gating is handled by the widget), so we only
6073 // gate the optional clusters here. If a variant is missing from
6074 // `status_items`, its span vec stays empty and the footer hides it.
6075 let mut props = render_footer_from(app, &app.status_items, toast);
6076 // FooterProps is mut so the working-strip animation can layer on top.
6077
6078 // Animate the spacer between the left status line and the right-hand
6079 // chips whenever a turn is live: model loading/streaming, compacting, or
6080 // sub-agents in flight. Honors the `low_motion` setting — calm terminals
6081 // get the plain whitespace gap. Strip frame counter ticks every 150 ms
6082 // (crest A advances every 4 ticks ≈ 600 ms, B every 6 ticks ≈ 900 ms,
6083 // jitter every 17 ticks ≈ 2.5 s). Dot-pulse counter ticks every 400 ms
6084 // so `working` → `working...` reads at a calm pace.
6085 if footer_working_strip_active(app) {
6086 let now_ms = std::time::SystemTime::now()
6087 .duration_since(std::time::UNIX_EPOCH)
6088 .map(|d| d.as_millis() as u64)
6089 .unwrap_or(0);
6090 let dot_frame = now_ms / 400;
6091 // Surface one compact live status row in the footer whenever a turn
6092 // is live. Tool turns get the current action plus active/done counts;
6093 // non-tool work falls back to the existing dot-pulse label.
6094 props.state_label = active_subagent_status_label(app)
6095 .or_else(|| active_tool_status_label(app))
6096 .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale));
6097 props.state_color = palette::DEEPSEEK_SKY;
6098
6099 // Spout drift: only animate when low_motion is off. The textual
6100 // `working...` pulse stays even in low-motion mode so the user still
6101 // sees that something is happening.
6102 if !app.low_motion {
6103 let strip_frame = now_ms;
6104 props.working_strip_frame = Some(strip_frame);
6105 }
6106 } else if props.state_label == "ready"
6107 && let Some(label) = selected_detail_footer_label(app)
6108 {
6109 props.state_label = label;
6110 props.state_color = palette::TEXT_MUTED;
6111 }
6112
6113 let widget = FooterWidget::new(props);
6114 let buf = f.buffer_mut();
6115 widget.render(area, buf);
6116 }
6117
6118 /// Whether the footer should animate the water-spout strip. Driven by the
6119 /// underlying live-work flags so the strip stays visible for the *entire*
6120 /// turn — not just the moments where bytes are streaming. `is_loading` can
6121 /// flicker off between LLM rounds within a single turn (tool execution,
6122 /// reasoning replay, capacity refresh, etc.), so we ALSO gate on the turn
6123 /// itself still being in flight via `runtime_turn_status == "in_progress"`.
6124 /// Without that, the user sees the strip vanish for seconds at a time even
6125 /// though the agent is still working.
6126 fn footer_working_strip_active(app: &App) -> bool {
6127 let turn_in_progress = app.runtime_turn_status.as_deref() == Some("in_progress");
6128 app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress
6129 }
6130
6131 fn is_noisy_subagent_progress(status: &str) -> bool {
6132 let status = status.trim().to_ascii_lowercase();
6133 status.contains("requesting model response")
6134 }
6135
6136 fn subagent_objective_summary(app: &App, id: &str) -> Option<String> {
6137 app.subagent_cache
6138 .iter()
6139 .find(|agent| agent.agent_id == id)
6140 .map(|agent| summarize_tool_output(&agent.assignment.objective))
6141 .filter(|summary| !summary.is_empty())
6142 }
6143
6144 fn friendly_subagent_progress(app: &App, id: &str, status: &str) -> String {
6145 if !is_noisy_subagent_progress(status) {
6146 return summarize_tool_output(status);
6147 }
6148
6149 if let Some(summary) = subagent_objective_summary(app, id) {
6150 return format!("working on {summary}");
6151 }
6152 if let Some(existing) = app.agent_progress.get(id)
6153 && !is_noisy_subagent_progress(existing)
6154 && existing != "working"
6155 {
6156 return existing.clone();
6157 }
6158 "working".to_string()
6159 }
6160
6161 fn active_subagent_status_label(app: &App) -> Option<String> {
6162 let running = running_agent_count(app);
6163 let fanout = active_fanout_counts(app);
6164 let (display_running, total) = if let Some((fanout_running, fanout_total)) = fanout {
6165 if fanout_running == 0 {
6166 return None;
6167 }
6168 (fanout_running, fanout_total)
6169 } else {
6170 if running == 0 {
6171 return None;
6172 }
6173 (running, running)
6174 };
6175 let detail = app
6176 .subagent_cache
6177 .iter()
6178 .find(|agent| matches!(agent.status, SubAgentStatus::Running))
6179 .map(|agent| summarize_tool_output(&agent.assignment.objective))
6180 .filter(|summary| !summary.is_empty())
6181 .or_else(|| {
6182 app.agent_progress
6183 .values()
6184 .find(|value| !is_noisy_subagent_progress(value) && value.as_str() != "working")
6185 .cloned()
6186 })
6187 .unwrap_or_else(|| "working".to_string());
6188 let detail = truncate_line_to_width(&detail, 34);
6189 let elapsed = app
6190 .agent_activity_started_at
6191 .or(app.turn_started_at)
6192 .map(|started| format!("{}s", started.elapsed().as_secs()));
6193
6194 let mut parts = vec![format!("agents {display_running}/{total}"), detail];
6195 if let Some(elapsed) = elapsed {
6196 parts.push(elapsed);
6197 }
6198 parts.push("Alt+4".to_string());
6199 Some(parts.join(" \u{00B7} "))
6200 }
6201
6202 #[derive(Default)]
6203 struct ActiveToolStatusSnapshot {
6204 primary_running: Option<String>,
6205 primary_any: Option<String>,
6206 running: usize,
6207 completed: usize,
6208 started_at: Option<Instant>,
6209 }
6210
6211 impl ActiveToolStatusSnapshot {
6212 fn record(&mut self, label: String, status: ToolStatus, started_at: Option<Instant>) {
6213 if self.primary_any.is_none() {
6214 self.primary_any = Some(label.clone());
6215 }
6216 if status == ToolStatus::Running {
6217 self.running += 1;
6218 if self.primary_running.is_none() {
6219 self.primary_running = Some(label);
6220 }
6221 } else {
6222 self.completed += 1;
6223 }
6224 if let Some(started) = started_at {
6225 self.started_at = Some(match self.started_at {
6226 Some(current) => current.min(started),
6227 None => started,
6228 });
6229 }
6230 }
6231
6232 fn total(&self) -> usize {
6233 self.running + self.completed
6234 }
6235 }
6236
6237 fn active_tool_status_label(app: &App) -> Option<String> {
6238 let active = app.active_cell.as_ref()?;
6239 if active.is_empty() {
6240 return None;
6241 }
6242
6243 let mut snapshot = ActiveToolStatusSnapshot::default();
6244 for cell in active.entries() {
6245 collect_active_tool_status(cell, &mut snapshot);
6246 }
6247 if snapshot.total() == 0 {
6248 return None;
6249 }
6250
6251 let primary = snapshot
6252 .primary_running
6253 .or(snapshot.primary_any)
6254 .unwrap_or_else(|| "tools".to_string());
6255 let primary = truncate_line_to_width(&primary, 30);
6256 let elapsed = snapshot
6257 .started_at
6258 .or(app.turn_started_at)
6259 .map(|started| format!("{}s", started.elapsed().as_secs()));
6260
6261 let mut parts = vec![
6262 primary,
6263 format!("{} active", snapshot.running),
6264 format!("{} done", snapshot.completed),
6265 ];
6266 if let Some(elapsed) = elapsed {
6267 parts.push(elapsed);
6268 }
6269 if active_foreground_shell_running(app) {
6270 parts.push("Ctrl+B shell".to_string());
6271 }
6272 parts.push("Alt+V".to_string());
6273 Some(parts.join(" \u{00B7} "))
6274 }
6275
6276 fn open_shell_control(app: &mut App) {
6277 if !app.is_loading || !active_foreground_shell_running(app) {
6278 app.status_message = Some("No foreground shell command to control".to_string());
6279 return;
6280 }
6281
6282 app.view_stack.push(ShellControlView::new());
6283 app.status_message = Some("Shell control opened".to_string());
6284 }
6285
6286 fn request_foreground_shell_background(app: &mut App) {
6287 if !app.is_loading || !active_foreground_shell_running(app) {
6288 app.status_message = Some("No foreground shell command to background".to_string());
6289 return;
6290 }
6291
6292 let Some(shell_manager) = app.runtime_services.shell_manager.clone() else {
6293 app.status_message = Some("Shell manager is not attached".to_string());
6294 return;
6295 };
6296
6297 match shell_manager.lock() {
6298 Ok(mut manager) => {
6299 manager.request_foreground_background();
6300 app.status_message = Some("Backgrounding current shell command...".to_string());
6301 }
6302 Err(_) => {
6303 app.status_message = Some("Shell manager lock is poisoned".to_string());
6304 }
6305 }
6306 }
6307
6308 fn active_foreground_shell_running(app: &App) -> bool {
6309 app.active_cell.as_ref().is_some_and(|active| {
6310 active.entries().iter().any(|cell| {
6311 matches!(
6312 cell,
6313 HistoryCell::Tool(ToolCell::Exec(exec))
6314 if exec.status == ToolStatus::Running && exec.interaction.is_none()
6315 )
6316 })
6317 })
6318 }
6319
6320 fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) {
6321 let HistoryCell::Tool(tool) = cell else {
6322 return;
6323 };
6324 match tool {
6325 ToolCell::Exec(exec) => snapshot.record(
6326 format!("run {}", one_line_summary(&exec.command, 80)),
6327 exec.status,
6328 exec.started_at,
6329 ),
6330 ToolCell::Exploring(explore) => {
6331 for entry in &explore.entries {
6332 snapshot.record(
6333 format!("read {}", one_line_summary(&entry.label, 80)),
6334 entry.status,
6335 None,
6336 );
6337 }
6338 }
6339 ToolCell::PlanUpdate(plan) => {
6340 snapshot.record("update plan".to_string(), plan.status, None);
6341 }
6342 ToolCell::PatchSummary(patch) => {
6343 snapshot.record(format!("patch {}", patch.path), patch.status, None);
6344 }
6345 ToolCell::Review(review) => {
6346 let target = one_line_summary(&review.target, 80);
6347 let label = if target.is_empty() {
6348 "review".to_string()
6349 } else {
6350 format!("review {target}")
6351 };
6352 snapshot.record(label, review.status, None);
6353 }
6354 ToolCell::DiffPreview(diff) => {
6355 snapshot.record(format!("diff {}", diff.title), ToolStatus::Success, None);
6356 }
6357 ToolCell::Mcp(mcp) => snapshot.record(format!("tool {}", mcp.tool), mcp.status, None),
6358 ToolCell::ViewImage(image) => snapshot.record(
6359 format!("image {}", image.path.display()),
6360 ToolStatus::Success,
6361 None,
6362 ),
6363 ToolCell::WebSearch(search) => {
6364 snapshot.record(format!("search {}", search.query), search.status, None);
6365 }
6366 ToolCell::Generic(generic) => {
6367 // Sub-agent dispatch represents itself through the DelegateCard
6368 // + Agents sidebar. Counting it again here would duplicate the
6369 // status. RLM is different today: it is a foreground tool call,
6370 // so keep it in the live tool footer until the async RLM
6371 // workbench lands (#513).
6372 if generic.name == "agent_spawn" {
6373 return;
6374 }
6375 snapshot.record(format!("tool {}", generic.name), generic.status, None);
6376 }
6377 }
6378 }
6379
6380 fn one_line_summary(text: &str, max_width: usize) -> String {
6381 truncate_line_to_width(
6382 &text.split_whitespace().collect::<Vec<_>>().join(" "),
6383 max_width,
6384 )
6385 }
6386
6387 /// Build [`FooterProps`] from a user-configured `status_items` slice.
6388 ///
6389 /// Variants are routed to their structural cluster: `Mode` and `Model` are
6390 /// always emitted (the widget needs them to lay out the line correctly even
6391 /// when the user toggled them off the picker — we honour the toggle by
6392 /// blanking their visible content rather than collapsing the layout).
6393 /// `Cost` and `Status` belong in the left cluster; the rest in the right.
6394 ///
6395 /// A variant absent from `items` produces an empty span vec, which the
6396 /// footer widget already hides cleanly. This keeps the renderer fully
6397 /// data-driven without changing `FooterProps`'s public shape.
6398 fn render_footer_from(
6399 app: &App,
6400 items: &[crate::config::StatusItem],
6401 toast: Option<FooterToast>,
6402 ) -> FooterProps {
6403 use crate::config::StatusItem as S;
6404 let has = |item: S| items.contains(&item);
6405
6406 let (state_label, state_color) = if has(S::Status) {
6407 footer_state_label(app)
6408 } else {
6409 // "ready" is the sentinel the widget uses to skip the status segment;
6410 // pair it with theme text_muted for visual neutrality.
6411 ("ready", app.ui_theme.text_muted)
6412 };
6413
6414 let coherence = if has(S::Coherence) {
6415 footer_coherence_spans(app)
6416 } else {
6417 Vec::new()
6418 };
6419 let agents = if has(S::Agents) {
6420 crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale)
6421 } else {
6422 Vec::new()
6423 };
6424 let reasoning_replay = if has(S::ReasoningReplay) {
6425 footer_reasoning_replay_spans(app)
6426 } else {
6427 Vec::new()
6428 };
6429 let cache = if has(S::Cache) {
6430 footer_cache_spans(app)
6431 } else {
6432 Vec::new()
6433 };
6434 let cost = if has(S::Cost) {
6435 footer_cost_spans(app)
6436 } else {
6437 Vec::new()
6438 };
6439
6440 // Build the props; `Mode` and `Model` toggles modulate downstream by
6441 // blanking the rendered text rather than restructuring the widget — the
6442 // user is opting out of the chip, not destroying the bar.
6443 let mut props = FooterProps::from_app(
6444 app,
6445 toast,
6446 state_label,
6447 state_color,
6448 coherence,
6449 agents,
6450 reasoning_replay,
6451 cache,
6452 cost,
6453 );
6454 if !has(S::Mode) {
6455 props.mode_label = "";
6456 }
6457 if !has(S::Model) {
6458 props.model.clear();
6459 }
6460
6461 // Right-cluster extension chips: append in `items` order so user
6462 // ordering is preserved across the new variants.
6463 let mut extra: Vec<Span<'static>> = Vec::new();
6464 for item in items {
6465 let chip = match *item {
6466 S::ContextPercent => footer_context_percent_spans(app),
6467 S::GitBranch | S::LastToolElapsed | S::RateLimit => Vec::new(),
6468 _ => continue,
6469 };
6470 if chip.is_empty() {
6471 continue;
6472 }
6473 if !extra.is_empty() {
6474 extra.push(Span::raw(" "));
6475 }
6476 extra.extend(chip);
6477 }
6478 if !extra.is_empty() {
6479 // Stack into the cache slot — last existing right-cluster pipe — so
6480 // they appear adjacent without changing FooterProps's API. Keep
6481 // existing cache spans first so cache hit rate stays before the
6482 // user-added extras.
6483 if !props.cache.is_empty() {
6484 props.cache.push(Span::raw(" "));
6485 }
6486 props.cache.extend(extra);
6487 }
6488
6489 props
6490 }
6491
6492 /// Spans for the "context %" footer chip. Mirrors the header colour ramp so
6493 /// the two surfaces stay visually consistent when both are enabled.
6494 fn footer_context_percent_spans(app: &App) -> Vec<Span<'static>> {
6495 let Some((_, _, percent)) = context_usage_snapshot(app) else {
6496 return Vec::new();
6497 };
6498 let color = if percent >= 95.0 {
6499 palette::STATUS_ERROR
6500 } else if percent >= 85.0 {
6501 palette::STATUS_WARNING
6502 } else {
6503 palette::TEXT_MUTED
6504 };
6505 vec![Span::styled(
6506 format!("active ctx {percent:.0}%"),
6507 Style::default().fg(color),
6508 )]
6509 }
6510
6511 fn footer_cost_spans(app: &App) -> Vec<Span<'static>> {
6512 let displayed_cost = app.displayed_session_cost_for_currency(app.cost_currency);
6513 if !should_show_footer_cost(displayed_cost) {
6514 return Vec::new();
6515 }
6516 vec![Span::styled(
6517 app.format_cost_amount(displayed_cost),
6518 Style::default().fg(palette::TEXT_MUTED),
6519 )]
6520 }
6521
6522 fn should_show_footer_cost(displayed_cost: f64) -> bool {
6523 displayed_cost.is_finite() && displayed_cost > 0.0
6524 }
6525
6526 /// Test-only helper retained as a parity reference for `FooterWidget`'s
6527 /// auxiliary-span composition. Production rendering is performed by the
6528 /// widget itself; the existing footer parity tests still exercise this
6529 /// function directly to guard against drift.
6530 #[allow(dead_code)]
6531 fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
6532 // Context % is already shown in the header signal bar — don't
6533 // duplicate it in the footer. The footer carries unique info only:
6534 // coherence, in-flight sub-agents, reasoning replay tokens, cache hit
6535 // rate, and session cost.
6536 let coherence_spans = footer_coherence_spans(app);
6537 let agents_spans =
6538 crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale);
6539 let replay_spans = footer_reasoning_replay_spans(app);
6540 let cache_spans = footer_cache_spans(app);
6541 let cost_spans = footer_cost_spans(app);
6542
6543 let parts: Vec<&Vec<Span<'static>>> = [
6544 &coherence_spans,
6545 &agents_spans,
6546 &replay_spans,
6547 &cache_spans,
6548 &cost_spans,
6549 ]
6550 .iter()
6551 .filter(|spans| !spans.is_empty())
6552 .copied()
6553 .collect();
6554
6555 // Try to fit as many parts as possible, dropping from the end.
6556 for end in (0..=parts.len()).rev() {
6557 let mut combined = Vec::new();
6558 for (i, part) in parts[..end].iter().enumerate() {
6559 if i > 0 {
6560 combined.push(Span::raw(" "));
6561 }
6562 combined.extend(part.iter().cloned());
6563 }
6564 if spans_width(&combined) <= max_width {
6565 return combined;
6566 }
6567 }
6568 Vec::new()
6569 }
6570
6571 fn footer_coherence_spans(app: &App) -> Vec<Span<'static>> {
6572 // Only surface coherence when the engine is actively intervening — the
6573 // user-facing signal is "we're doing something different now," not
6574 // "your conversation is getting complex," which the context-percent
6575 // header already covers. `GettingCrowded` is just a soft hint, so we
6576 // suppress it; the active interventions get their own visible label.
6577 let (label, color) = match app.coherence_state {
6578 CoherenceState::Healthy | CoherenceState::GettingCrowded => return Vec::new(),
6579 CoherenceState::RefreshingContext => ("refreshing context", palette::STATUS_WARNING),
6580 CoherenceState::VerifyingRecentWork => ("verifying", palette::DEEPSEEK_SKY),
6581 CoherenceState::ResettingPlan => ("resetting plan", palette::STATUS_ERROR),
6582 };
6583
6584 vec![Span::styled(label.to_string(), Style::default().fg(color))]
6585 }
6586
6587 fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
6588 let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else {
6589 return Vec::new();
6590 };
6591 let miss_tokens = app
6592 .session
6593 .last_prompt_cache_miss_tokens
6594 .unwrap_or_else(|| {
6595 app.session
6596 .last_prompt_tokens
6597 .unwrap_or(0)
6598 .saturating_sub(hit_tokens)
6599 });
6600 let total = hit_tokens.saturating_add(miss_tokens);
6601 if total == 0 {
6602 return Vec::new();
6603 }
6604
6605 let percent = (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0);
6606 // Threshold-based coloring for cache hit rate (#396):
6607 // >80%: green (good cache utilization)
6608 // 40-80%: yellow/warning
6609 // <40%: red/dimmed (poor cache)
6610 let color = if percent > 80.0 {
6611 palette::STATUS_SUCCESS
6612 } else if percent >= 40.0 {
6613 palette::STATUS_WARNING
6614 } else {
6615 palette::STATUS_ERROR
6616 };
6617 vec![Span::styled(
6618 format!("cache hit {:.0}%", percent),
6619 Style::default().fg(color),
6620 )]
6621 }
6622
6623 /// Render a footer chip showing the size of the `reasoning_content` block
6624 /// replayed on the most recent thinking-mode tool-calling turn (#30).
6625 ///
6626 /// Stays hidden when the count is zero (non-thinking models, first turn, or
6627 /// turns with no tool calls). When replay tokens dominate the input budget
6628 /// (>50%), the chip turns warning-coloured so users notice that thinking
6629 /// replay is the main consumer of context.
6630 fn footer_reasoning_replay_spans(app: &App) -> Vec<Span<'static>> {
6631 let Some(replay) = app.session.last_reasoning_replay_tokens else {
6632 return Vec::new();
6633 };
6634 if replay == 0 {
6635 return Vec::new();
6636 }
6637 let label = format!("rsn {}", format_token_count_compact(u64::from(replay)));
6638 let color = match app.session.last_prompt_tokens {
6639 Some(input) if input > 0 && f64::from(replay) / f64::from(input) > 0.5 => {
6640 palette::STATUS_WARNING
6641 }
6642 _ => palette::TEXT_MUTED,
6643 };
6644 vec![Span::styled(label, Style::default().fg(color))]
6645 }
6646
6647 #[allow(dead_code)]
6648 fn footer_toast_spans(
6649 toast: &crate::tui::app::StatusToast,
6650 max_width: usize,
6651 ) -> Vec<Span<'static>> {
6652 let truncated = truncate_line_to_width(&toast.text, max_width.max(1));
6653 vec![Span::styled(
6654 truncated,
6655 Style::default().fg(status_color(toast.level)),
6656 )]
6657 }
6658
6659 #[allow(dead_code)]
6660 fn footer_status_line_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
6661 if max_width == 0 {
6662 return Vec::new();
6663 }
6664
6665 let (mode_label, mode_color) = footer_mode_style(app);
6666 let (status_label, status_color) = footer_state_label(app);
6667 let sep = " \u{00B7} ";
6668 let show_status = status_label != "ready";
6669
6670 let fixed_width = mode_label.width()
6671 + sep.width()
6672 + if show_status {
6673 sep.width() + status_label.width()
6674 } else {
6675 0
6676 };
6677
6678 if max_width <= mode_label.width() {
6679 return vec![Span::styled(
6680 truncate_line_to_width(mode_label, max_width),
6681 Style::default().fg(mode_color),
6682 )];
6683 }
6684
6685 let model_budget = max_width.saturating_sub(fixed_width).max(1);
6686 let model_label = truncate_line_to_width(&app.model, model_budget);
6687
6688 let mut spans = vec![
6689 Span::styled(mode_label.to_string(), Style::default().fg(mode_color)),
6690 Span::styled(sep.to_string(), Style::default().fg(app.ui_theme.text_dim)),
6691 Span::styled(model_label, Style::default().fg(app.ui_theme.text_hint)),
6692 ];
6693
6694 if show_status {
6695 spans.push(Span::styled(
6696 sep.to_string(),
6697 Style::default().fg(app.ui_theme.text_dim),
6698 ));
6699 spans.push(Span::styled(
6700 status_label.to_string(),
6701 Style::default().fg(status_color),
6702 ));
6703 }
6704
6705 spans
6706 }
6707
6708 fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Color) {
6709 if app.is_compacting {
6710 return ("compacting \u{238B}", app.ui_theme.status_warning);
6711 }
6712 // Note: we deliberately do NOT show a "thinking" label for `is_loading`.
6713 // The animated water-spout strip in the footer's spacer is the visual
6714 // signal that the model is live; "thinking" was misleading because it
6715 // fired for every kind of in-flight work (tool calls, streaming, etc.),
6716 // not strictly reasoning. Sub-agents still surface "working" because
6717 // that's a distinct lifecycle the user can act on (open `/agents`).
6718 if running_agent_count(app) > 0 {
6719 return ("working", app.ui_theme.status_working);
6720 }
6721 if app.queued_draft.is_some() {
6722 return ("draft", app.ui_theme.text_muted);
6723 }
6724
6725 if !app.view_stack.is_empty() {
6726 return ("overlay", app.ui_theme.text_muted);
6727 }
6728
6729 if !app.input.is_empty() {
6730 return ("draft", app.ui_theme.text_muted);
6731 }
6732
6733 ("ready", app.ui_theme.status_ready)
6734 }
6735
6736 #[allow(dead_code)]
6737 fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) {
6738 let label = app.mode.as_setting();
6739 let color = match app.mode {
6740 crate::tui::app::AppMode::Agent => app.ui_theme.mode_agent,
6741 crate::tui::app::AppMode::Yolo => app.ui_theme.mode_yolo,
6742 crate::tui::app::AppMode::Plan => app.ui_theme.mode_plan,
6743 };
6744 (label, color)
6745 }
6746
6747 fn format_token_count_compact(tokens: u64) -> String {
6748 if tokens >= 1_000_000 {
6749 format!("{:.1}M", tokens as f64 / 1_000_000.0)
6750 } else if tokens >= 1_000 {
6751 format!("{:.1}k", tokens as f64 / 1_000.0)
6752 } else {
6753 tokens.to_string()
6754 }
6755 }
6756
6757 #[allow(dead_code)]
6758 fn format_context_budget(used: i64, max: u32) -> String {
6759 let max_u64 = u64::from(max);
6760 let max_i64 = i64::from(max);
6761
6762 if used > max_i64 {
6763 return format!(
6764 ">{}/{}",
6765 format_token_count_compact(max_u64),
6766 format_token_count_compact(max_u64)
6767 );
6768 }
6769
6770 let used_u64 = u64::try_from(used.max(0)).unwrap_or(0);
6771 format!(
6772 "{}/{}",
6773 format_token_count_compact(used_u64),
6774 format_token_count_compact(max_u64)
6775 )
6776 }
6777
6778 #[allow(dead_code)]
6779 fn spans_width(spans: &[Span<'_>]) -> usize {
6780 spans.iter().map(|span| span.content.width()).sum()
6781 }
6782
6783 #[allow(dead_code)]
6784 fn transcript_scroll_percent(top: usize, visible: usize, total: usize) -> Option<u16> {
6785 if total <= visible {
6786 return None;
6787 }
6788
6789 let max_top = total.saturating_sub(visible);
6790 if max_top == 0 {
6791 return None;
6792 }
6793
6794 let clamped_top = top.min(max_top);
6795 let percent = ((clamped_top as f64 / max_top as f64) * 100.0).round() as u16;
6796 Some(percent.min(100))
6797 }
6798
6799 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
6800 enum SearchDirection {
6801 Forward,
6802 Backward,
6803 }
6804
6805 fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool {
6806 let line_meta = app.viewport.transcript_cache.line_meta();
6807 if line_meta.is_empty() {
6808 return false;
6809 }
6810
6811 let top = app
6812 .viewport
6813 .last_transcript_top
6814 .min(line_meta.len().saturating_sub(1));
6815 let current_cell = line_meta
6816 .get(top)
6817 .and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line)
6818 .map(|(cell_index, _)| cell_index);
6819
6820 let mut scan_indices = Vec::new();
6821 match direction {
6822 SearchDirection::Forward => {
6823 scan_indices.extend((top.saturating_add(1))..line_meta.len());
6824 }
6825 SearchDirection::Backward => {
6826 scan_indices.extend((0..top).rev());
6827 }
6828 }
6829
6830 for idx in scan_indices {
6831 let Some((cell_index, _)) = line_meta[idx].cell_line() else {
6832 continue;
6833 };
6834 if current_cell.is_some_and(|current| current == cell_index) {
6835 continue;
6836 }
6837 if !matches!(app.history.get(cell_index), Some(HistoryCell::Tool(_))) {
6838 continue;
6839 }
6840 if let Some(anchor) = TranscriptScroll::anchor_for(line_meta, idx) {
6841 app.viewport.transcript_scroll = anchor;
6842 app.viewport.pending_scroll_delta = 0;
6843 app.needs_redraw = true;
6844 return true;
6845 }
6846 }
6847
6848 false
6849 }
6850
6851 fn estimated_context_tokens(app: &App) -> Option<i64> {
6852 i64::try_from(estimate_input_tokens_conservative(
6853 &app.api_messages,
6854 app.system_prompt.as_ref(),
6855 ))
6856 .ok()
6857 }
6858
6859 fn context_usage_snapshot(app: &App) -> Option<(i64, u32, f64)> {
6860 let max = context_window_for_model(app.effective_model_for_budget())?;
6861 let max_i64 = i64::from(max);
6862 let reported = app
6863 .session
6864 .last_prompt_tokens
6865 .map(i64::from)
6866 .map(|tokens| tokens.max(0));
6867 let estimated = estimated_context_tokens(app).map(|tokens| tokens.max(0));
6868
6869 // Always prefer the estimated current-context size (computed from
6870 // `app.api_messages`) when we have it. Reported `last_prompt_tokens`
6871 // comes from `Event::TurnComplete.usage`, which the engine builds with
6872 // `turn.add_usage` — that SUMS input_tokens across every round in the
6873 // turn, so a multi-round tool-call turn reports a value much larger
6874 // than the actual context window state, then the next single-round
6875 // turn drops back to a single round's input_tokens. User-visible %
6876 // was bouncing 31% → 9% (#115) because of this. The estimate is
6877 // monotonic wrt conversation growth, which is what a "context filling
6878 // up" indicator should show. We still consult `reported` only as a
6879 // fallback when no estimate is available (e.g., immediately after a
6880 // session restore before the api_messages are populated).
6881 let used = match (estimated, reported) {
6882 (Some(estimated), _) => estimated.min(max_i64),
6883 (None, Some(reported)) => reported.min(max_i64),
6884 (None, None) => return None,
6885 };
6886
6887 let max_f64 = f64::from(max);
6888 let used_f64 = used as f64;
6889 let percent = ((used_f64 / max_f64) * 100.0).clamp(0.0, 100.0);
6890 Some((used, max, percent))
6891 }
6892
6893 /// Retained as a callable utility — `context_usage_snapshot` no longer uses
6894 /// it directly (#115 makes the estimate the primary signal), but tests in
6895 /// `ui/tests.rs` still exercise it and a future heuristic may want to
6896 /// distinguish "obviously inflated reported tokens" from healthy reports.
6897 #[allow(dead_code)]
6898 fn is_reported_context_inflated(reported: i64, estimated: i64) -> bool {
6899 const MIN_ABSOLUTE_GAP: i64 = 4_096;
6900 if estimated <= 0 || reported <= estimated {
6901 return false;
6902 }
6903
6904 reported.saturating_sub(estimated) >= MIN_ABSOLUTE_GAP
6905 && reported >= estimated.saturating_mul(4)
6906 }
6907
6908 fn maybe_warn_context_pressure(app: &mut App) {
6909 let Some((used, max, percent)) = context_usage_snapshot(app) else {
6910 return;
6911 };
6912
6913 if percent < CONTEXT_WARNING_THRESHOLD_PERCENT {
6914 return;
6915 }
6916
6917 let recommendation = if app.auto_compact {
6918 "Auto-compaction is enabled."
6919 } else {
6920 "Consider /compact or /clear."
6921 };
6922
6923 if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
6924 app.status_message = Some(format!(
6925 "Context critical: {:.0}% ({used}/{max} tokens). {recommendation}",
6926 percent
6927 ));
6928 return;
6929 }
6930
6931 if app.status_message.is_none() {
6932 app.status_message = Some(format!(
6933 "Context high: {:.0}% ({used}/{max} tokens). {recommendation}",
6934 percent
6935 ));
6936 }
6937 }
6938
6939 fn should_auto_compact_before_send(app: &App) -> bool {
6940 if !app.auto_compact {
6941 return false;
6942 }
6943 context_usage_snapshot(app)
6944 .map(|(_, _, pct)| pct >= CONTEXT_CRITICAL_THRESHOLD_PERCENT)
6945 .unwrap_or(false)
6946 }
6947
6948 fn status_animation_interval_ms(app: &App) -> u64 {
6949 if app.low_motion {
6950 2_400
6951 } else {
6952 UI_STATUS_ANIMATION_MS
6953 }
6954 }
6955
6956 fn active_poll_ms(app: &App) -> u64 {
6957 if app.low_motion {
6958 96
6959 } else {
6960 UI_ACTIVE_POLL_MS
6961 }
6962 }
6963
6964 fn idle_poll_ms(app: &App) -> u64 {
6965 if app.low_motion { 120 } else { UI_IDLE_POLL_MS }
6966 }
6967
6968 fn clamp_event_poll_timeout(timeout: Duration) -> Duration {
6969 const MIN_EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(1);
6970 timeout.max(MIN_EVENT_POLL_TIMEOUT)
6971 }
6972
6973 fn history_has_live_motion(history: &[HistoryCell]) -> bool {
6974 use crate::tui::history::SubAgentCell;
6975 use crate::tui::widgets::agent_card::AgentLifecycle;
6976 history.iter().any(|cell| match cell {
6977 HistoryCell::Thinking { streaming, .. } => *streaming,
6978 HistoryCell::Tool(tool) => match tool {
6979 ToolCell::Exec(cell) => cell.status == ToolStatus::Running,
6980 ToolCell::Exploring(cell) => cell
6981 .entries
6982 .iter()
6983 .any(|entry| entry.status == ToolStatus::Running),
6984 ToolCell::PlanUpdate(cell) => cell.status == ToolStatus::Running,
6985 ToolCell::PatchSummary(cell) => cell.status == ToolStatus::Running,
6986 ToolCell::Review(cell) => cell.status == ToolStatus::Running,
6987 ToolCell::DiffPreview(_) => false,
6988 ToolCell::Mcp(cell) => cell.status == ToolStatus::Running,
6989 ToolCell::ViewImage(_) => false,
6990 ToolCell::WebSearch(cell) => cell.status == ToolStatus::Running,
6991 ToolCell::Generic(cell) => cell.status == ToolStatus::Running,
6992 },
6993 HistoryCell::SubAgent(SubAgentCell::Delegate(card)) => matches!(
6994 card.status,
6995 AgentLifecycle::Pending | AgentLifecycle::Running
6996 ),
6997 HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => card
6998 .workers
6999 .iter()
7000 .any(|w| matches!(w.status, AgentLifecycle::Pending | AgentLifecycle::Running)),
7001 _ => false,
7002 })
7003 }
7004
7005 pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String {
7006 if max_width == 0 {
7007 return String::new();
7008 }
7009 if UnicodeWidthStr::width(text) <= max_width {
7010 return text.to_string();
7011 }
7012 // For very small budgets, take chars until we exceed the *display* width.
7013 // Counting characters instead of widths (the previous behavior) overran
7014 // the budget for any double-width grapheme and contributed to mid-character
7015 // sidebar artifacts on resize (issue #65).
7016 if max_width <= 3 {
7017 let mut out = String::new();
7018 let mut width = 0usize;
7019 for ch in text.chars() {
7020 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
7021 if width + ch_width > max_width {
7022 break;
7023 }
7024 out.push(ch);
7025 width += ch_width;
7026 }
7027 return out;
7028 }
7029
7030 let mut out = String::new();
7031 let mut width = 0usize;
7032 let limit = max_width.saturating_sub(3);
7033 for ch in text.chars() {
7034 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
7035 if width + ch_width > limit {
7036 break;
7037 }
7038 out.push(ch);
7039 width += ch_width;
7040 }
7041 out.push_str("...");
7042 out
7043 }
7044
7045 fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
7046 if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) {
7047 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
7048 app.view_stack.pop();
7049 open_context_menu(app, mouse);
7050 return Vec::new();
7051 }
7052 return app.view_stack.handle_mouse(mouse);
7053 }
7054
7055 if !app.view_stack.is_empty() {
7056 app.needs_redraw = true;
7057 return app.view_stack.handle_mouse(mouse);
7058 }
7059
7060 match mouse.kind {
7061 MouseEventKind::ScrollUp => {
7062 let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Up);
7063 app.viewport.pending_scroll_delta += update.delta_lines;
7064 if update.delta_lines != 0 {
7065 app.user_scrolled_during_stream = true;
7066 app.needs_redraw = true;
7067 }
7068 }
7069 MouseEventKind::ScrollDown => {
7070 let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Down);
7071 app.viewport.pending_scroll_delta += update.delta_lines;
7072 if update.delta_lines != 0 {
7073 app.user_scrolled_during_stream = true;
7074 app.needs_redraw = true;
7075 }
7076 }
7077 MouseEventKind::Down(MouseButton::Left) => {
7078 if let Some(point) = selection_point_from_mouse(app, mouse) {
7079 app.viewport.transcript_selection.anchor = Some(point);
7080 app.viewport.transcript_selection.head = Some(point);
7081 app.viewport.transcript_selection.dragging = true;
7082
7083 if app.is_loading
7084 && app.viewport.transcript_scroll.is_at_tail()
7085 && let Some(anchor) = TranscriptScroll::anchor_for(
7086 app.viewport.transcript_cache.line_meta(),
7087 app.viewport.last_transcript_top,
7088 )
7089 {
7090 app.viewport.transcript_scroll = anchor;
7091 }
7092 } else if app.viewport.transcript_selection.is_active() {
7093 app.viewport.transcript_selection.clear();
7094 }
7095 }
7096 MouseEventKind::Drag(MouseButton::Left) => {
7097 if app.viewport.transcript_selection.dragging
7098 && let Some(point) = selection_point_from_mouse(app, mouse)
7099 {
7100 app.viewport.transcript_selection.head = Some(point);
7101 }
7102 }
7103 MouseEventKind::Up(MouseButton::Left) if app.viewport.transcript_selection.dragging => {
7104 app.viewport.transcript_selection.dragging = false;
7105 if selection_has_content(app) {
7106 copy_active_selection(app);
7107 }
7108 }
7109 MouseEventKind::Down(MouseButton::Right) => {
7110 open_context_menu(app, mouse);
7111 }
7112 _ => {}
7113 }
7114
7115 Vec::new()
7116 }
7117
7118 fn open_context_menu(app: &mut App, mouse: MouseEvent) {
7119 let entries = build_context_menu_entries(app, mouse);
7120 if entries.is_empty() {
7121 return;
7122 }
7123 app.view_stack
7124 .push(ContextMenuView::new(entries, mouse.column, mouse.row));
7125 app.needs_redraw = true;
7126 }
7127
7128 fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<ContextMenuEntry> {
7129 let mut entries = Vec::new();
7130
7131 if selection_has_content(app) {
7132 entries.push(ContextMenuEntry {
7133 label: "Copy selection".to_string(),
7134 description: "write selected transcript text".to_string(),
7135 action: ContextMenuAction::CopySelection,
7136 });
7137 entries.push(ContextMenuEntry {
7138 label: "Open selection".to_string(),
7139 description: "show selected text in pager".to_string(),
7140 action: ContextMenuAction::OpenSelection,
7141 });
7142 entries.push(ContextMenuEntry {
7143 label: "Clear selection".to_string(),
7144 description: String::new(),
7145 action: ContextMenuAction::ClearSelection,
7146 });
7147 }
7148
7149 if let Some(filtered_cell_index) = transcript_cell_index_from_mouse(app, mouse) {
7150 // Convert filtered index → original virtual index using the
7151 // mapping built in ChatWidget::new. When no cells are collapsed
7152 // this is an identity mapping.
7153 let cell_index = app
7154 .collapsed_cell_map
7155 .get(filtered_cell_index)
7156 .copied()
7157 .unwrap_or(filtered_cell_index);
7158
7159 let target = detail_target_label(app, cell_index)
7160 .map(|label| truncate_line_to_width(&label, 28))
7161 .unwrap_or_else(|| "message".to_string());
7162 entries.push(ContextMenuEntry {
7163 label: "Open details".to_string(),
7164 description: target,
7165 action: ContextMenuAction::OpenDetails { cell_index },
7166 });
7167 entries.push(ContextMenuEntry {
7168 label: "Copy message".to_string(),
7169 description: "write clicked transcript cell".to_string(),
7170 action: ContextMenuAction::CopyCell { cell_index },
7171 });
7172 entries.push(ContextMenuEntry {
7173 label: "Open in editor".to_string(),
7174 description: "open file:line in $EDITOR".to_string(),
7175 action: ContextMenuAction::OpenFileAtLine { cell_index },
7176 });
7177 // Hide/show cell toggle.
7178 if app.collapsed_cells.contains(&cell_index) {
7179 entries.push(ContextMenuEntry {
7180 label: "Show cell".to_string(),
7181 description: "unhide this transcript cell".to_string(),
7182 action: ContextMenuAction::ShowCell { cell_index },
7183 });
7184 } else {
7185 entries.push(ContextMenuEntry {
7186 label: "Hide cell".to_string(),
7187 description: "collapse this transcript cell".to_string(),
7188 action: ContextMenuAction::HideCell { cell_index },
7189 });
7190 }
7191 }
7192
7193 // When cells are hidden, offer a way to show them all.
7194 if !app.collapsed_cells.is_empty() {
7195 let count = app.collapsed_cells.len();
7196 entries.push(ContextMenuEntry {
7197 label: format!("Show hidden ({count})"),
7198 description: "unhide all collapsed cells".to_string(),
7199 action: ContextMenuAction::ShowAllHidden,
7200 });
7201 }
7202
7203 entries.push(ContextMenuEntry {
7204 label: "Paste".to_string(),
7205 description: "insert clipboard into composer".to_string(),
7206 action: ContextMenuAction::Paste,
7207 });
7208 entries.push(ContextMenuEntry {
7209 label: "Command palette".to_string(),
7210 description: "commands, skills, and tools".to_string(),
7211 action: ContextMenuAction::OpenCommandPalette,
7212 });
7213 entries.push(ContextMenuEntry {
7214 label: "Context inspector".to_string(),
7215 description: "active context and cache hints".to_string(),
7216 action: ContextMenuAction::OpenContextInspector,
7217 });
7218 entries.push(ContextMenuEntry {
7219 label: "Help".to_string(),
7220 description: "keybindings and commands".to_string(),
7221 action: ContextMenuAction::OpenHelp,
7222 });
7223
7224 entries
7225 }
7226
7227 fn transcript_cell_index_from_mouse(app: &App, mouse: MouseEvent) -> Option<usize> {
7228 let point = selection_point_from_mouse(app, mouse)?;
7229 app.viewport
7230 .transcript_cache
7231 .line_meta()
7232 .get(point.line_index)
7233 .and_then(|meta| meta.cell_line())
7234 .map(|(cell_index, _)| cell_index)
7235 }
7236
7237 fn handle_context_menu_action(app: &mut App, action: ContextMenuAction) {
7238 match action {
7239 ContextMenuAction::CopySelection => {
7240 copy_active_selection(app);
7241 }
7242 ContextMenuAction::OpenSelection => {
7243 if !open_pager_for_selection(app) {
7244 app.status_message = Some("No selection to open".to_string());
7245 }
7246 }
7247 ContextMenuAction::ClearSelection => {
7248 app.viewport.transcript_selection.clear();
7249 app.status_message = Some("Selection cleared".to_string());
7250 }
7251 ContextMenuAction::CopyCell { cell_index } => {
7252 copy_cell_to_clipboard(app, cell_index);
7253 }
7254 ContextMenuAction::OpenDetails { cell_index } => {
7255 if !open_details_pager_for_cell(app, cell_index) {
7256 app.status_message = Some("No details available for that line".to_string());
7257 }
7258 }
7259 ContextMenuAction::Paste => {
7260 app.paste_from_clipboard();
7261 }
7262 ContextMenuAction::OpenCommandPalette => {
7263 app.view_stack
7264 .push(CommandPaletteView::new(build_command_palette_entries(
7265 app.ui_locale,
7266 &app.skills_dir,
7267 &app.workspace,
7268 &app.mcp_config_path,
7269 app.mcp_snapshot.as_ref(),
7270 )));
7271 }
7272 ContextMenuAction::OpenContextInspector => {
7273 open_context_inspector(app);
7274 }
7275 ContextMenuAction::OpenHelp => {
7276 app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
7277 }
7278 ContextMenuAction::OpenFileAtLine { cell_index } => {
7279 let width = app
7280 .viewport
7281 .last_transcript_area
7282 .map(|area| area.width)
7283 .unwrap_or(80);
7284 let text = history_cell_to_text(
7285 app.cell_at_virtual_index(cell_index)
7286 .unwrap_or(&HistoryCell::System {
7287 content: String::new(),
7288 }),
7289 width,
7290 );
7291 if crate::tui::history::try_open_file_at_line(&text, &app.workspace) {
7292 app.status_message = Some("Opened file in editor".to_string());
7293 } else {
7294 app.status_message = Some("No file:line pattern found in selection".to_string());
7295 }
7296 }
7297 ContextMenuAction::HideCell { cell_index } => {
7298 app.collapsed_cells.insert(cell_index);
7299 app.status_message = Some("Cell hidden".to_string());
7300 }
7301 ContextMenuAction::ShowCell { cell_index } => {
7302 app.collapsed_cells.remove(&cell_index);
7303 app.status_message = Some("Cell shown".to_string());
7304 }
7305 ContextMenuAction::ShowAllHidden => {
7306 let count = app.collapsed_cells.len();
7307 app.collapsed_cells.clear();
7308 app.status_message = Some(format!("{count} hidden cell(s) restored"));
7309 }
7310 }
7311 app.needs_redraw = true;
7312 }
7313
7314 fn selection_point_from_mouse(app: &App, mouse: MouseEvent) -> Option<TranscriptSelectionPoint> {
7315 selection_point_from_position(
7316 app.viewport.last_transcript_area?,
7317 mouse.column,
7318 mouse.row,
7319 app.viewport.last_transcript_top,
7320 app.viewport.last_transcript_total,
7321 app.viewport.last_transcript_padding_top,
7322 )
7323 }
7324
7325 fn selection_point_from_position(
7326 area: Rect,
7327 column: u16,
7328 row: u16,
7329 transcript_top: usize,
7330 transcript_total: usize,
7331 padding_top: usize,
7332 ) -> Option<TranscriptSelectionPoint> {
7333 if column < area.x
7334 || column >= area.x + area.width
7335 || row < area.y
7336 || row >= area.y + area.height
7337 {
7338 return None;
7339 }
7340
7341 if transcript_total == 0 {
7342 return None;
7343 }
7344
7345 let row = row.saturating_sub(area.y) as usize;
7346 if row < padding_top {
7347 return None;
7348 }
7349 let row = row.saturating_sub(padding_top);
7350
7351 let col = column.saturating_sub(area.x) as usize;
7352 let line_index = transcript_top
7353 .saturating_add(row)
7354 .min(transcript_total.saturating_sub(1));
7355
7356 Some(TranscriptSelectionPoint {
7357 line_index,
7358 column: col,
7359 })
7360 }
7361
7362 fn selection_has_content(app: &App) -> bool {
7363 selection_to_text(app).is_some_and(|text| !text.is_empty())
7364 }
7365
7366 fn copy_active_selection(app: &mut App) {
7367 if !app.viewport.transcript_selection.is_active() {
7368 return;
7369 }
7370 if let Some(text) = selection_to_text(app).filter(|text| !text.is_empty()) {
7371 if app.clipboard.write_text(&text).is_ok() {
7372 app.status_message = Some("Selection copied".to_string());
7373 } else {
7374 app.status_message = Some("Copy failed".to_string());
7375 }
7376 } else {
7377 app.viewport.transcript_selection.clear();
7378 app.status_message = Some("No selection to copy".to_string());
7379 }
7380 }
7381
7382 fn selection_to_text(app: &App) -> Option<String> {
7383 let (start, end) = app.viewport.transcript_selection.ordered_endpoints()?;
7384 let lines = app.viewport.transcript_cache.lines();
7385 if lines.is_empty() {
7386 return None;
7387 }
7388 let end_index = end.line_index.min(lines.len().saturating_sub(1));
7389 let start_index = start.line_index.min(end_index);
7390
7391 let mut selected_lines = Vec::new();
7392 #[allow(clippy::needless_range_loop)]
7393 for line_index in start_index..=end_index {
7394 let line_text = line_to_plain(&lines[line_index]);
7395 let line_width = text_display_width(&line_text);
7396 let (col_start, col_end) = if start_index == end_index {
7397 (start.column, end.column)
7398 } else if line_index == start_index {
7399 (start.column, line_width)
7400 } else if line_index == end_index {
7401 (0, end.column)
7402 } else {
7403 (0, line_width)
7404 };
7405
7406 let slice = slice_text(&line_text, col_start, col_end);
7407 selected_lines.push(slice);
7408 }
7409 Some(selected_lines.join("\n"))
7410 }
7411
7412 fn open_pager_for_selection(app: &mut App) -> bool {
7413 let Some(text) = selection_to_text(app) else {
7414 return false;
7415 };
7416 let width = app
7417 .viewport
7418 .last_transcript_area
7419 .map(|area| area.width)
7420 .unwrap_or(80);
7421 let pager = PagerView::from_text("Selection", &text, width.saturating_sub(2));
7422 app.view_stack.push(pager);
7423 true
7424 }
7425
7426 fn open_pager_for_last_message(app: &mut App) -> bool {
7427 let Some(cell) = app.history.last() else {
7428 return false;
7429 };
7430 let width = app
7431 .viewport
7432 .last_transcript_area
7433 .map(|area| area.width)
7434 .unwrap_or(80);
7435 let text = history_cell_to_text(cell, width);
7436 let pager = PagerView::from_text("Message", &text, width.saturating_sub(2));
7437 app.view_stack.push(pager);
7438 true
7439 }
7440
7441 /// Open a pager showing the full thinking block. Targets the cell at the
7442 /// current selection if it's a Thinking cell; otherwise falls back to the
7443 /// most recent Thinking cell in history. Bound to Ctrl+O so users can read
7444 /// reasoning content that's been collapsed in calm-mode rendering.
7445 fn open_thinking_pager(app: &mut App) -> bool {
7446 let selected_cell = app
7447 .viewport
7448 .transcript_selection
7449 .ordered_endpoints()
7450 .and_then(|(start, _)| {
7451 app.viewport
7452 .transcript_cache
7453 .line_meta()
7454 .get(start.line_index)
7455 .and_then(|meta| meta.cell_line())
7456 .map(|(cell_index, _)| cell_index)
7457 })
7458 .filter(|&idx| {
7459 matches!(
7460 app.history.get(idx),
7461 Some(crate::tui::history::HistoryCell::Thinking { .. })
7462 )
7463 });
7464
7465 let target_idx = selected_cell.or_else(|| {
7466 app.history
7467 .iter()
7468 .enumerate()
7469 .rev()
7470 .find_map(|(idx, cell)| {
7471 if matches!(cell, crate::tui::history::HistoryCell::Thinking { .. }) {
7472 Some(idx)
7473 } else {
7474 None
7475 }
7476 })
7477 });
7478
7479 let Some(idx) = target_idx else {
7480 app.status_message = Some("No thinking blocks to expand".to_string());
7481 return true;
7482 };
7483
7484 let cell = &app.history[idx];
7485 let width = app
7486 .viewport
7487 .last_transcript_area
7488 .map(|area| area.width)
7489 .unwrap_or(80);
7490 let text = history_cell_to_text(cell, width);
7491 app.view_stack.push(PagerView::from_text(
7492 "Thinking",
7493 &text,
7494 width.saturating_sub(2),
7495 ));
7496 true
7497 }
7498
7499 fn open_tool_details_pager(app: &mut App) -> bool {
7500 let target_cell = detail_target_cell_index(app);
7501
7502 let Some(cell_index) = target_cell else {
7503 return false;
7504 };
7505 open_details_pager_for_cell(app, cell_index)
7506 }
7507
7508 /// Build the trailing "Spillover" section for the tool-details pager
7509 /// (#500). Returns `None` when the cell at `cell_index` is not a
7510 /// `GenericToolCell` with a recorded spillover path, or when the
7511 /// spillover file is missing or unreadable. Failures fall back to a
7512 /// short notice in the section so the user understands why the full
7513 /// content can't be loaded — better than silent truncation.
7514 fn spillover_pager_section(app: &App, cell_index: usize) -> Option<String> {
7515 use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell};
7516
7517 let cell = app.cell_at_virtual_index(cell_index)?;
7518 let HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
7519 spillover_path: Some(path),
7520 ..
7521 })) = cell
7522 else {
7523 return None;
7524 };
7525 let path_str = path.display().to_string();
7526 let body = match std::fs::read_to_string(path) {
7527 Ok(text) => text,
7528 Err(err) => format!("(could not read spillover file: {err})"),
7529 };
7530 Some(format!(
7531 "── Full output (spillover) ──\nFile: {path_str}\n\n{body}"
7532 ))
7533 }
7534
7535 fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool {
7536 if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
7537 let input = serde_json::to_string_pretty(&detail.input)
7538 .unwrap_or_else(|_| detail.input.to_string());
7539 let output = detail.output.as_deref().map_or(
7540 "(not available)".to_string(),
7541 std::string::ToString::to_string,
7542 );
7543
7544 // #500: when the tool result was spilled to disk, fold the full
7545 // file content into the pager body so the user can see what was
7546 // elided (the model only ever saw the head). The truncated head
7547 // stays above as `Output:` so the user can compare what the
7548 // model received against the full payload.
7549 let spillover_section = spillover_pager_section(app, cell_index);
7550
7551 let content = if let Some(section) = spillover_section {
7552 format!(
7553 "Tool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}\n\n{}",
7554 detail.tool_id, detail.tool_name, input, output, section
7555 )
7556 } else {
7557 format!(
7558 "Tool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}",
7559 detail.tool_id, detail.tool_name, input, output
7560 )
7561 };
7562
7563 let width = app
7564 .viewport
7565 .last_transcript_area
7566 .map(|area| area.width)
7567 .unwrap_or(80);
7568 app.view_stack.push(PagerView::from_text(
7569 format!("Tool: {}", detail.tool_name),
7570 &content,
7571 width.saturating_sub(2),
7572 ));
7573 return true;
7574 }
7575
7576 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
7577 app.status_message = Some("No details available for the selected line".to_string());
7578 return false;
7579 };
7580 let title = match cell {
7581 HistoryCell::User { .. } => "You".to_string(),
7582 HistoryCell::Assistant { .. } => "Assistant".to_string(),
7583 HistoryCell::System { .. } => "Note".to_string(),
7584 HistoryCell::Error { .. } => "Error".to_string(),
7585 HistoryCell::Thinking { .. } => "Reasoning".to_string(),
7586 HistoryCell::Tool(_) => "Message".to_string(),
7587 HistoryCell::SubAgent(_) => "Sub-agent".to_string(),
7588 HistoryCell::ArchivedContext { .. } => "Archived Context".to_string(),
7589 };
7590 let width = app
7591 .viewport
7592 .last_transcript_area
7593 .map(|area| area.width)
7594 .unwrap_or(80);
7595 let content = history_cell_to_text(cell, width);
7596 app.view_stack.push(PagerView::from_text(
7597 title,
7598 &content,
7599 width.saturating_sub(2),
7600 ));
7601 true
7602 }
7603
7604 /// Copy the "focused" transcript cell to the system clipboard.
7605 /// The focused cell is determined by the detail-target heuristic
7606 /// (viewport centre or most recent cell). Returns true when text
7607 /// was actually copied.
7608 fn copy_focused_cell(app: &mut App) -> bool {
7609 let cell_index = detail_target_cell_index(app);
7610 let Some(index) = cell_index else {
7611 return false;
7612 };
7613 copy_cell_to_clipboard(app, index)
7614 }
7615
7616 fn copy_cell_to_clipboard(app: &mut App, cell_index: usize) -> bool {
7617 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
7618 app.status_message = Some("No message at that line".to_string());
7619 return false;
7620 };
7621 let width = app
7622 .viewport
7623 .last_transcript_area
7624 .map(|area| area.width)
7625 .unwrap_or(80);
7626 let text = history_cell_to_text(cell, width);
7627 if text.trim().is_empty() {
7628 app.status_message = Some("Message is empty".to_string());
7629 return false;
7630 }
7631 if app.clipboard.write_text(&text).is_ok() {
7632 app.status_message = Some("Message copied".to_string());
7633 true
7634 } else {
7635 app.status_message = Some("Copy failed".to_string());
7636 false
7637 }
7638 }
7639
7640 fn detail_target_cell_index(app: &App) -> Option<usize> {
7641 if let Some((start, _)) = app.viewport.transcript_selection.ordered_endpoints() {
7642 return app
7643 .viewport
7644 .transcript_cache
7645 .line_meta()
7646 .get(start.line_index)
7647 .and_then(|meta| meta.cell_line())
7648 .map(|(cell_index, _)| cell_index);
7649 }
7650
7651 app.detail_cell_index_for_viewport(
7652 app.viewport.last_transcript_top,
7653 app.viewport.last_transcript_visible.max(1),
7654 app.viewport.transcript_cache.line_meta(),
7655 )
7656 .or_else(|| app.history.len().checked_sub(1))
7657 }
7658
7659 fn selected_detail_footer_label(app: &App) -> Option<String> {
7660 if app.viewport.transcript_selection.is_active() {
7661 return None;
7662 }
7663 let cell_index = app.detail_cell_index_for_viewport(
7664 app.viewport.last_transcript_top,
7665 app.viewport.last_transcript_visible.max(1),
7666 app.viewport.transcript_cache.line_meta(),
7667 )?;
7668 let label = detail_target_label(app, cell_index)?;
7669 Some(format!(
7670 "Alt+V details: {}",
7671 truncate_line_to_width(&label, 34)
7672 ))
7673 }
7674
7675 fn detail_target_label(app: &App, cell_index: usize) -> Option<String> {
7676 if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
7677 return Some(detail.tool_name.clone());
7678 }
7679 let cell = app.cell_at_virtual_index(cell_index)?;
7680 match cell {
7681 HistoryCell::Tool(ToolCell::Exec(exec)) => {
7682 Some(format!("run {}", one_line_summary(&exec.command, 80)))
7683 }
7684 HistoryCell::Tool(ToolCell::Exploring(explore)) => Some(format!(
7685 "workspace {} item{}",
7686 explore.entries.len(),
7687 if explore.entries.len() == 1 { "" } else { "s" }
7688 )),
7689 HistoryCell::Tool(ToolCell::PlanUpdate(_)) => Some("update plan".to_string()),
7690 HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(format!("patch {}", patch.path)),
7691 HistoryCell::Tool(ToolCell::Review(review)) => {
7692 let target = one_line_summary(&review.target, 80);
7693 Some(if target.is_empty() {
7694 "review".to_string()
7695 } else {
7696 format!("review {target}")
7697 })
7698 }
7699 HistoryCell::Tool(ToolCell::DiffPreview(diff)) => Some(format!("diff {}", diff.title)),
7700 HistoryCell::Tool(ToolCell::Mcp(mcp)) => Some(format!("tool {}", mcp.tool)),
7701 HistoryCell::Tool(ToolCell::ViewImage(image)) => {
7702 Some(format!("image {}", image.path.display()))
7703 }
7704 HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)),
7705 HistoryCell::Tool(ToolCell::Generic(generic)) => Some(format!("tool {}", generic.name)),
7706 HistoryCell::SubAgent(_) => Some("sub-agent".to_string()),
7707 _ => None,
7708 }
7709 }
7710
7711 fn is_copy_shortcut(key: &KeyEvent) -> bool {
7712 let is_c = matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'));
7713 if !is_c {
7714 return false;
7715 }
7716
7717 if key.modifiers.contains(KeyModifiers::SUPER) {
7718 return true;
7719 }
7720
7721 key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT)
7722 }
7723
7724 fn is_file_tree_toggle_shortcut(key: &KeyEvent) -> bool {
7725 let is_shifted_e = matches!(key.code, KeyCode::Char('E'))
7726 || (matches!(key.code, KeyCode::Char('e')) && key.modifiers.contains(KeyModifiers::SHIFT));
7727 if !is_shifted_e {
7728 return false;
7729 }
7730
7731 let has_forbidden_modifier =
7732 key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::SUPER);
7733 let ctrl_shift_e = key.modifiers.contains(KeyModifiers::CONTROL) && !has_forbidden_modifier;
7734
7735 let cmd_shift_e = key.modifiers.contains(KeyModifiers::SUPER)
7736 && key.modifiers.contains(KeyModifiers::SHIFT)
7737 && !key.modifiers.contains(KeyModifiers::CONTROL)
7738 && !key.modifiers.contains(KeyModifiers::ALT);
7739
7740 ctrl_shift_e || cmd_shift_e
7741 }
7742
7743 fn details_shortcut_modifiers(modifiers: KeyModifiers) -> bool {
7744 modifiers.is_empty()
7745 || modifiers == KeyModifiers::SHIFT
7746 || (modifiers.contains(KeyModifiers::ALT)
7747 && !modifiers.contains(KeyModifiers::CONTROL)
7748 && !modifiers.contains(KeyModifiers::SUPER))
7749 }
7750
7751 fn is_paste_shortcut(key: &KeyEvent) -> bool {
7752 let is_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'));
7753 let is_legacy_ctrl_v = matches!(key.code, KeyCode::Char('\u{16}'));
7754 if !is_v && !is_legacy_ctrl_v {
7755 return false;
7756 }
7757
7758 if is_legacy_ctrl_v {
7759 return true;
7760 }
7761
7762 // Cmd+V on macOS
7763 if key.modifiers.contains(KeyModifiers::SUPER) {
7764 return true;
7765 }
7766
7767 // Ctrl+V on Linux/Windows
7768 key.modifiers.contains(KeyModifiers::CONTROL)
7769 }
7770
7771 fn is_text_input_key(key: &KeyEvent) -> bool {
7772 if matches!(key.code, KeyCode::Char(c) if c.is_control()) {
7773 return false;
7774 }
7775
7776 !key.modifiers.contains(KeyModifiers::CONTROL)
7777 && !key.modifiers.contains(KeyModifiers::ALT)
7778 && !key.modifiers.contains(KeyModifiers::SUPER)
7779 }
7780
7781 fn is_ctrl_h_backspace(key: &KeyEvent) -> bool {
7782 matches!(key.code, KeyCode::Char('h'))
7783 && key.modifiers.contains(KeyModifiers::CONTROL)
7784 && !key.modifiers.contains(KeyModifiers::ALT)
7785 && !key.modifiers.contains(KeyModifiers::SUPER)
7786 }
7787
7788 fn should_scroll_with_arrows(app: &App) -> bool {
7789 // When the composer is empty (or only whitespace), Up/Down arrows
7790 // scroll the transcript. When the composer has text, they navigate
7791 // composer history so the user can recall previous prompts.
7792 // Cmd+Up / Alt+Up always scroll regardless, handled upstream.
7793 app.input.trim().is_empty()
7794 }
7795
7796 fn extract_reasoning_header(text: &str) -> Option<String> {
7797 let start = text.find("**")?;
7798 let rest = &text[start + 2..];
7799 let end = rest.find("**")?;
7800 let header = rest[..end].trim().trim_end_matches(':');
7801 if header.is_empty() {
7802 None
7803 } else {
7804 Some(header.to_string())
7805 }
7806 }
7807
7808 #[cfg(test)]
7809 mod tests;
7810
7810 lines RUST