| 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 | ¤t_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 |