返回 DeepSeek-TUI-2026
app.rs
根目录 / crates / tui / src / tui / app.rs
1 //! Application state for the `DeepSeek` TUI.
2
3 use std::collections::{HashMap, HashSet, VecDeque};
4 use std::path::{Path, PathBuf};
5 use std::time::{Duration, Instant};
6
7 use ratatui::layout::Rect;
8 use serde_json::Value;
9 use thiserror::Error;
10
11 use crate::compaction::CompactionConfig;
12 use crate::config::{
13 ApiProvider, Config, DEFAULT_TEXT_MODEL, SavedCredential, has_api_key, save_api_key,
14 };
15 use crate::config_ui::ConfigUiMode;
16 use crate::core::coherence::CoherenceState;
17 use crate::cycle_manager::{CycleBriefing, CycleConfig};
18 use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
19 use crate::localization::{Locale, MessageId, resolve_locale, tr};
20 use crate::models::{Message, SystemPrompt, compaction_threshold_for_model_and_effort};
21 use crate::palette::{self, UiTheme};
22 use crate::pricing::{CostCurrency, CostEstimate};
23 use crate::session_manager::SessionContextReference;
24 use crate::settings::Settings;
25 use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
26 use crate::tools::shell::new_shared_shell_manager;
27 use crate::tools::spec::RuntimeToolServices;
28 use crate::tools::subagent::SubAgentResult;
29 use crate::tools::todo::{SharedTodoList, new_shared_todo_list};
30 use crate::tui::active_cell::ActiveCell;
31 use crate::tui::approval::ApprovalMode;
32 use crate::tui::clipboard::{ClipboardContent, ClipboardHandler};
33 use crate::tui::file_mention::ContextReference;
34 use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
35 use crate::tui::paste_burst::{FlushResult, PasteBurst};
36 use crate::tui::scrolling::{MouseScrollState, TranscriptLineMeta, TranscriptScroll};
37 use crate::tui::selection::TranscriptSelection;
38 use crate::tui::streaming::StreamingState;
39 use crate::tui::transcript::TranscriptViewCache;
40 use crate::tui::views::ViewStack;
41 use crate::utils::is_chinese_system_locale;
42
43 // === Types ===
44
45 /// State machine for onboarding new users.
46 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
47 pub enum OnboardingState {
48 Welcome,
49 /// Pick the UI locale before any other config decisions (#566).
50 /// Defaults to auto-detection from `LC_ALL` / `LANG`; explicit picks
51 /// land in `~/.deepseek/settings.toml` via `Settings::set("locale", …)`.
52 Language,
53 ApiKey,
54 TrustDirectory,
55 Tips,
56 None,
57 }
58
59 fn initial_onboarding_state(
60 skip_onboarding: bool,
61 was_onboarded: bool,
62 needs_api_key: bool,
63 needs_workspace_trust: bool,
64 ) -> OnboardingState {
65 if skip_onboarding || (was_onboarded && !needs_api_key && !needs_workspace_trust) {
66 return OnboardingState::None;
67 }
68
69 if was_onboarded && needs_api_key {
70 OnboardingState::ApiKey
71 } else if was_onboarded && needs_workspace_trust {
72 OnboardingState::TrustDirectory
73 } else {
74 OnboardingState::Welcome
75 }
76 }
77
78 fn onboarding_is_workspace_trust_gate(
79 skip_onboarding: bool,
80 was_onboarded: bool,
81 needs_api_key: bool,
82 needs_workspace_trust: bool,
83 ) -> bool {
84 !skip_onboarding && was_onboarded && !needs_api_key && needs_workspace_trust
85 }
86
87 /// Supported application modes for the TUI.
88 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
89 pub enum AppMode {
90 Agent,
91 Yolo,
92 Plan,
93 }
94
95 /// One row in the per-turn cache-telemetry ring (`/cache` debug surface, #263).
96 #[derive(Debug, Clone)]
97 pub struct TurnCacheRecord {
98 /// Provider-reported total input tokens for the turn (cache-hit +
99 /// cache-miss + uncategorized). Useful for sanity-checking that hits +
100 /// misses sum back to roughly the prompt size.
101 pub input_tokens: u32,
102 /// Provider-reported output tokens.
103 pub output_tokens: u32,
104 /// `prompt_cache_hit_tokens` from DeepSeek's usage payload. `None` when
105 /// the model in use does not report cache telemetry (see
106 /// `Capabilities::cache_telemetry_supported`).
107 pub cache_hit_tokens: Option<u32>,
108 /// `prompt_cache_miss_tokens`. `None` when the provider did not report it
109 /// — in that case the `/cache` formatter infers the miss as
110 /// `input_tokens − cache_hit_tokens`.
111 pub cache_miss_tokens: Option<u32>,
112 /// Approximate tokens spent re-sending prior `reasoning_content` on
113 /// V4-thinking tool-calling turns (chars/3 heuristic). Helps separate
114 /// cache misses caused by reasoning-replay churn from misses caused by
115 /// real prefix instability.
116 pub reasoning_replay_tokens: Option<u32>,
117 /// Local timestamp the turn telemetry was recorded.
118 pub recorded_at: Instant,
119 }
120
121 /// DeepSeek reasoning-effort tier, mirrored on ChatGPT/Claude effort pickers.
122 ///
123 /// The config file accepts all five string values for forward-compat with
124 /// providers that expose the full spectrum; DeepSeek currently collapses
125 /// `Low`/`Medium` → `high` and `Max` → `max` at the API boundary. The
126 /// keyboard cycler (Shift+Tab) walks only the three behaviorally distinct
127 /// tiers: `Off` → `High` → `Max` → `Off`.
128 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
129 pub enum ReasoningEffort {
130 Off,
131 Low,
132 Medium,
133 High,
134 Auto,
135 #[default]
136 Max,
137 }
138
139 impl ReasoningEffort {
140 /// Parse a config-file string into an effort tier. Unknown values fall
141 /// back to the default (`Max`) rather than erroring out.
142 #[must_use]
143 pub fn from_setting(value: &str) -> Self {
144 match value.trim().to_ascii_lowercase().as_str() {
145 "off" | "disabled" | "none" | "false" => Self::Off,
146 "low" | "minimal" => Self::Low,
147 "medium" | "mid" => Self::Medium,
148 "high" => Self::High,
149 "auto" | "automatic" => Self::Auto,
150 "max" | "maximum" | "xhigh" => Self::Max,
151 _ => Self::default(),
152 }
153 }
154
155 /// Canonical lowercase label used for config storage and UI hints.
156 #[must_use]
157 pub fn as_setting(self) -> &'static str {
158 match self {
159 Self::Off => "off",
160 Self::Low => "low",
161 Self::Medium => "medium",
162 Self::High => "high",
163 Self::Auto => "auto",
164 Self::Max => "max",
165 }
166 }
167
168 /// Short label for the header chip.
169 #[must_use]
170 pub fn short_label(self) -> &'static str {
171 match self {
172 Self::Off => "off",
173 Self::Low => "low",
174 Self::Medium => "med",
175 Self::High => "high",
176 Self::Auto => "auto",
177 Self::Max => "max",
178 }
179 }
180
181 /// Value forwarded to the engine/client. `None` means "provider default"
182 /// (for `Off` we still emit `"off"` so the client can inject
183 /// `thinking = {"type": "disabled"}`).
184 #[must_use]
185 pub fn api_value(self) -> Option<&'static str> {
186 Some(self.as_setting())
187 }
188
189 /// Cycle through the three behaviorally distinct tiers.
190 #[must_use]
191 pub fn cycle_next(self) -> Self {
192 match self {
193 Self::Off => Self::High,
194 Self::Auto => Self::Off,
195 Self::Low | Self::Medium | Self::High => Self::Max,
196 Self::Max => Self::Off,
197 }
198 }
199 }
200
201 /// Sidebar content focus mode.
202 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
203 pub enum SidebarFocus {
204 Auto,
205 Plan,
206 Todos,
207 Tasks,
208 Agents,
209 Context,
210 }
211
212 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
213 pub enum ComposerDensity {
214 Compact,
215 Comfortable,
216 Spacious,
217 }
218
219 impl ComposerDensity {
220 #[must_use]
221 pub fn from_setting(value: &str) -> Self {
222 match value.trim().to_ascii_lowercase().as_str() {
223 "compact" | "tight" => Self::Compact,
224 "spacious" | "loose" => Self::Spacious,
225 _ => Self::Comfortable,
226 }
227 }
228 }
229
230 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
231 pub enum TranscriptSpacing {
232 Compact,
233 Comfortable,
234 Spacious,
235 }
236
237 impl TranscriptSpacing {
238 #[must_use]
239 pub fn from_setting(value: &str) -> Self {
240 match value.trim().to_ascii_lowercase().as_str() {
241 "compact" | "tight" => Self::Compact,
242 "spacious" | "loose" => Self::Spacious,
243 _ => Self::Comfortable,
244 }
245 }
246 }
247
248 impl SidebarFocus {
249 #[must_use]
250 pub fn from_setting(value: &str) -> Self {
251 match value.trim().to_ascii_lowercase().as_str() {
252 "plan" => Self::Plan,
253 "todos" => Self::Todos,
254 "tasks" => Self::Tasks,
255 "agents" | "subagents" | "sub-agents" => Self::Agents,
256 "context" | "session" => Self::Context,
257 _ => Self::Auto,
258 }
259 }
260
261 #[must_use]
262 #[allow(dead_code)]
263 pub fn as_setting(self) -> &'static str {
264 match self {
265 Self::Auto => "auto",
266 Self::Plan => "plan",
267 Self::Todos => "todos",
268 Self::Tasks => "tasks",
269 Self::Agents => "agents",
270 Self::Context => "context",
271 }
272 }
273 }
274
275 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
276 pub enum StatusToastLevel {
277 Info,
278 Success,
279 Warning,
280 Error,
281 }
282
283 #[derive(Debug, Clone)]
284 pub struct StatusToast {
285 pub text: String,
286 pub level: StatusToastLevel,
287 pub created_at: Instant,
288 pub ttl_ms: Option<u64>,
289 }
290
291 impl StatusToast {
292 #[must_use]
293 pub fn new(text: impl Into<String>, level: StatusToastLevel, ttl_ms: Option<u64>) -> Self {
294 Self {
295 text: text.into(),
296 level,
297 created_at: Instant::now(),
298 ttl_ms,
299 }
300 }
301
302 #[must_use]
303 pub fn is_expired(&self, now: Instant) -> bool {
304 self.ttl_ms
305 .is_some_and(|ttl| now.duration_since(self.created_at).as_millis() >= u128::from(ttl))
306 }
307 }
308
309 #[derive(Debug, Clone, PartialEq, Eq)]
310 pub struct ComposerHistorySearch {
311 pre_search_input: String,
312 pre_search_cursor: usize,
313 query: String,
314 selected: usize,
315 }
316
317 impl ComposerHistorySearch {
318 fn new(pre_search_input: String, pre_search_cursor: usize) -> Self {
319 Self {
320 pre_search_input,
321 pre_search_cursor,
322 query: String::new(),
323 selected: 0,
324 }
325 }
326 }
327
328 #[derive(Debug, Clone, PartialEq, Eq)]
329 pub(crate) struct InputHistoryDraft {
330 input: String,
331 cursor: usize,
332 }
333
334 fn char_count(text: &str) -> usize {
335 text.chars().count()
336 }
337
338 fn byte_index_at_char(text: &str, char_index: usize) -> usize {
339 if char_index == 0 {
340 return 0;
341 }
342 text.char_indices()
343 .nth(char_index)
344 .map(|(idx, _)| idx)
345 .unwrap_or_else(|| text.len())
346 }
347
348 fn remove_char_at(text: &mut String, char_index: usize) -> bool {
349 let start = byte_index_at_char(text, char_index);
350 if start >= text.len() {
351 return false;
352 }
353 let ch = text[start..].chars().next().unwrap();
354 let end = start + ch.len_utf8();
355 text.replace_range(start..end, "");
356 true
357 }
358
359 fn normalize_paste_text(text: &str) -> String {
360 if text.contains('\r') {
361 text.replace("\r\n", "\n").replace('\r', "")
362 } else {
363 text.to_string()
364 }
365 }
366
367 fn sanitize_api_key_text(text: &str) -> String {
368 text.chars().filter(|c| !c.is_control()).collect()
369 }
370
371 const MAX_SUBMITTED_INPUT_CHARS: usize = 16_000;
372 const MAX_DRAFT_HISTORY: usize = 50;
373
374 impl AppMode {
375 #[must_use]
376 pub fn from_setting(value: &str) -> Self {
377 match value.trim().to_ascii_lowercase().as_str() {
378 "plan" => Self::Plan,
379 "yolo" => Self::Yolo,
380 _ => Self::Agent,
381 }
382 }
383
384 #[must_use]
385 pub fn as_setting(self) -> &'static str {
386 match self {
387 Self::Agent => "agent",
388 Self::Yolo => "yolo",
389 Self::Plan => "plan",
390 }
391 }
392
393 /// Short label used in the UI footer.
394 pub fn label(self) -> &'static str {
395 match self {
396 AppMode::Agent => "AGENT",
397 AppMode::Yolo => "YOLO",
398 AppMode::Plan => "PLAN",
399 }
400 }
401
402 #[allow(dead_code)]
403 /// Description shown in help or onboarding text.
404 pub fn description(self) -> &'static str {
405 match self {
406 AppMode::Agent => "Agent mode - autonomous task execution with tools",
407 AppMode::Yolo => "YOLO mode - full tool access without approvals",
408 AppMode::Plan => "Plan mode - design before implementing",
409 }
410 }
411 }
412
413 /// Configuration required to bootstrap the TUI.
414 #[derive(Clone)]
415 #[allow(clippy::struct_excessive_bools)]
416 pub struct TuiOptions {
417 pub model: String,
418 pub workspace: PathBuf,
419 pub config_path: Option<PathBuf>,
420 pub config_profile: Option<String>,
421 pub allow_shell: bool,
422 /// Use the alternate screen buffer (fullscreen TUI).
423 pub use_alt_screen: bool,
424 /// Capture mouse input for internal scrolling/selection.
425 pub use_mouse_capture: bool,
426 /// Enable terminal bracketed-paste mode (OSC `?2004h` / `?2004l`). Defaults
427 /// on; settable via `bracketed_paste = false` in `settings.toml` for the
428 /// rare terminal that mishandles it.
429 pub use_bracketed_paste: bool,
430 /// Maximum number of concurrent sub-agents.
431 pub max_subagents: usize,
432 #[allow(dead_code)]
433 pub skills_dir: PathBuf,
434 #[allow(dead_code)]
435 pub memory_path: PathBuf,
436 #[allow(dead_code)]
437 pub notes_path: PathBuf,
438 #[allow(dead_code)]
439 pub mcp_config_path: PathBuf,
440 #[allow(dead_code)]
441 pub use_memory: bool,
442 /// Start in agent mode (defaults to agent; --yolo starts in YOLO)
443 pub start_in_agent_mode: bool,
444 /// Skip onboarding screens
445 pub skip_onboarding: bool,
446 /// Auto-approve tool executions (yolo mode)
447 pub yolo: bool,
448 /// Resume a previous session by ID
449 pub resume_session_id: Option<String>,
450 /// Pre-populate the composer with this text when the TUI starts.
451 /// Used by `deepseek pr <N>` (#451) to drop the model into a
452 /// session with the PR context already typed — the user can edit
453 /// before sending or hit Enter to fire as-is.
454 pub initial_input: Option<String>,
455 }
456
457 #[derive(Debug, Clone, Copy)]
458 struct YoloRestoreState {
459 allow_shell: bool,
460 trust_mode: bool,
461 approval_mode: ApprovalMode,
462 }
463
464 // === Sub-state structs for App field organization (#377) ===
465
466 /// Vim modal editing mode for the composer input area.
467 ///
468 /// Enabled via `[composer] mode = "vim"` in `settings.toml`. When the
469 /// composer vim mode is active the user starts in `Normal` mode and presses
470 /// `i`, `a`, or `o` to enter `Insert` mode. `Esc` from `Insert` returns to
471 /// `Normal`. Standard vim motions (`h`/`j`/`k`/`l`, `w`/`b`, `0`/`$`, `x`,
472 /// `dd`) work in `Normal` mode. `Visual` is reserved for future selection
473 /// support and currently behaves like `Normal`.
474 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
475 pub enum VimMode {
476 /// Normal / command mode — motions and operators, no text insertion.
477 #[default]
478 Normal,
479 /// Insert mode — characters are appended at the cursor as typed.
480 Insert,
481 /// Visual mode — reserved for future selection support.
482 Visual,
483 }
484
485 impl VimMode {
486 /// Short status-bar label shown in the composer border.
487 #[must_use]
488 pub fn label(self) -> &'static str {
489 match self {
490 Self::Normal => "-- NORMAL --",
491 Self::Insert => "-- INSERT --",
492 Self::Visual => "-- VISUAL --",
493 }
494 }
495 }
496
497 /// Cached @-mention completion results to avoid re-walking the filesystem when
498 /// the cursor moves inside the same mention token.
499 #[derive(Debug, Clone)]
500 pub struct MentionCompletionCache {
501 /// Workspace root used for this completion walk.
502 pub workspace: PathBuf,
503 /// Process cwd captured for cwd-relative completion entries.
504 pub cwd: Option<PathBuf>,
505 /// The partial text after `@` that triggered this completion.
506 pub partial: String,
507 /// Candidate limit used for this completion walk.
508 pub limit: usize,
509 /// Cached completion entries.
510 pub entries: Vec<String>,
511 }
512
513 /// Composer input state — grouped fields for the text input area.
514 pub struct ComposerState {
515 /// Current composer text content.
516 pub input: String,
517 /// Cursor position within `input` (in characters).
518 pub cursor_position: usize,
519 /// Single-entry kill buffer for emacs-style `Ctrl+K` cut / `Ctrl+Y` yank.
520 pub kill_buffer: String,
521 pub paste_burst: PasteBurst,
522 pub input_history: Vec<String>,
523 pub draft_history: VecDeque<String>,
524 pub history_index: Option<usize>,
525 pub(crate) history_navigation_draft: Option<InputHistoryDraft>,
526 pub composer_history_search: Option<ComposerHistorySearch>,
527 pub selected_attachment_index: Option<usize>,
528 pub slash_menu_selected: usize,
529 pub slash_menu_hidden: bool,
530 pub mention_menu_selected: usize,
531 pub mention_menu_hidden: bool,
532 /// Cached @-mention completions to avoid re-walking the filesystem when
533 /// the cursor moves inside the same mention token.
534 pub mention_completion_cache: Option<MentionCompletionCache>,
535 /// Whether vim modal editing is enabled for this composer.
536 /// Sourced from `Settings::composer_vim_mode` at startup.
537 pub vim_enabled: bool,
538 /// Current vim editing mode. Only meaningful when `vim_enabled` is true.
539 pub vim_mode: VimMode,
540 /// Pending `d` prefix for the `dd` delete-line operator. Set when the
541 /// user presses `d` in Normal mode; cleared on the next key (either `d`
542 /// to complete `dd`, or any other key to cancel).
543 pub vim_pending_d: bool,
544 }
545
546 impl Default for ComposerState {
547 fn default() -> Self {
548 Self {
549 input: String::new(),
550 cursor_position: 0,
551 kill_buffer: String::new(),
552 paste_burst: PasteBurst::default(),
553 input_history: Vec::new(),
554 draft_history: VecDeque::new(),
555 history_index: None,
556 history_navigation_draft: None,
557 composer_history_search: None,
558 selected_attachment_index: None,
559 slash_menu_selected: 0,
560 slash_menu_hidden: false,
561 mention_menu_selected: 0,
562 mention_menu_hidden: false,
563 mention_completion_cache: None,
564 vim_enabled: false,
565 vim_mode: VimMode::Normal,
566 vim_pending_d: false,
567 }
568 }
569 }
570
571 /// Viewport/scroll state — fields related to transcript scrolling and caching.
572 pub struct ViewportState {
573 pub transcript_scroll: TranscriptScroll,
574 pub pending_scroll_delta: i32,
575 pub mouse_scroll: MouseScrollState,
576 pub transcript_cache: TranscriptViewCache,
577 pub transcript_selection: TranscriptSelection,
578 pub last_transcript_area: Option<Rect>,
579 pub last_transcript_top: usize,
580 pub last_transcript_visible: usize,
581 pub last_transcript_total: usize,
582 pub last_transcript_padding_top: usize,
583 }
584
585 impl Default for ViewportState {
586 fn default() -> Self {
587 Self {
588 transcript_scroll: TranscriptScroll::to_bottom(),
589 pending_scroll_delta: 0,
590 mouse_scroll: MouseScrollState::new(),
591 transcript_cache: TranscriptViewCache::new(),
592 transcript_selection: TranscriptSelection::default(),
593 last_transcript_area: None,
594 last_transcript_top: 0,
595 last_transcript_visible: 0,
596 last_transcript_total: 0,
597 last_transcript_padding_top: 0,
598 }
599 }
600 }
601
602 /// Goal mode state (#397).
603 #[derive(Debug, Clone, Default)]
604 pub struct GoalState {
605 pub goal_objective: Option<String>,
606 pub goal_token_budget: Option<u32>,
607 pub goal_started_at: Option<Instant>,
608 }
609
610 /// Session cost and token telemetry state.
611 #[derive(Debug, Clone)]
612 pub struct SessionState {
613 pub session_cost: f64,
614 pub session_cost_cny: f64,
615 pub subagent_cost: f64,
616 pub subagent_cost_cny: f64,
617 pub subagent_cost_event_seqs: HashSet<u64>,
618 pub displayed_cost_high_water: f64,
619 pub displayed_cost_high_water_cny: f64,
620 pub last_prompt_tokens: Option<u32>,
621 pub last_completion_tokens: Option<u32>,
622 pub last_prompt_cache_hit_tokens: Option<u32>,
623 pub last_prompt_cache_miss_tokens: Option<u32>,
624 pub last_reasoning_replay_tokens: Option<u32>,
625 pub total_tokens: u32,
626 pub total_conversation_tokens: u32,
627 pub turn_cache_history: VecDeque<TurnCacheRecord>,
628 }
629
630 impl Default for SessionState {
631 fn default() -> Self {
632 Self {
633 session_cost: 0.0,
634 session_cost_cny: 0.0,
635 subagent_cost: 0.0,
636 subagent_cost_cny: 0.0,
637 subagent_cost_event_seqs: HashSet::new(),
638 displayed_cost_high_water: 0.0,
639 displayed_cost_high_water_cny: 0.0,
640 last_prompt_tokens: None,
641 last_completion_tokens: None,
642 last_prompt_cache_hit_tokens: None,
643 last_prompt_cache_miss_tokens: None,
644 last_reasoning_replay_tokens: None,
645 total_tokens: 0,
646 total_conversation_tokens: 0,
647 turn_cache_history: VecDeque::new(),
648 }
649 }
650 }
651
652 /// Global UI state for the TUI.
653 #[allow(clippy::struct_excessive_bools)]
654 pub struct App {
655 pub mode: AppMode,
656 /// Composer sub-state (input, cursor, history, menus).
657 pub composer: ComposerState,
658 /// Viewport sub-state (scroll, cache, selection).
659 pub viewport: ViewportState,
660 /// Goal sub-state.
661 pub goal: GoalState,
662 /// Session sub-state (cost, tokens, telemetry).
663 pub session: SessionState,
664 pub history: Vec<HistoryCell>,
665 pub history_version: u64,
666 /// Per-cell revision counter, kept in lockstep with `history`.
667 pub history_revisions: Vec<u64>,
668 /// Monotonic counter used to issue fresh per-cell revisions.
669 pub next_history_revision: u64,
670 pub api_messages: Vec<Message>,
671 pub is_loading: bool,
672 /// Degraded connectivity mode; new user inputs are queued for later retry.
673 pub offline_mode: bool,
674 /// Legacy status text sink retained for compatibility with existing call sites.
675 pub status_message: Option<String>,
676 /// Recent status toasts (ephemeral, newest at back).
677 pub status_toasts: VecDeque<StatusToast>,
678 /// Sticky status toast used for important warnings/errors.
679 pub sticky_status: Option<StatusToast>,
680 /// Last status text already promoted from `status_message` into toast state.
681 pub last_status_message_seen: Option<String>,
682 pub model: String,
683 /// When true, the model is auto-selected based on request complexity
684 /// rather than using a fixed model. The `/model auto` command sets this.
685 /// `dispatch_user_message` calls `auto_model_heuristic` to resolve the
686 /// effective model for each outbound message.
687 pub auto_model: bool,
688 /// Last concrete model chosen while `auto_model` is active.
689 pub last_effective_model: Option<String>,
690 /// Current API provider (mirrors `Config::api_provider`).
691 /// Updated by `/provider` switches so the UI/commands can read the
692 /// active backend without re-deriving it from the live config.
693 pub api_provider: ApiProvider,
694 /// Current reasoning-effort tier for DeepSeek thinking mode.
695 /// Cycled via Shift+Tab; initialized from config at startup.
696 pub reasoning_effort: ReasoningEffort,
697 /// Last concrete thinking tier chosen while `reasoning_effort` is auto.
698 pub last_effective_reasoning_effort: Option<ReasoningEffort>,
699 pub workspace: PathBuf,
700 pub config_path: Option<PathBuf>,
701 pub config_profile: Option<String>,
702 pub mcp_config_path: PathBuf,
703 pub skills_dir: PathBuf,
704 /// Path to the user-memory file (#489). Always populated; only
705 /// consulted when `use_memory` is `true`.
706 pub memory_path: PathBuf,
707 /// Whether the user-memory feature is enabled (#489). Mirrors
708 /// `Config::memory_enabled()` at app boot. Used by the `# foo`
709 /// composer interception, the `/memory` slash command, and tool
710 /// registration for `remember`.
711 pub use_memory: bool,
712 pub use_alt_screen: bool,
713 pub use_mouse_capture: bool,
714 pub use_bracketed_paste: bool,
715 pub use_paste_burst_detection: bool,
716 #[allow(dead_code)]
717 pub system_prompt: Option<SystemPrompt>,
718 pub auto_compact: bool,
719 pub calm_mode: bool,
720 pub low_motion: bool,
721 /// Pending #61 (animated working strip). Set from config but not read
722 /// until the footer widget consumes it.
723 #[allow(dead_code)]
724 pub fancy_animations: bool,
725 pub show_thinking: bool,
726 pub show_tool_details: bool,
727 pub ui_locale: Locale,
728 pub cost_currency: CostCurrency,
729 pub composer_density: ComposerDensity,
730 pub composer_border: bool,
731 pub transcript_spacing: TranscriptSpacing,
732 pub sidebar_width_percent: u16,
733 pub sidebar_focus: SidebarFocus,
734 /// Whether the session-context panel is enabled (#504).
735 pub context_panel: bool,
736 /// File-tree pane state. `None` when hidden; `Some` when visible.
737 pub file_tree: Option<crate::tui::file_tree::FileTreeState>,
738 #[allow(dead_code)]
739 pub compact_threshold: usize,
740 pub max_input_history: usize,
741 pub allow_shell: bool,
742 pub max_subagents: usize,
743 /// Cached sub-agent snapshots for UI views.
744 pub subagent_cache: Vec<SubAgentResult>,
745 /// Last known per-agent progress text for running sub-agents.
746 pub agent_progress: HashMap<String, String>,
747 /// In-transcript sub-agent card index by `agent_id` (issue #128).
748 /// Maps each live sub-agent to the `HistoryCell::SubAgent` it renders
749 /// into, so successive mailbox envelopes mutate the same cell rather
750 /// than spawning duplicates.
751 pub subagent_card_index: HashMap<String, usize>,
752 /// History index of the most recent FanoutCard. Sibling sub-agents
753 /// spawned by the same `rlm` invocation route into this card; reset
754 /// when a fresh fanout-family tool call starts.
755 pub last_fanout_card_index: Option<usize>,
756 /// Most recently observed sub-agent dispatch tool name (set on
757 /// `ToolCallStarted` for `agent_spawn` / `rlm` / etc., cleared
758 /// after the first `Started` mailbox envelope routes through it).
759 pub pending_subagent_dispatch: Option<String>,
760 /// Animation anchor for status-strip active sub-agent spinner.
761 pub agent_activity_started_at: Option<Instant>,
762 pub ui_theme: UiTheme,
763 // Onboarding
764 pub onboarding: OnboardingState,
765 pub onboarding_needs_api_key: bool,
766 pub onboarding_workspace_trust_gate: bool,
767 pub api_key_env_only: bool,
768 pub api_key_input: String,
769 pub api_key_cursor: usize,
770 // Hooks system
771 pub hooks: HookExecutor,
772 #[allow(dead_code)]
773 pub yolo: bool,
774 yolo_restore: Option<YoloRestoreState>,
775 // Clipboard handler
776 pub clipboard: ClipboardHandler,
777 // Tool approval session allowlist
778 pub approval_session_approved: HashSet<String>,
779 /// Approval keys (or tool names) the user has denied or aborted in
780 /// this session. Subsequent re-requests for the same approval key
781 /// auto-deny without re-prompting (#360) — the model can retry a
782 /// dangerous command after being told no, but the user shouldn't
783 /// have to keep dismissing the same dialog.
784 pub approval_session_denied: HashSet<String>,
785 pub approval_mode: ApprovalMode,
786 // Modal view stack (approval/help/etc.)
787 pub view_stack: ViewStack,
788 /// Esc-Esc backtrack state machine (#133). `Inactive` by default; first
789 /// Esc primes, second Esc opens the live-transcript overlay scoped to
790 /// previous user messages so the user can rewind a turn.
791 pub backtrack: crate::tui::backtrack::BacktrackState,
792 /// Current session ID for auto-save updates
793 pub current_session_id: Option<String>,
794 /// Trust mode - allow access outside workspace
795 pub trust_mode: bool,
796 /// Ordered list of footer items the user wants visible. Sourced from
797 /// `tui.status_items` in `~/.deepseek/config.toml` at startup; mutated
798 /// live by `/statusline`. The renderer iterates this slice; no item is
799 /// hardcoded in the footer code path.
800 pub status_items: Vec<crate::config::StatusItem>,
801 /// Project documentation (AGENTS.md or CLAUDE.md)
802 #[allow(dead_code)]
803 pub project_doc: Option<String>,
804 /// Plan state for tracking tasks
805 pub plan_state: SharedPlanState,
806 /// Whether a plan follow-up prompt is waiting for user input
807 pub plan_prompt_pending: bool,
808 /// Whether update_plan was called during the current turn
809 pub plan_tool_used_in_turn: bool,
810 /// Todo list for `TodoWriteTool`
811 #[allow(dead_code)] // For future engine integration
812 pub todos: SharedTodoList,
813 /// Durable runtime services exposed to model-visible task/automation tools.
814 pub runtime_services: RuntimeToolServices,
815 /// Last MCP manager/discovery snapshot shown in the UI.
816 pub mcp_snapshot: Option<crate::mcp::McpManagerSnapshot>,
817 /// Number of MCP servers declared in the user's config at app boot.
818 /// Used by the footer chip (#502) so a count is visible even before
819 /// the user runs `/mcp` for the first time. `0` hides the chip.
820 pub mcp_configured_count: usize,
821 /// Set after in-TUI MCP config edits because the engine caches its MCP pool.
822 pub mcp_restart_required: bool,
823 /// Tool execution log
824 pub tool_log: Vec<String>,
825 /// Active skill to apply to next user message
826 pub active_skill: Option<String>,
827 /// Cached (name, description) pairs from the skill registry.
828 /// Populated once at startup and refreshed on install/uninstall so
829 /// the slash menu can show skills without filesystem I/O on every keystroke.
830 pub cached_skills: Vec<(String, String)>,
831 /// Tool call cells by tool id (for cells already finalized in `history`).
832 /// While a tool call is in flight inside `active_cell`, it is tracked by
833 /// `active_tool_entries` instead and migrated here at flush time.
834 pub tool_cells: HashMap<String, usize>,
835 /// Full tool input/output keyed by history cell index.
836 pub tool_details_by_cell: HashMap<usize, ToolDetailRecord>,
837 /// Linked context references keyed by the visible user history cell that
838 /// introduced them.
839 pub context_references_by_cell: HashMap<usize, Vec<SessionContextReference>>,
840 /// Session-wide context references persisted with saved sessions.
841 pub session_context_references: Vec<SessionContextReference>,
842 /// In-flight tool/exec group for the current turn. Mutated in place as
843 /// parallel tool calls start and complete; flushed into `history` on
844 /// `TurnComplete`.
845 pub active_cell: Option<ActiveCell>,
846 /// Revision counter for `active_cell`. Combined with `active_cell.revision`
847 /// when feeding the transcript cache so cached lines for the synthetic
848 /// active-cell row are invalidated on every mutation.
849 pub active_cell_revision: u64,
850 /// Pending tool details for entries that live inside `active_cell`.
851 /// Keyed by tool id rather than cell index because the active cell's
852 /// virtual index can shift (orphan completions push real cells in
853 /// between). Migrated into `tool_details_by_cell` on flush.
854 pub active_tool_details: HashMap<String, ToolDetailRecord>,
855 /// Active exploring cell entry index (within `active_cell.entries`).
856 /// `None` once the active cell flushes or no exploring entry exists.
857 pub exploring_cell: Option<usize>,
858 /// Mapping of exploring tool ids to `(entry index in active_cell, entry
859 /// within ExploringCell)`. Used to update individual exploring entries
860 /// when their tools complete.
861 pub exploring_entries: HashMap<String, (usize, usize)>,
862 /// Tool calls that should be ignored by the UI
863 pub ignored_tool_calls: HashSet<String>,
864 /// Last exec wait command shown (for duplicate suppression)
865 pub last_exec_wait_command: Option<String>,
866 /// Current streaming assistant cell
867 pub streaming_message_index: Option<usize>,
868 /// Index into `active_cell.entries` of the thinking entry currently being
869 /// streamed. `None` when no thinking block is in flight. P2.3 routes
870 /// thinking into the active cell so it groups visually with tool calls
871 /// until the next assistant prose chunk flushes the group into history.
872 pub streaming_thinking_active_entry: Option<usize>,
873 /// Newline-gated streaming collector state.
874 pub streaming_state: StreamingState,
875 /// Accumulated reasoning text
876 pub reasoning_buffer: String,
877 /// Live reasoning header extracted from bold text
878 pub reasoning_header: Option<String>,
879 /// Last completed reasoning block
880 pub last_reasoning: Option<String>,
881 /// Tool calls captured for the pending assistant message
882 pub pending_tool_uses: Vec<(String, String, Value)>,
883 /// User messages queued while a turn is running
884 pub queued_messages: VecDeque<QueuedMessage>,
885 /// Draft queued message being edited
886 pub queued_draft: Option<QueuedMessage>,
887 /// Legacy pending-steer bucket retained for session compatibility. New
888 /// in-flight input uses Enter for same-turn steering and Tab for queued
889 /// follow-ups; Esc only cancels the active turn.
890 pub pending_steers: VecDeque<QueuedMessage>,
891 /// Engine-rejected steers (e.g. a tool was already running and couldn't be
892 /// cancelled cleanly). Surfaced in the pending-input preview so the user
893 /// knows the steer was deferred to end-of-turn. Today no engine path
894 /// produces these; the field is scaffolding for a future signalling
895 /// channel and the bucket renders identically when populated.
896 pub rejected_steers: VecDeque<String>,
897 /// Legacy resend flag for pending steer recovery.
898 pub submit_pending_steers_after_interrupt: bool,
899 /// Start time for current turn
900 pub turn_started_at: Option<Instant>,
901 /// Sum of completed turn durations for this `App` instance (#448
902 /// follow-up). Drives the footer's `worked Nh Mm` chip so the
903 /// label reflects actual model work, not wall-clock since launch.
904 /// Incremented on `TurnComplete` from the elapsed time of the
905 /// just-finished turn. Resets per launch.
906 pub cumulative_turn_duration: std::time::Duration,
907 /// Current runtime turn id (if known).
908 pub runtime_turn_id: Option<String>,
909 /// Current runtime turn status (if known).
910 pub runtime_turn_status: Option<String>,
911
912 /// Cached git context snapshot for the footer.
913 pub workspace_context: Option<String>,
914 /// Shared cell for async git context updates (#399 S1).
915 pub workspace_context_cell: std::sync::Arc<std::sync::Mutex<Option<String>>>,
916 /// Timestamp for cached workspace context.
917 pub workspace_context_refreshed_at: Option<Instant>,
918 /// Cached background tasks for sidebar rendering.
919 pub task_panel: Vec<TaskPanelEntry>,
920 /// Whether the UI needs to be redrawn.
921 pub needs_redraw: bool,
922 /// When the current thinking block started (for duration tracking).
923 pub thinking_started_at: Option<Instant>,
924 /// Whether context compaction is currently in progress.
925 pub is_compacting: bool,
926 /// Set when the user scrolls up/down during a streaming turn so subsequent
927 /// streamed chunks don't yank the view back to the live tail. Cleared
928 /// when the user explicitly returns to bottom or the turn completes.
929 pub user_scrolled_during_stream: bool,
930 /// Plain-language session coherence state for the footer.
931 pub coherence_state: CoherenceState,
932 /// Timestamp of the last user message send (for brief visual feedback).
933 pub last_send_at: Option<Instant>,
934 /// Two-tap quit confirmation. When set, a prior Ctrl+C in idle state has
935 /// armed the quit shortcut; a second Ctrl+C before this `Instant` exits
936 /// the app, while expiry silently re-arms the prompt for next time.
937 /// Stays `None` while a turn is in flight or a modal/picker is open so
938 /// Ctrl+C keeps its current "interrupt this turn" semantics in those
939 /// states. See [`App::arm_quit`] / [`App::quit_is_armed`].
940 pub quit_armed_until: Option<Instant>,
941
942 /// Number of checkpoint-restart cycles crossed in this session
943 /// (issue #124). Mirrors `Session.cycle_count` on the engine side.
944 pub cycle_count: u32,
945
946 /// Briefings produced at past cycle boundaries, in chronological order.
947 /// Used by `/cycles` and `/cycle <n>` slash commands.
948 pub cycle_briefings: Vec<CycleBriefing>,
949
950 /// Active cycle configuration (token threshold, briefing cap, per-model
951 /// overrides). Loaded from config and forwarded to the engine.
952 pub cycle: CycleConfig,
953
954 // === Goal Mode (#397) ===
955 /// Transcript cells the user has collapsed (hidden from view).
956 /// Stores **original** virtual cell indices (pre-filtering).
957 pub collapsed_cells: HashSet<usize>,
958 /// Mapping from filtered cell index → original virtual index.
959 /// Populated during `ChatWidget::new` by filtering out collapsed cells.
960 /// Used by `build_context_menu_entries` to convert line-meta indices
961 /// back to original indices for the `HideCell` / `ShowCell` actions.
962 pub collapsed_cell_map: Vec<usize>,
963
964 /// Whether `/edit` has loaded the last user message into the composer and
965 /// the next submit should replace (not append to) the last exchange.
966 pub edit_in_progress: bool,
967
968 /// Whether LSP diagnostics are currently enabled. Mirrors the config file
969 /// `[lsp].enabled` setting. Toggled at runtime via `/lsp on|off`.
970 pub lsp_enabled: bool,
971 }
972
973 /// Message queued while the engine is busy.
974 #[derive(Debug, Clone, PartialEq, Eq)]
975 pub struct QueuedMessage {
976 pub display: String,
977 pub skill_instruction: Option<String>,
978 }
979
980 /// How a freshly-typed user input should be sent.
981 ///
982 /// Picked by [`App::decide_submit_disposition`] when the user hits Enter on a
983 /// non-empty composer.
984 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
985 pub enum SubmitDisposition {
986 /// Engine idle and online: send immediately.
987 Immediate,
988 /// Park on `queued_messages` (offline, or engine busy — #382).
989 Queue,
990 /// Explicit steer via Ctrl+Enter (#382). Not returned by `decide_submit_disposition`.
991 #[allow(dead_code)]
992 Steer,
993 /// Park on `queued_messages` for dispatch after TurnComplete.
994 /// Legacy path; #382 unified busy states under `Queue`.
995 #[allow(dead_code)]
996 QueueFollowUp,
997 }
998
999 /// Detailed tool payload attached to a history cell.
1000 #[derive(Debug, Clone)]
1001 pub struct ToolDetailRecord {
1002 pub tool_id: String,
1003 pub tool_name: String,
1004 pub input: Value,
1005 pub output: Option<String>,
1006 }
1007
1008 /// Lightweight task view for sidebar rendering.
1009 #[derive(Debug, Clone)]
1010 pub struct TaskPanelEntry {
1011 pub id: String,
1012 pub status: String,
1013 pub prompt_summary: String,
1014 pub duration_ms: Option<u64>,
1015 }
1016
1017 impl QueuedMessage {
1018 pub fn new(display: String, skill_instruction: Option<String>) -> Self {
1019 Self {
1020 display,
1021 skill_instruction,
1022 }
1023 }
1024
1025 #[allow(dead_code)] // Tests and queue helpers use the display-only form; send path resolves @mentions.
1026 pub fn content(&self) -> String {
1027 if let Some(skill_instruction) = self.skill_instruction.as_ref() {
1028 format!(
1029 "{skill_instruction}\n\n---\n\nUser request: {}",
1030 self.display
1031 )
1032 } else {
1033 self.display.clone()
1034 }
1035 }
1036 }
1037
1038 // === Errors ===
1039
1040 /// Errors that can occur while submitting API keys during onboarding.
1041 #[derive(Debug, Error)]
1042 pub enum ApiKeyError {
1043 /// The provided API key was empty.
1044 #[error("Failed to save API key: API key cannot be empty")]
1045 Empty,
1046 /// Persisting the API key failed.
1047 #[error("Failed to save API key: {source}")]
1048 SaveFailed { source: anyhow::Error },
1049 }
1050
1051 // === Deref to ComposerState for backward compat ===
1052
1053 impl std::ops::Deref for App {
1054 type Target = ComposerState;
1055 fn deref(&self) -> &Self::Target {
1056 &self.composer
1057 }
1058 }
1059
1060 impl std::ops::DerefMut for App {
1061 fn deref_mut(&mut self) -> &mut Self::Target {
1062 &mut self.composer
1063 }
1064 }
1065
1066 // === App State ===
1067
1068 impl App {
1069 /// Cap on the session turn-cache history. Holds enough turns to debug a long
1070 /// session without being so large the on-screen `/cache` table wraps.
1071 pub const TURN_CACHE_HISTORY_CAP: usize = 50;
1072
1073 /// Append a per-turn cache-telemetry record, trimming the oldest entry once
1074 /// the ring exceeds [`Self::TURN_CACHE_HISTORY_CAP`].
1075 pub fn push_turn_cache_record(&mut self, record: TurnCacheRecord) {
1076 self.session.turn_cache_history.push_back(record);
1077 while self.session.turn_cache_history.len() > Self::TURN_CACHE_HISTORY_CAP {
1078 self.session.turn_cache_history.pop_front();
1079 }
1080 }
1081
1082 pub fn tr(&self, id: MessageId) -> &'static str {
1083 tr(self.ui_locale, id)
1084 }
1085
1086 #[allow(clippy::too_many_lines)]
1087 pub fn new(options: TuiOptions, config: &Config) -> Self {
1088 let TuiOptions {
1089 model,
1090 workspace,
1091 config_path,
1092 config_profile,
1093 allow_shell,
1094 use_alt_screen,
1095 use_mouse_capture,
1096 use_bracketed_paste,
1097 max_subagents,
1098 skills_dir: global_skills_dir,
1099 memory_path,
1100 notes_path: _,
1101 mcp_config_path,
1102 use_memory,
1103 start_in_agent_mode,
1104 skip_onboarding,
1105 yolo,
1106 resume_session_id: _,
1107 initial_input,
1108 } = options;
1109
1110 // If no provider is explicitly configured AND the system locale
1111 // indicates Chinese (zh-*), suggest DeepseekCN (api.deepseeki.com)
1112 // as the appropriate default.
1113 let provider = if config.provider.is_none() && is_chinese_system_locale() {
1114 let cn_base_url = crate::config::DEFAULT_DEEPSEEKCN_BASE_URL.to_string();
1115 // Store the suggested base URL in config so the first API call
1116 // uses the CN endpoint. We mutate a clone to avoid writing.
1117 let mut config = config.clone();
1118 config.base_url = Some(cn_base_url);
1119 config.api_provider()
1120 } else {
1121 config.api_provider()
1122 };
1123
1124 // Check if API key exists
1125 let needs_api_key = !has_api_key(config);
1126 let api_key_env_only = crate::config::active_provider_uses_env_only_api_key(config);
1127 let was_onboarded = crate::tui::onboarding::is_onboarded();
1128 let settings = Settings::load().unwrap_or_else(|_| Settings::default());
1129 let auto_compact = settings.auto_compact;
1130 let calm_mode = settings.calm_mode;
1131 let low_motion = settings.low_motion;
1132 let fancy_animations = settings.fancy_animations;
1133 let show_thinking = settings.show_thinking;
1134 let show_tool_details = settings.show_tool_details;
1135 let ui_locale = resolve_locale(&settings.locale);
1136 let cost_currency =
1137 CostCurrency::from_setting(&settings.cost_currency).unwrap_or(CostCurrency::Usd);
1138 let composer_density = ComposerDensity::from_setting(&settings.composer_density);
1139 let composer_border = settings.composer_border;
1140 let composer_vim_enabled = settings
1141 .composer_vim_mode
1142 .trim()
1143 .eq_ignore_ascii_case("vim");
1144 let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing);
1145 let sidebar_width_percent = settings.sidebar_width_percent;
1146 let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus);
1147 let max_input_history = settings.max_input_history;
1148 let use_paste_burst_detection = settings.paste_burst_detection;
1149 let ui_theme = palette::UI_THEME;
1150 let model = settings.default_model.clone().unwrap_or(model);
1151 let auto_model = model.trim().eq_ignore_ascii_case("auto");
1152 let threshold_model = if auto_model {
1153 DEFAULT_TEXT_MODEL
1154 } else {
1155 model.as_str()
1156 };
1157 let compact_threshold =
1158 compaction_threshold_for_model_and_effort(threshold_model, config.reasoning_effort());
1159 let reasoning_effort = if auto_model {
1160 ReasoningEffort::Auto
1161 } else {
1162 config
1163 .reasoning_effort()
1164 .map_or_else(ReasoningEffort::default, |s| {
1165 ReasoningEffort::from_setting(s)
1166 })
1167 };
1168
1169 // Start in YOLO mode if --yolo flag was passed
1170 let preferred_mode = AppMode::from_setting(&settings.default_mode);
1171 let initial_mode = if yolo {
1172 AppMode::Yolo
1173 } else if start_in_agent_mode {
1174 AppMode::Agent
1175 } else {
1176 preferred_mode
1177 };
1178 let needs_workspace_trust =
1179 initial_mode != AppMode::Yolo && crate::tui::onboarding::needs_trust(&workspace);
1180 let onboarding = initial_onboarding_state(
1181 skip_onboarding,
1182 was_onboarded,
1183 needs_api_key,
1184 needs_workspace_trust,
1185 );
1186 let onboarding_workspace_trust_gate = onboarding_is_workspace_trust_gate(
1187 skip_onboarding,
1188 was_onboarded,
1189 needs_api_key,
1190 needs_workspace_trust,
1191 );
1192
1193 let yolo_restore = if initial_mode == AppMode::Yolo {
1194 Some(YoloRestoreState {
1195 allow_shell: config.allow_shell(),
1196 trust_mode: false,
1197 approval_mode: config
1198 .approval_policy
1199 .as_deref()
1200 .and_then(ApprovalMode::from_config_value)
1201 .unwrap_or_default(),
1202 })
1203 } else {
1204 None
1205 };
1206 let allow_shell = allow_shell || initial_mode == AppMode::Yolo;
1207 let shell_manager = new_shared_shell_manager(workspace.clone());
1208
1209 // Initialize hooks executor from config
1210 let hooks_config = config.hooks_config();
1211 let hooks = HookExecutor::new(hooks_config, workspace.clone());
1212
1213 // Initialize plan state
1214 let plan_state = new_shared_plan_state();
1215
1216 let agents_skills_dir = workspace.join(".agents").join("skills");
1217 let local_skills_dir = workspace.join("skills");
1218 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
1219 let skills_dir = if agents_skills_dir.exists() {
1220 agents_skills_dir
1221 } else if local_skills_dir.exists() {
1222 local_skills_dir
1223 } else if config.skills_dir.is_none()
1224 && let Some(global_agents) = agents_global_skills_dir
1225 && global_agents.exists()
1226 {
1227 global_agents
1228 } else {
1229 global_skills_dir
1230 };
1231 let cached_skills = Self::discover_cached_skills(&skills_dir);
1232
1233 let input_history = crate::composer_history::load_history();
1234 let (initial_input_text, initial_input_cursor) = match initial_input {
1235 // #451: pre-populate the composer when invoked via
1236 // `deepseek pr <N>` (or any future caller that wants to
1237 // drop the model into a session with context already
1238 // typed). Cursor lands at the end so Enter sends as-is.
1239 Some(text) if !text.is_empty() => {
1240 let cursor = text.len();
1241 (text, cursor)
1242 }
1243 _ => (String::new(), 0),
1244 };
1245 Self {
1246 mode: initial_mode,
1247 composer: ComposerState {
1248 input: initial_input_text,
1249 cursor_position: initial_input_cursor,
1250 kill_buffer: String::new(),
1251 paste_burst: PasteBurst::default(),
1252 input_history,
1253 draft_history: VecDeque::new(),
1254 history_index: None,
1255 history_navigation_draft: None,
1256 composer_history_search: None,
1257 selected_attachment_index: None,
1258 slash_menu_selected: 0,
1259 slash_menu_hidden: false,
1260 mention_menu_selected: 0,
1261 mention_menu_hidden: false,
1262 mention_completion_cache: None,
1263 vim_enabled: composer_vim_enabled,
1264 vim_mode: VimMode::Normal,
1265 vim_pending_d: false,
1266 },
1267 viewport: ViewportState::default(),
1268 goal: GoalState::default(),
1269 session: SessionState::default(),
1270 history: Vec::new(),
1271 history_version: 0,
1272 history_revisions: Vec::new(),
1273 next_history_revision: 1,
1274 api_messages: Vec::new(),
1275 is_loading: false,
1276 offline_mode: false,
1277 status_message: None,
1278 status_toasts: VecDeque::new(),
1279 sticky_status: None,
1280 last_status_message_seen: None,
1281 model,
1282 auto_model,
1283 last_effective_model: None,
1284 api_provider: provider,
1285 reasoning_effort,
1286 last_effective_reasoning_effort: None,
1287 workspace,
1288 config_path,
1289 config_profile,
1290 mcp_config_path: mcp_config_path.clone(),
1291 skills_dir,
1292 memory_path,
1293 use_memory,
1294 use_alt_screen,
1295 use_mouse_capture,
1296 use_bracketed_paste,
1297 use_paste_burst_detection,
1298 system_prompt: None,
1299 auto_compact,
1300 calm_mode,
1301 low_motion,
1302 fancy_animations,
1303 show_thinking,
1304 show_tool_details,
1305 ui_locale,
1306 cost_currency,
1307 composer_density,
1308 composer_border,
1309 transcript_spacing,
1310 sidebar_width_percent,
1311 sidebar_focus,
1312 context_panel: settings.context_panel,
1313 file_tree: None,
1314 compact_threshold,
1315 max_input_history,
1316 allow_shell,
1317 max_subagents,
1318 subagent_cache: Vec::new(),
1319 agent_progress: HashMap::new(),
1320 subagent_card_index: HashMap::new(),
1321 last_fanout_card_index: None,
1322 pending_subagent_dispatch: None,
1323 agent_activity_started_at: None,
1324 ui_theme,
1325 onboarding,
1326 onboarding_needs_api_key: needs_api_key,
1327 onboarding_workspace_trust_gate,
1328 api_key_env_only,
1329 api_key_input: String::new(),
1330 api_key_cursor: 0,
1331 hooks,
1332 yolo: initial_mode == AppMode::Yolo,
1333 yolo_restore,
1334 clipboard: ClipboardHandler::new(),
1335 approval_session_approved: HashSet::new(),
1336 approval_session_denied: HashSet::new(),
1337 approval_mode: if matches!(initial_mode, AppMode::Yolo) {
1338 ApprovalMode::Auto
1339 } else {
1340 config
1341 .approval_policy
1342 .as_deref()
1343 .and_then(ApprovalMode::from_config_value)
1344 .unwrap_or_default()
1345 },
1346 view_stack: ViewStack::new(),
1347 backtrack: crate::tui::backtrack::BacktrackState::new(),
1348 current_session_id: None,
1349 trust_mode: initial_mode == AppMode::Yolo,
1350 status_items: config
1351 .tui
1352 .as_ref()
1353 .and_then(|tui| tui.status_items.clone())
1354 .unwrap_or_else(crate::config::StatusItem::default_footer),
1355 project_doc: None,
1356 plan_state,
1357 plan_prompt_pending: false,
1358 plan_tool_used_in_turn: false,
1359 todos: new_shared_todo_list(),
1360 runtime_services: RuntimeToolServices {
1361 shell_manager: Some(shell_manager),
1362 ..RuntimeToolServices::default()
1363 },
1364 mcp_snapshot: None,
1365 // Read the MCP config once at boot to know how many servers
1366 // the user has declared. The footer chip uses this even when
1367 // no live snapshot is available (#502). Cheap (just reads
1368 // the JSON file); errors fall through to zero so a missing
1369 // or malformed config simply hides the chip.
1370 mcp_configured_count: crate::mcp::load_config(&mcp_config_path)
1371 .map(|cfg| cfg.servers.len())
1372 .unwrap_or(0),
1373 mcp_restart_required: false,
1374 tool_log: Vec::new(),
1375 active_skill: None,
1376 cached_skills,
1377 tool_cells: HashMap::new(),
1378 tool_details_by_cell: HashMap::new(),
1379 context_references_by_cell: HashMap::new(),
1380 session_context_references: Vec::new(),
1381 active_cell: None,
1382 active_cell_revision: 0,
1383 active_tool_details: HashMap::new(),
1384 exploring_cell: None,
1385 exploring_entries: HashMap::new(),
1386 ignored_tool_calls: HashSet::new(),
1387 last_exec_wait_command: None,
1388 streaming_message_index: None,
1389 streaming_thinking_active_entry: None,
1390 streaming_state: StreamingState::new(),
1391 reasoning_buffer: String::new(),
1392 reasoning_header: None,
1393 last_reasoning: None,
1394 pending_tool_uses: Vec::new(),
1395 queued_messages: VecDeque::new(),
1396 queued_draft: None,
1397 pending_steers: VecDeque::new(),
1398 rejected_steers: VecDeque::new(),
1399 submit_pending_steers_after_interrupt: false,
1400 turn_started_at: None,
1401 cumulative_turn_duration: std::time::Duration::ZERO,
1402 runtime_turn_id: None,
1403 runtime_turn_status: None,
1404 workspace_context: None,
1405 workspace_context_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
1406 workspace_context_refreshed_at: None,
1407 task_panel: Vec::new(),
1408 needs_redraw: true,
1409 thinking_started_at: None,
1410 is_compacting: false,
1411 user_scrolled_during_stream: false,
1412 coherence_state: CoherenceState::default(),
1413 last_send_at: None,
1414 quit_armed_until: None,
1415 cycle_count: 0,
1416 cycle_briefings: Vec::new(),
1417 cycle: CycleConfig::default(),
1418 collapsed_cells: HashSet::new(),
1419 collapsed_cell_map: Vec::new(),
1420 edit_in_progress: false,
1421 lsp_enabled: config.lsp.as_ref().and_then(|l| l.enabled).unwrap_or(true),
1422 }
1423 }
1424
1425 fn discover_cached_skills(skills_dir: &std::path::Path) -> Vec<(String, String)> {
1426 crate::skills::SkillRegistry::discover(skills_dir)
1427 .list()
1428 .iter()
1429 .map(|s| (s.name.clone(), s.description.clone()))
1430 .collect()
1431 }
1432
1433 pub fn refresh_skill_cache(&mut self) {
1434 self.cached_skills = Self::discover_cached_skills(&self.skills_dir);
1435 }
1436
1437 pub fn submit_api_key(&mut self) -> Result<SavedCredential, ApiKeyError> {
1438 let key = self.api_key_input.trim().to_string();
1439 if key.is_empty() {
1440 return Err(ApiKeyError::Empty);
1441 }
1442
1443 match save_api_key(&key) {
1444 Ok(saved) => {
1445 self.api_key_input.clear();
1446 self.api_key_cursor = 0;
1447 self.onboarding_needs_api_key = false;
1448 self.api_key_env_only = false;
1449 Ok(saved)
1450 }
1451 Err(source) => Err(ApiKeyError::SaveFailed { source }),
1452 }
1453 }
1454
1455 pub fn finish_onboarding(&mut self) {
1456 self.onboarding = OnboardingState::None;
1457 if let Err(err) = crate::tui::onboarding::mark_onboarded() {
1458 self.status_message = Some(format!("Failed to mark onboarding: {err}"));
1459 }
1460 self.needs_redraw = true;
1461 }
1462
1463 /// Apply a locale tag selected from the onboarding language picker (#566).
1464 /// Persists the value to `~/.deepseek/settings.toml` and immediately
1465 /// re-resolves `ui_locale` so the rest of onboarding renders in the new
1466 /// language. `App` doesn't keep `Settings` resident — it loads on entry
1467 /// and rewrites on exit, mirroring the pattern used by the `/config`
1468 /// surface.
1469 pub fn set_locale_from_onboarding(&mut self, tag: &str) -> anyhow::Result<()> {
1470 let mut settings = Settings::load().unwrap_or_else(|_| Settings::default());
1471 settings.set("locale", tag)?;
1472 settings.save()?;
1473 self.ui_locale = crate::localization::resolve_locale(&settings.locale);
1474 self.needs_redraw = true;
1475 Ok(())
1476 }
1477
1478 /// Locale tag currently persisted in `~/.deepseek/settings.toml` (or
1479 /// `"auto"` when no settings file exists). Used by the onboarding
1480 /// language picker to highlight the current selection without `App`
1481 /// having to keep `Settings` resident.
1482 pub fn current_locale_tag(&self) -> String {
1483 Settings::load()
1484 .map(|s| s.locale)
1485 .unwrap_or_else(|_| "auto".to_string())
1486 }
1487
1488 pub fn set_mode(&mut self, mode: AppMode) -> bool {
1489 let previous_mode = self.mode;
1490 if previous_mode == mode {
1491 return false;
1492 }
1493
1494 let entering_yolo = mode == AppMode::Yolo && previous_mode != AppMode::Yolo;
1495 let leaving_yolo = previous_mode == AppMode::Yolo && mode != AppMode::Yolo;
1496 self.mode = mode;
1497 self.status_message = Some(format!("Switched to {} mode", mode.label()));
1498
1499 if entering_yolo {
1500 self.yolo_restore = Some(YoloRestoreState {
1501 allow_shell: self.allow_shell,
1502 trust_mode: self.trust_mode,
1503 approval_mode: self.approval_mode,
1504 });
1505 self.allow_shell = true;
1506 self.trust_mode = true;
1507 self.approval_mode = ApprovalMode::Auto;
1508 } else if leaving_yolo && let Some(restore) = self.yolo_restore.take() {
1509 self.allow_shell = restore.allow_shell;
1510 self.trust_mode = restore.trust_mode;
1511 self.approval_mode = restore.approval_mode;
1512 }
1513
1514 self.yolo = mode == AppMode::Yolo;
1515 if mode != AppMode::Plan {
1516 self.plan_prompt_pending = false;
1517 self.plan_tool_used_in_turn = false;
1518 }
1519
1520 // Execute mode change hooks
1521 let context = HookContext::new()
1522 .with_mode(mode.label())
1523 .with_previous_mode(previous_mode.label())
1524 .with_workspace(self.workspace.clone())
1525 .with_model(&self.model);
1526 let _ = self.hooks.execute(HookEvent::ModeChange, &context);
1527 self.needs_redraw = true;
1528 true
1529 }
1530
1531 /// Cycle through modes: Plan → Agent → YOLO → Plan.
1532 pub fn cycle_mode(&mut self) {
1533 let next = match self.mode {
1534 AppMode::Plan => AppMode::Agent,
1535 AppMode::Agent => AppMode::Yolo,
1536 AppMode::Yolo => AppMode::Plan,
1537 };
1538 let _ = self.set_mode(next);
1539 }
1540
1541 /// Cycle through modes in reverse.
1542 #[allow(dead_code)]
1543 pub fn cycle_mode_reverse(&mut self) {
1544 let next = match self.mode {
1545 AppMode::Agent => AppMode::Plan,
1546 AppMode::Yolo => AppMode::Agent,
1547 AppMode::Plan => AppMode::Yolo,
1548 };
1549 let _ = self.set_mode(next);
1550 }
1551
1552 /// Cycle reasoning-effort through the three behaviorally distinct tiers:
1553 /// `Off` → `High` → `Max` → `Off`.
1554 pub fn cycle_effort(&mut self) {
1555 self.reasoning_effort = self.reasoning_effort.cycle_next();
1556 self.last_effective_reasoning_effort = None;
1557 self.needs_redraw = true;
1558 self.push_status_toast(
1559 format!("Thinking: {}", self.reasoning_effort.short_label()),
1560 StatusToastLevel::Info,
1561 Some(1_500),
1562 );
1563 }
1564
1565 /// Execute hooks for a specific event with the given context
1566 pub fn execute_hooks(&self, event: HookEvent, context: &HookContext) -> Vec<HookResult> {
1567 self.hooks.execute(event, context)
1568 }
1569
1570 /// Create a hook context with common fields pre-populated
1571 pub fn base_hook_context(&self) -> HookContext {
1572 HookContext::new()
1573 .with_mode(self.mode.label())
1574 .with_workspace(self.workspace.clone())
1575 .with_model(&self.model)
1576 .with_session_id(self.hooks.session_id())
1577 .with_tokens(self.session.total_tokens)
1578 }
1579
1580 /// Soft cap on [`Self::history`] length. When history exceeds this count,
1581 /// the oldest cells are folded into a single placeholder to bound memory
1582 /// and render cost (#399 S2). The cap is generous — 5000 cells is more
1583 /// than enough to keep the visible transcript intact across sessions.
1584 pub const HISTORY_SOFT_CAP: usize = 5_000;
1585
1586 /// Number of oldest cells to fold when the soft cap fires. Folding in
1587 /// batches amortizes the cost instead of triggering on every push.
1588 const HISTORY_FOLD_BATCH: usize = 1_000;
1589
1590 pub fn add_message(&mut self, msg: HistoryCell) {
1591 let rev = self.fresh_history_revision();
1592 self.history.push(msg);
1593 self.history_revisions.push(rev);
1594 self.history_version = self.history_version.wrapping_add(1);
1595
1596 // Bound history length: when the soft cap fires, fold the oldest
1597 // batch into a single ArchivedContext placeholder.
1598 self.maybe_fold_history();
1599 let selection_has_range = self
1600 .viewport
1601 .transcript_selection
1602 .ordered_endpoints()
1603 .is_some_and(|(start, end)| start != end);
1604 if self.viewport.transcript_scroll.is_at_tail()
1605 && !self.viewport.transcript_selection.dragging
1606 && !selection_has_range
1607 && !self.user_scrolled_during_stream
1608 {
1609 self.scroll_to_bottom();
1610 }
1611 }
1612
1613 /// Add `delta` to the parent-turn session cost and bump the displayed
1614 /// high-water mark so the footer total never reverses (#244).
1615 #[allow(dead_code)]
1616 pub fn accrue_session_cost(&mut self, delta: f64) {
1617 self.accrue_session_cost_estimate(CostEstimate::usd_only(delta));
1618 }
1619
1620 /// Add a dual-currency parent-turn cost estimate.
1621 pub fn accrue_session_cost_estimate(&mut self, estimate: CostEstimate) {
1622 self.session.session_cost += estimate.usd;
1623 self.session.session_cost_cny += estimate.cny;
1624 self.refresh_displayed_cost_high_water();
1625 }
1626
1627 /// Add `delta` to the running sub-agent cost and bump the displayed
1628 /// high-water mark so the footer total never reverses (#244).
1629 #[allow(dead_code)]
1630 pub fn accrue_subagent_cost(&mut self, delta: f64) {
1631 self.accrue_subagent_cost_estimate(CostEstimate::usd_only(delta));
1632 }
1633
1634 /// Add a dual-currency sub-agent/background cost estimate.
1635 pub fn accrue_subagent_cost_estimate(&mut self, estimate: CostEstimate) {
1636 self.session.subagent_cost += estimate.usd;
1637 self.session.subagent_cost_cny += estimate.cny;
1638 self.refresh_displayed_cost_high_water();
1639 }
1640
1641 /// Recompute the displayed cost high-water mark. Called any time a cost
1642 /// counter is mutated; never decreases.
1643 pub fn refresh_displayed_cost_high_water(&mut self) {
1644 let current = self.session.session_cost + self.session.subagent_cost;
1645 if current > self.session.displayed_cost_high_water {
1646 self.session.displayed_cost_high_water = current;
1647 }
1648 let current_cny = self.session.session_cost_cny + self.session.subagent_cost_cny;
1649 if current_cny > self.session.displayed_cost_high_water_cny {
1650 self.session.displayed_cost_high_water_cny = current_cny;
1651 }
1652 }
1653
1654 /// Read the visible session+sub-agent cost. Guaranteed monotonic across
1655 /// reconciliation events (cache adjustments, provisional → final swaps)
1656 /// for the lifetime of one session (#244).
1657 #[allow(dead_code)]
1658 pub fn displayed_session_cost(&self) -> f64 {
1659 self.displayed_session_cost_for_currency(CostCurrency::Usd)
1660 }
1661
1662 /// Read the visible session+sub-agent cost in the chosen currency.
1663 pub fn displayed_session_cost_for_currency(&self, currency: CostCurrency) -> f64 {
1664 match currency {
1665 CostCurrency::Usd => {
1666 let current = self.session.session_cost + self.session.subagent_cost;
1667 current.max(self.session.displayed_cost_high_water)
1668 }
1669 CostCurrency::Cny => {
1670 let current = self.session.session_cost_cny + self.session.subagent_cost_cny;
1671 current.max(self.session.displayed_cost_high_water_cny)
1672 }
1673 }
1674 }
1675
1676 pub fn session_cost_for_currency(&self, currency: CostCurrency) -> f64 {
1677 match currency {
1678 CostCurrency::Usd => self.session.session_cost,
1679 CostCurrency::Cny => self.session.session_cost_cny,
1680 }
1681 }
1682
1683 pub fn subagent_cost_for_currency(&self, currency: CostCurrency) -> f64 {
1684 match currency {
1685 CostCurrency::Usd => self.session.subagent_cost,
1686 CostCurrency::Cny => self.session.subagent_cost_cny,
1687 }
1688 }
1689
1690 pub fn format_cost_amount(&self, amount: f64) -> String {
1691 crate::pricing::format_cost_amount(amount, self.cost_currency)
1692 }
1693
1694 pub fn format_cost_amount_precise(&self, amount: f64) -> String {
1695 crate::pricing::format_cost_amount_precise(amount, self.cost_currency)
1696 }
1697
1698 /// Fold the oldest [`Self::HISTORY_FOLD_BATCH`] cells into a single
1699 /// `ArchivedContext` placeholder when history exceeds the soft cap.
1700 /// Called from [`Self::add_message`]; the caller is responsible for
1701 /// also removing the folded range from any auxiliary per-cell maps.
1702 fn maybe_fold_history(&mut self) {
1703 if self.history.len() <= Self::HISTORY_SOFT_CAP {
1704 return;
1705 }
1706
1707 let fold_count = Self::HISTORY_FOLD_BATCH.min(self.history.len());
1708 // Don't fold into the very last cell(s) — keep a buffer of
1709 // non-folded cells so the visible transcript tail stays intact.
1710 let keep_tail = Self::HISTORY_SOFT_CAP.saturating_sub(Self::HISTORY_FOLD_BATCH);
1711 if self.history.len().saturating_sub(fold_count) < keep_tail {
1712 return;
1713 }
1714
1715 // Gather the range of cell indices we are folding.
1716 let folded: Vec<HistoryCell> = self.history.drain(..fold_count).collect();
1717 let folded_revs: Vec<u64> = self.history_revisions.drain(..fold_count).collect();
1718 let _ = folded_revs; // revisions are discarded with the cells
1719
1720 // Shift all per-cell index maps down by `fold_count`.
1721 self.shift_history_maps_down(fold_count);
1722
1723 // Build a single placeholder cell summarizing the folded range.
1724 let total_folded = folded.len();
1725 let summary = format!(
1726 "{total_folded} older transcript cells folded to bound memory. \
1727 Use /sessions to load a prior session snapshot if needed."
1728 );
1729 let placeholder = HistoryCell::ArchivedContext {
1730 level: 0,
1731 range: format!("cells 0-{}", total_folded.saturating_sub(1)),
1732 tokens: String::new(),
1733 density: String::new(),
1734 model: String::new(),
1735 timestamp: String::new(),
1736 summary,
1737 };
1738
1739 // Insert the placeholder at the front.
1740 let rev = self.fresh_history_revision();
1741 self.history.insert(0, placeholder);
1742 self.history_revisions.insert(0, rev);
1743 self.history_version = self.history_version.wrapping_add(1);
1744 self.needs_redraw = true;
1745 }
1746
1747 /// Shift all per-cell index maps down by `n` after removing the first
1748 /// `n` history cells. Every map key >= n is mapped to key - n; keys < n
1749 /// are dropped.
1750 fn shift_history_maps_down(&mut self, n: usize) {
1751 // tool_cells: HashMap<String, usize>
1752 self.tool_cells.retain(|_, idx| {
1753 if *idx >= n {
1754 *idx -= n;
1755 true
1756 } else {
1757 false
1758 }
1759 });
1760
1761 // tool_details_by_cell: HashMap<usize, ToolDetailRecord>
1762 self.tool_details_by_cell = std::mem::take(&mut self.tool_details_by_cell)
1763 .into_iter()
1764 .filter_map(|(idx, detail)| {
1765 if idx >= n {
1766 Some((idx - n, detail))
1767 } else {
1768 None
1769 }
1770 })
1771 .collect();
1772
1773 // context_references_by_cell
1774 self.context_references_by_cell = std::mem::take(&mut self.context_references_by_cell)
1775 .into_iter()
1776 .filter_map(|(idx, refs)| {
1777 if idx >= n {
1778 Some((idx - n, refs))
1779 } else {
1780 None
1781 }
1782 })
1783 .collect();
1784 self.rebuild_session_context_references();
1785
1786 // subagent_card_index
1787 self.subagent_card_index.retain(|_, idx| {
1788 if *idx >= n {
1789 *idx -= n;
1790 true
1791 } else {
1792 false
1793 }
1794 });
1795
1796 // last_fanout_card_index
1797 if let Some(ref mut idx) = self.last_fanout_card_index {
1798 if *idx >= n {
1799 *idx -= n;
1800 } else {
1801 self.last_fanout_card_index = None;
1802 }
1803 }
1804
1805 // collapsed_cells
1806 self.collapsed_cells = std::mem::take(&mut self.collapsed_cells)
1807 .into_iter()
1808 .filter_map(|idx| if idx >= n { Some(idx - n) } else { None })
1809 .collect();
1810 self.collapsed_cell_map.clear();
1811 }
1812
1813 pub fn mark_history_updated(&mut self) {
1814 self.history_version = self.history_version.wrapping_add(1);
1815 // Resync per-cell revisions to history.len(). This is the
1816 // "I-don't-know-which-cell-changed" path: if cells were appended in
1817 // bulk (e.g. session resume, compaction), every new cell gets a
1818 // fresh revision; if cells were removed, drop trailing revs. We
1819 // intentionally do NOT bump revisions for indices that already had
1820 // one — the cache will reuse those. Callers that mutate a specific
1821 // cell's content must call `bump_history_cell(idx)` instead.
1822 self.resync_history_revisions();
1823 self.needs_redraw = true;
1824 }
1825
1826 /// Issue a fresh, monotonically increasing revision counter for a new
1827 /// history cell. Wrapping is acceptable — collisions are astronomically
1828 /// rare and at worst trigger one extra re-render.
1829 fn fresh_history_revision(&mut self) -> u64 {
1830 let rev = self.next_history_revision;
1831 self.next_history_revision = self.next_history_revision.wrapping_add(1);
1832 rev
1833 }
1834
1835 /// Bring `history_revisions` back into shape (`history_revisions.len() ==
1836 /// history.len()`). Pushes fresh revs for newly appended cells, truncates
1837 /// for cells that were removed. **Does not** invalidate existing entries.
1838 pub fn resync_history_revisions(&mut self) {
1839 if self.history_revisions.len() < self.history.len() {
1840 let needed = self.history.len() - self.history_revisions.len();
1841 for _ in 0..needed {
1842 let rev = self.fresh_history_revision();
1843 self.history_revisions.push(rev);
1844 }
1845 } else if self.history_revisions.len() > self.history.len() {
1846 self.history_revisions.truncate(self.history.len());
1847 }
1848 }
1849
1850 /// Bump the revision counter of a single history cell so the transcript
1851 /// cache re-renders it on the next frame. Use this whenever a cell's
1852 /// content (e.g. a streaming Assistant body) is mutated in place.
1853 pub fn bump_history_cell(&mut self, idx: usize) {
1854 // Resync first in case callers mutated `history` directly without
1855 // pushing through `add_message`. After resync, the index is valid
1856 // (or out of bounds — in which case there's nothing to bump).
1857 self.resync_history_revisions();
1858 if let Some(rev) = self.history_revisions.get_mut(idx) {
1859 let new_rev = self.next_history_revision;
1860 self.next_history_revision = self.next_history_revision.wrapping_add(1);
1861 *rev = new_rev;
1862 }
1863 self.history_version = self.history_version.wrapping_add(1);
1864 self.needs_redraw = true;
1865 }
1866
1867 /// Append a single history cell, allocating a fresh per-cell revision.
1868 /// Equivalent to `add_message` but exposed as a generic alias so call
1869 /// sites currently doing `app.history.push(...)` followed by
1870 /// `app.mark_history_updated()` can collapse to one helper.
1871 pub fn push_history_cell(&mut self, cell: HistoryCell) {
1872 let rev = self.fresh_history_revision();
1873 self.history.push(cell);
1874 self.history_revisions.push(rev);
1875 self.history_version = self.history_version.wrapping_add(1);
1876 self.maybe_fold_history();
1877 self.needs_redraw = true;
1878 }
1879
1880 /// Append a batch of history cells, allocating fresh revisions.
1881 pub fn extend_history<I>(&mut self, cells: I)
1882 where
1883 I: IntoIterator<Item = HistoryCell>,
1884 {
1885 for cell in cells {
1886 let rev = self.fresh_history_revision();
1887 self.history.push(cell);
1888 self.history_revisions.push(rev);
1889 }
1890 self.maybe_fold_history();
1891 self.history_version = self.history_version.wrapping_add(1);
1892 self.needs_redraw = true;
1893 }
1894
1895 /// Clear the history and its revision tracking. Used by /clear, session
1896 /// reset, and other "wipe and reload" flows.
1897 pub fn clear_history(&mut self) {
1898 self.history.clear();
1899 self.history_revisions.clear();
1900 self.context_references_by_cell.clear();
1901 self.session_context_references.clear();
1902 self.collapsed_cells.clear();
1903 self.collapsed_cell_map.clear();
1904 self.history_version = self.history_version.wrapping_add(1);
1905 self.needs_redraw = true;
1906 }
1907
1908 /// Pop the trailing history cell, keeping revisions in sync.
1909 pub fn pop_history(&mut self) -> Option<HistoryCell> {
1910 let cell = self.history.pop();
1911 if cell.is_some() {
1912 self.history_revisions.pop();
1913 self.context_references_by_cell.remove(&self.history.len());
1914 self.rebuild_session_context_references();
1915 self.history_version = self.history_version.wrapping_add(1);
1916 self.needs_redraw = true;
1917 }
1918 cell
1919 }
1920
1921 /// Truncate `history` (and the parallel `history_revisions` + auxiliary
1922 /// per-cell maps) so that only cells with index `< new_len` remain.
1923 /// Used by Esc-Esc backtrack (#133) to roll the visible transcript
1924 /// back to a chosen user message. Cells dropped here are gone — the
1925 /// caller is expected to also trim the matching `api_messages` so the
1926 /// next turn matches what the user sees.
1927 pub fn truncate_history_to(&mut self, new_len: usize) {
1928 if new_len >= self.history.len() {
1929 return;
1930 }
1931 self.history.truncate(new_len);
1932 if self.history_revisions.len() > new_len {
1933 self.history_revisions.truncate(new_len);
1934 }
1935 // Drop any auxiliary maps keyed on history indices that now point
1936 // past the new tail. We keep the rest intact so unaffected tool
1937 // cells continue to render correctly.
1938 self.tool_cells.retain(|_, idx| *idx < new_len);
1939 self.tool_details_by_cell.retain(|idx, _| *idx < new_len);
1940 self.context_references_by_cell
1941 .retain(|idx, _| *idx < new_len);
1942 self.rebuild_session_context_references();
1943 self.subagent_card_index.retain(|_, idx| *idx < new_len);
1944 if self
1945 .last_fanout_card_index
1946 .is_some_and(|idx| idx >= new_len)
1947 {
1948 self.last_fanout_card_index = None;
1949 }
1950 // Drop collapsed cells that reference indices past the new tail.
1951 self.collapsed_cells.retain(|idx| *idx < new_len);
1952 self.collapsed_cell_map.clear();
1953 self.history_version = self.history_version.wrapping_add(1);
1954 self.needs_redraw = true;
1955 }
1956
1957 /// Bump the active-cell revision counter and request a redraw.
1958 ///
1959 /// Use this whenever an entry inside `active_cell` is mutated. The
1960 /// transcript cache combines this counter with `history_version` to
1961 /// produce a per-cell revision so the synthetic active-cell row can be
1962 /// re-rendered without invalidating committed history cells.
1963 pub fn bump_active_cell_revision(&mut self) {
1964 self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
1965 if let Some(active) = self.active_cell.as_mut() {
1966 active.bump_revision();
1967 }
1968 self.history_version = self.history_version.wrapping_add(1);
1969 self.needs_redraw = true;
1970 }
1971
1972 /// Total number of cells in the *virtual* transcript: `history.len()`
1973 /// plus active cell entries (if any).
1974 #[must_use]
1975 #[allow(dead_code)] // Reserved for renderers that need a unified cell count.
1976 pub fn virtual_cell_count(&self) -> usize {
1977 self.history.len() + self.active_cell.as_ref().map_or(0, ActiveCell::entry_count)
1978 }
1979
1980 /// The next cell index a freshly-pushed entry would occupy in the virtual
1981 /// transcript. Used by `register_tool_cell`-style callsites that record
1982 /// cell-index metadata before the active cell flushes to history.
1983 #[must_use]
1984 #[allow(dead_code)] // Reserved for the eventual merged push helper.
1985 pub fn next_virtual_cell_index(&self) -> usize {
1986 self.virtual_cell_count()
1987 }
1988
1989 /// Resolve a virtual cell index to either a committed history cell or an
1990 /// active-cell entry. Used by the pager / details lookup code so it can
1991 /// transparently address still-in-flight cells.
1992 #[must_use]
1993 #[allow(dead_code)] // Used by the upcoming pager rewrite (read-only resolver).
1994 pub fn cell_at_virtual_index(&self, index: usize) -> Option<&HistoryCell> {
1995 if index < self.history.len() {
1996 self.history.get(index)
1997 } else {
1998 let entry_idx = index - self.history.len();
1999 self.active_cell
2000 .as_ref()
2001 .and_then(|active| active.entries().get(entry_idx))
2002 }
2003 }
2004
2005 /// Resolve the tool-detail record for a committed or still-active virtual
2006 /// transcript cell.
2007 #[must_use]
2008 pub fn tool_detail_record_for_cell(&self, index: usize) -> Option<&ToolDetailRecord> {
2009 if let Some(detail) = self.tool_details_by_cell.get(&index) {
2010 return Some(detail);
2011 }
2012 self.active_tool_details
2013 .values()
2014 .find(|detail| self.tool_cells.get(&detail.tool_id).copied() == Some(index))
2015 }
2016
2017 /// Whether a virtual transcript cell can open a meaningful Alt+V detail
2018 /// view.
2019 #[must_use]
2020 pub fn cell_has_detail_target(&self, index: usize) -> bool {
2021 self.tool_detail_record_for_cell(index).is_some()
2022 || matches!(
2023 self.cell_at_virtual_index(index),
2024 Some(HistoryCell::Tool(_) | HistoryCell::SubAgent(_))
2025 )
2026 }
2027
2028 /// Pick the detail target for the current viewport. This is used by the
2029 /// transcript highlight and footer hint so they agree with Alt+V.
2030 #[must_use]
2031 pub fn detail_cell_index_for_viewport(
2032 &self,
2033 top: usize,
2034 visible: usize,
2035 line_meta: &[TranscriptLineMeta],
2036 ) -> Option<usize> {
2037 let selected_cell = self
2038 .viewport
2039 .transcript_selection
2040 .ordered_endpoints()
2041 .and_then(|(start, _)| line_meta.get(start.line_index))
2042 .and_then(TranscriptLineMeta::cell_line)
2043 .map(|(cell_index, _)| cell_index)
2044 .filter(|&idx| self.cell_has_detail_target(idx));
2045 if selected_cell.is_some() {
2046 return selected_cell;
2047 }
2048
2049 let start = top.min(line_meta.len().saturating_sub(1));
2050 let end = start.saturating_add(visible).min(line_meta.len());
2051 for meta in line_meta.iter().take(end).skip(start) {
2052 let Some((cell_index, _)) = meta.cell_line() else {
2053 continue;
2054 };
2055 if self.cell_has_detail_target(cell_index) {
2056 return Some(cell_index);
2057 }
2058 }
2059
2060 (0..self.virtual_cell_count())
2061 .rev()
2062 .find(|&idx| self.cell_has_detail_target(idx))
2063 }
2064
2065 pub fn record_context_references(
2066 &mut self,
2067 history_cell: usize,
2068 message_index: usize,
2069 references: Vec<ContextReference>,
2070 ) {
2071 if references.is_empty() {
2072 return;
2073 }
2074 let records: Vec<SessionContextReference> = references
2075 .into_iter()
2076 .map(|reference| SessionContextReference {
2077 message_index,
2078 reference,
2079 })
2080 .collect();
2081 self.context_references_by_cell
2082 .insert(history_cell, records.clone());
2083 self.rebuild_session_context_references();
2084 self.needs_redraw = true;
2085 }
2086
2087 pub fn sync_context_references_from_session(
2088 &mut self,
2089 references: &[SessionContextReference],
2090 message_to_cell: &HashMap<usize, usize>,
2091 ) {
2092 self.context_references_by_cell.clear();
2093 for record in references {
2094 let Some(&cell_index) = message_to_cell.get(&record.message_index) else {
2095 continue;
2096 };
2097 self.context_references_by_cell
2098 .entry(cell_index)
2099 .or_default()
2100 .push(record.clone());
2101 }
2102 self.rebuild_session_context_references();
2103 }
2104
2105 fn rebuild_session_context_references(&mut self) {
2106 let mut records: Vec<SessionContextReference> = self
2107 .context_references_by_cell
2108 .values()
2109 .flat_map(|records| records.iter().cloned())
2110 .collect();
2111 records.sort_by_key(|record| record.message_index);
2112 self.session_context_references = records;
2113 }
2114
2115 /// Mutable variant of [`Self::cell_at_virtual_index`]. Bumps the
2116 /// appropriate revision counter (active-cell revision when targeting an
2117 /// in-flight entry, history version otherwise).
2118 pub fn cell_at_virtual_index_mut(&mut self, index: usize) -> Option<&mut HistoryCell> {
2119 if index < self.history.len() {
2120 // Bump only the targeted cell's revision; leave every other
2121 // cell's cached render intact.
2122 self.resync_history_revisions();
2123 if let Some(rev) = self.history_revisions.get_mut(index) {
2124 let new_rev = self.next_history_revision;
2125 self.next_history_revision = self.next_history_revision.wrapping_add(1);
2126 *rev = new_rev;
2127 }
2128 self.history_version = self.history_version.wrapping_add(1);
2129 self.history.get_mut(index)
2130 } else {
2131 let entry_idx = index - self.history.len();
2132 self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
2133 self.history_version = self.history_version.wrapping_add(1);
2134 self.active_cell
2135 .as_mut()
2136 .and_then(|active| active.entry_mut(entry_idx))
2137 }
2138 }
2139
2140 /// Drain the active cell into history. Companion maps that reference
2141 /// active-cell entries by virtual index (`tool_cells`,
2142 /// `tool_details_by_cell`) are rewritten to point at the new history
2143 /// indices. Idempotent — calling this when there is no active cell is a
2144 /// no-op.
2145 ///
2146 /// Caller is responsible for first marking in-progress entries with the
2147 /// terminal status they want (e.g. via
2148 /// [`ActiveCell::mark_in_progress_as_interrupted`]).
2149 pub fn flush_active_cell(&mut self) {
2150 let Some(mut active) = self.active_cell.take() else {
2151 self.streaming_thinking_active_entry = None;
2152 return;
2153 };
2154 if active.is_empty() {
2155 self.exploring_cell = None;
2156 self.exploring_entries.clear();
2157 self.active_tool_details.clear();
2158 self.streaming_thinking_active_entry = None;
2159 self.bump_active_cell_revision();
2160 return;
2161 }
2162
2163 if let Some(entry_idx) = self.streaming_thinking_active_entry.take()
2164 && let Some(HistoryCell::Thinking { streaming, .. }) = active.entry_mut(entry_idx)
2165 {
2166 *streaming = false;
2167 }
2168
2169 let drained = active.drain();
2170 let base_index = self.history.len();
2171
2172 let mut details = std::mem::take(&mut self.active_tool_details);
2173 for (tool_id, detail) in details.drain() {
2174 self.tool_details_by_cell
2175 .entry(self.tool_cells.get(&tool_id).copied().unwrap_or(base_index))
2176 .or_insert(detail);
2177 }
2178
2179 self.exploring_cell = None;
2180 self.exploring_entries.clear();
2181
2182 for cell in drained {
2183 let rev = self.fresh_history_revision();
2184 self.history.push(cell);
2185 self.history_revisions.push(rev);
2186 }
2187 self.history_version = self.history_version.wrapping_add(1);
2188 self.needs_redraw = true;
2189 let selection_has_range = self
2190 .viewport
2191 .transcript_selection
2192 .ordered_endpoints()
2193 .is_some_and(|(start, end)| start != end);
2194 if self.viewport.transcript_scroll.is_at_tail()
2195 && !self.viewport.transcript_selection.dragging
2196 && !selection_has_range
2197 && !self.user_scrolled_during_stream
2198 {
2199 self.scroll_to_bottom();
2200 }
2201 }
2202
2203 /// Mark every still-running entry in the active cell as interrupted, then
2204 /// flush. Convenience helper for cancellation paths.
2205 pub fn finalize_active_cell_as_interrupted(&mut self) {
2206 if let Some(active) = self.active_cell.as_mut() {
2207 active.mark_in_progress_as_interrupted();
2208 }
2209 self.flush_active_cell();
2210 }
2211
2212 pub fn push_status_toast(
2213 &mut self,
2214 text: impl Into<String>,
2215 level: StatusToastLevel,
2216 ttl_ms: Option<u64>,
2217 ) {
2218 let toast = StatusToast::new(text, level, ttl_ms);
2219 self.status_toasts.push_back(toast);
2220 while self.status_toasts.len() > 24 {
2221 self.status_toasts.pop_front();
2222 }
2223 self.needs_redraw = true;
2224 }
2225
2226 /// How long the "press Ctrl+C again to quit" prompt stays armed before it
2227 /// silently expires.
2228 pub const QUIT_CONFIRMATION_WINDOW: Duration = Duration::from_secs(2);
2229
2230 /// Arm the quit confirmation timer. The next Ctrl+C within
2231 /// [`Self::QUIT_CONFIRMATION_WINDOW`] should exit the app cleanly. Call this only
2232 /// from idle state — while a turn is in flight or a modal is open Ctrl+C
2233 /// retains its existing "interrupt this turn" / "close modal" semantics.
2234 pub fn arm_quit(&mut self) {
2235 self.quit_armed_until = Some(Instant::now() + Self::QUIT_CONFIRMATION_WINDOW);
2236 self.needs_redraw = true;
2237 }
2238
2239 /// Whether the quit timer is currently armed (i.e. a prior Ctrl+C set it
2240 /// and it hasn't expired yet).
2241 pub fn quit_is_armed(&self) -> bool {
2242 self.quit_armed_until
2243 .map(|deadline| Instant::now() < deadline)
2244 .unwrap_or(false)
2245 }
2246
2247 /// Clear the quit-armed timer. Call when expiry is detected on a tick or
2248 /// when the user takes any other action that should disarm the prompt
2249 /// (typing, sending a message, etc.).
2250 pub fn disarm_quit(&mut self) {
2251 if self.quit_armed_until.is_some() {
2252 self.quit_armed_until = None;
2253 self.needs_redraw = true;
2254 }
2255 }
2256
2257 /// Tick called from the redraw loop. Lets time-based UI state (the
2258 /// quit-armed prompt) expire even when no input event is delivered.
2259 pub fn tick_quit_armed(&mut self) {
2260 if let Some(deadline) = self.quit_armed_until
2261 && Instant::now() >= deadline
2262 {
2263 self.quit_armed_until = None;
2264 self.needs_redraw = true;
2265 }
2266 }
2267
2268 pub fn set_sticky_status(
2269 &mut self,
2270 text: impl Into<String>,
2271 level: StatusToastLevel,
2272 ttl_ms: Option<u64>,
2273 ) {
2274 self.sticky_status = Some(StatusToast::new(text, level, ttl_ms));
2275 self.needs_redraw = true;
2276 }
2277
2278 pub fn clear_sticky_status(&mut self) {
2279 self.sticky_status = None;
2280 }
2281
2282 pub fn set_sidebar_focus(&mut self, focus: SidebarFocus) {
2283 self.sidebar_focus = focus;
2284 self.needs_redraw = true;
2285 }
2286
2287 pub fn close_slash_menu(&mut self) {
2288 self.slash_menu_hidden = true;
2289 self.needs_redraw = true;
2290 }
2291
2292 fn classify_status_text(text: &str) -> (StatusToastLevel, Option<u64>, bool) {
2293 let lower = text.to_ascii_lowercase();
2294 let has = |needle: &str| lower.contains(needle);
2295
2296 if has("offline mode") || has("context critical") {
2297 return (StatusToastLevel::Warning, None, true);
2298 }
2299 if has("error")
2300 || has("failed")
2301 || has("denied")
2302 || has("timeout")
2303 || has("aborted")
2304 || has("critical")
2305 {
2306 return (StatusToastLevel::Error, Some(15_000), true);
2307 }
2308 if has("saved")
2309 || has("loaded")
2310 || has("queued")
2311 || has("found")
2312 || has("enabled")
2313 || has("completed")
2314 {
2315 return (StatusToastLevel::Success, Some(5_000), false);
2316 }
2317 if has("cancelled") || has("warning") {
2318 return (StatusToastLevel::Warning, Some(5_000), false);
2319 }
2320 (StatusToastLevel::Info, Some(4_000), false)
2321 }
2322
2323 pub fn sync_status_message_to_toasts(&mut self) {
2324 let current = self.status_message.clone();
2325 if self.last_status_message_seen == current {
2326 return;
2327 }
2328 self.last_status_message_seen = current.clone();
2329
2330 let Some(message) = current else {
2331 return;
2332 };
2333 if message.trim().is_empty() {
2334 return;
2335 }
2336
2337 let (level, ttl_ms, sticky) = Self::classify_status_text(&message);
2338 if sticky {
2339 self.set_sticky_status(message, level, ttl_ms);
2340 } else {
2341 if matches!(level, StatusToastLevel::Success)
2342 && self
2343 .sticky_status
2344 .as_ref()
2345 .is_some_and(|toast| matches!(toast.level, StatusToastLevel::Error))
2346 {
2347 self.clear_sticky_status();
2348 }
2349 self.push_status_toast(message, level, ttl_ms);
2350 }
2351 }
2352
2353 /// Up to `limit` currently-active toasts, most recent last (so a stacked
2354 /// renderer iterating top-to-bottom shows the freshest message at the
2355 /// bottom, like a chat log). Drains expired toasts off the front as a
2356 /// side effect — same cleanup as `active_status_toast` so callers see a
2357 /// consistent queue. Whalescale#439.
2358 pub fn active_status_toasts(&mut self, limit: usize) -> Vec<StatusToast> {
2359 self.sync_status_message_to_toasts();
2360 let now = Instant::now();
2361 while self
2362 .status_toasts
2363 .front()
2364 .is_some_and(|toast| toast.is_expired(now))
2365 {
2366 self.status_toasts.pop_front();
2367 self.needs_redraw = true;
2368 }
2369 if self
2370 .sticky_status
2371 .as_ref()
2372 .is_some_and(|toast| toast.is_expired(now))
2373 {
2374 self.sticky_status = None;
2375 self.needs_redraw = true;
2376 }
2377
2378 let mut out: Vec<StatusToast> = Vec::with_capacity(limit);
2379 if let Some(sticky) = self.sticky_status.clone() {
2380 out.push(sticky);
2381 }
2382 let take = limit.saturating_sub(out.len());
2383 let queued: Vec<StatusToast> = self
2384 .status_toasts
2385 .iter()
2386 .rev()
2387 .take(take)
2388 .cloned()
2389 .collect();
2390 // Iterate in queue order (oldest of the visible window first) so the
2391 // stacked renderer feels chronological — most recent at the bottom.
2392 for toast in queued.into_iter().rev() {
2393 out.push(toast);
2394 }
2395 out
2396 }
2397
2398 pub fn active_status_toast(&mut self) -> Option<StatusToast> {
2399 self.sync_status_message_to_toasts();
2400 let now = Instant::now();
2401 let mut removed = false;
2402
2403 while self
2404 .status_toasts
2405 .front()
2406 .is_some_and(|toast| toast.is_expired(now))
2407 {
2408 self.status_toasts.pop_front();
2409 removed = true;
2410 }
2411
2412 if self
2413 .sticky_status
2414 .as_ref()
2415 .is_some_and(|toast| toast.is_expired(now))
2416 {
2417 self.sticky_status = None;
2418 removed = true;
2419 }
2420
2421 if removed {
2422 self.needs_redraw = true;
2423 }
2424
2425 self.sticky_status
2426 .clone()
2427 .or_else(|| self.status_toasts.back().cloned())
2428 }
2429
2430 pub fn transcript_render_options(&self) -> TranscriptRenderOptions {
2431 TranscriptRenderOptions {
2432 show_thinking: self.show_thinking,
2433 show_tool_details: self.show_tool_details,
2434 calm_mode: self.calm_mode,
2435 low_motion: self.low_motion,
2436 spacing: self.transcript_spacing,
2437 }
2438 }
2439
2440 /// Handle terminal resize event.
2441 pub fn handle_resize(&mut self, _width: u16, _height: u16) {
2442 self.viewport.transcript_cache = TranscriptViewCache::new();
2443
2444 if !self.viewport.transcript_scroll.is_at_tail() {
2445 self.viewport.transcript_scroll = TranscriptScroll::to_bottom();
2446 }
2447
2448 self.viewport.pending_scroll_delta = 0;
2449 self.viewport.transcript_selection.clear();
2450
2451 self.viewport.last_transcript_area = None;
2452 self.viewport.last_transcript_top = 0;
2453 self.viewport.last_transcript_visible = 0;
2454 self.viewport.last_transcript_total = 0;
2455 self.viewport.last_transcript_padding_top = 0;
2456
2457 self.mark_history_updated();
2458 }
2459
2460 pub fn cursor_byte_index(&self) -> usize {
2461 byte_index_at_char(&self.input, self.cursor_position)
2462 }
2463
2464 pub fn insert_str(&mut self, text: &str) {
2465 if text.is_empty() {
2466 return;
2467 }
2468 self.selected_attachment_index = None;
2469 let cursor = self.cursor_position.min(char_count(&self.input));
2470 let byte_index = byte_index_at_char(&self.input, cursor);
2471 self.input.insert_str(byte_index, text);
2472 self.cursor_position = cursor + char_count(text);
2473 self.slash_menu_hidden = false;
2474 self.mention_menu_hidden = false;
2475 self.mention_menu_selected = 0;
2476 self.needs_redraw = true;
2477 }
2478
2479 pub fn insert_paste_text(&mut self, text: &str) {
2480 if let Some(pending) = self.paste_burst.flush_before_modified_input() {
2481 self.insert_str(&pending);
2482 }
2483 let normalized = normalize_paste_text(text);
2484 if !normalized.is_empty() {
2485 self.insert_str(&normalized);
2486 }
2487 self.paste_burst.clear_after_explicit_paste();
2488 }
2489
2490 pub fn insert_media_attachment(&mut self, kind: &str, path: &Path, description: Option<&str>) {
2491 let reference = media_attachment_reference(kind, path, description);
2492 let cursor = self.cursor_position.min(char_count(&self.input));
2493 let byte_index = byte_index_at_char(&self.input, cursor);
2494 let needs_prefix_newline = self.input[..byte_index]
2495 .chars()
2496 .last()
2497 .is_some_and(|ch| !ch.is_whitespace());
2498 let needs_suffix_newline = self.input[byte_index..]
2499 .chars()
2500 .next()
2501 .is_some_and(|ch| !ch.is_whitespace());
2502
2503 let mut inserted = String::new();
2504 if needs_prefix_newline {
2505 inserted.push('\n');
2506 }
2507 inserted.push_str(&reference);
2508 if needs_suffix_newline || self.input[byte_index..].is_empty() {
2509 inserted.push('\n');
2510 }
2511 self.insert_str(&inserted);
2512 self.paste_burst.clear_after_explicit_paste();
2513 }
2514
2515 pub fn composer_attachment_count(&self) -> usize {
2516 crate::tui::file_mention::media_attachment_references(&self.input).len()
2517 }
2518
2519 pub fn selected_composer_attachment_index(&self) -> Option<usize> {
2520 let count = self.composer_attachment_count();
2521 self.selected_attachment_index
2522 .filter(|index| *index < count)
2523 }
2524
2525 pub fn select_previous_composer_attachment(&mut self) -> bool {
2526 let count = self.composer_attachment_count();
2527 if count == 0 {
2528 self.selected_attachment_index = None;
2529 return false;
2530 }
2531
2532 let next = self
2533 .selected_composer_attachment_index()
2534 .map_or(count.saturating_sub(1), |index| index.saturating_sub(1));
2535 self.selected_attachment_index = Some(next);
2536 self.cursor_position = 0;
2537 self.status_message = Some("Attachment selected - Backspace/Delete removes it".to_string());
2538 self.needs_redraw = true;
2539 true
2540 }
2541
2542 pub fn select_next_composer_attachment(&mut self) -> bool {
2543 let count = self.composer_attachment_count();
2544 let Some(index) = self.selected_composer_attachment_index() else {
2545 return false;
2546 };
2547 if index + 1 < count {
2548 self.selected_attachment_index = Some(index + 1);
2549 self.status_message =
2550 Some("Attachment selected - Backspace/Delete removes it".to_string());
2551 } else {
2552 self.selected_attachment_index = None;
2553 self.status_message = Some("Composer focused".to_string());
2554 }
2555 self.needs_redraw = true;
2556 true
2557 }
2558
2559 pub fn clear_composer_attachment_selection(&mut self) -> bool {
2560 if self.selected_attachment_index.take().is_some() {
2561 self.status_message = Some("Composer focused".to_string());
2562 self.needs_redraw = true;
2563 true
2564 } else {
2565 false
2566 }
2567 }
2568
2569 pub fn remove_selected_composer_attachment(&mut self) -> bool {
2570 let references = crate::tui::file_mention::media_attachment_references(&self.input);
2571 let Some(index) = self
2572 .selected_composer_attachment_index()
2573 .filter(|index| *index < references.len())
2574 else {
2575 self.selected_attachment_index = None;
2576 return false;
2577 };
2578 let reference = references[index].clone();
2579 let cursor_byte = byte_index_at_char(&self.input, self.cursor_position);
2580 let new_cursor_byte = if cursor_byte <= reference.start_byte {
2581 cursor_byte
2582 } else if cursor_byte >= reference.end_byte {
2583 cursor_byte.saturating_sub(reference.end_byte - reference.start_byte)
2584 } else {
2585 reference.start_byte
2586 };
2587
2588 self.input
2589 .replace_range(reference.start_byte..reference.end_byte, "");
2590 self.cursor_position = self.input[..new_cursor_byte.min(self.input.len())]
2591 .chars()
2592 .count();
2593 let remaining = self.composer_attachment_count();
2594 self.selected_attachment_index = if remaining == 0 {
2595 None
2596 } else {
2597 Some(index.min(remaining.saturating_sub(1)))
2598 };
2599 self.slash_menu_hidden = false;
2600 self.mention_menu_hidden = false;
2601 self.mention_menu_selected = 0;
2602 self.status_message = Some(format!("Removed attachment: {}", reference.path));
2603 self.needs_redraw = true;
2604 true
2605 }
2606
2607 pub fn flush_paste_burst_if_due(&mut self, now: Instant) -> bool {
2608 match self.paste_burst.flush_if_due(now) {
2609 FlushResult::Paste(text) => {
2610 self.insert_str(&text);
2611 true
2612 }
2613 FlushResult::Typed(ch) => {
2614 self.insert_char(ch);
2615 true
2616 }
2617 FlushResult::None => false,
2618 }
2619 }
2620
2621 pub fn flush_paste_burst_if_enabled(&mut self, now: Instant) -> bool {
2622 self.use_paste_burst_detection && self.flush_paste_burst_if_due(now)
2623 }
2624
2625 pub fn paste_burst_next_flush_delay_if_enabled(&self, now: Instant) -> Option<Duration> {
2626 if self.use_paste_burst_detection {
2627 self.paste_burst.next_flush_delay(now)
2628 } else {
2629 None
2630 }
2631 }
2632
2633 pub fn flush_paste_burst_before_modified_input_if_enabled(&mut self) -> Option<String> {
2634 if self.use_paste_burst_detection {
2635 self.paste_burst.flush_before_modified_input()
2636 } else {
2637 None
2638 }
2639 }
2640
2641 pub fn insert_api_key_char(&mut self, c: char) {
2642 let cursor = self.api_key_cursor.min(char_count(&self.api_key_input));
2643 let byte_index = byte_index_at_char(&self.api_key_input, cursor);
2644 self.api_key_input.insert(byte_index, c);
2645 self.api_key_cursor = cursor + 1;
2646 }
2647
2648 pub fn insert_api_key_str(&mut self, text: &str) {
2649 let sanitized = sanitize_api_key_text(text);
2650 if sanitized.is_empty() {
2651 return;
2652 }
2653 let cursor = self.api_key_cursor.min(char_count(&self.api_key_input));
2654 let byte_index = byte_index_at_char(&self.api_key_input, cursor);
2655 self.api_key_input.insert_str(byte_index, &sanitized);
2656 self.api_key_cursor = cursor + char_count(&sanitized);
2657 }
2658
2659 pub fn delete_api_key_char(&mut self) {
2660 if self.api_key_cursor == 0 {
2661 return;
2662 }
2663 let target = self.api_key_cursor.saturating_sub(1);
2664 if remove_char_at(&mut self.api_key_input, target) {
2665 self.api_key_cursor = target;
2666 }
2667 }
2668
2669 /// Paste from clipboard into input
2670 pub fn paste_from_clipboard(&mut self) {
2671 if let Some(content) = self.clipboard.read(self.workspace.as_path()) {
2672 self.apply_clipboard_content(content);
2673 }
2674 }
2675
2676 pub fn apply_clipboard_content(&mut self, content: ClipboardContent) {
2677 match content {
2678 ClipboardContent::Text(text) => {
2679 self.insert_paste_text(&text);
2680 }
2681 ClipboardContent::Image(pasted) => {
2682 let description = format!("{} ({})", pasted.short_label(), pasted.size_label());
2683 self.insert_media_attachment("image", &pasted.path, Some(&description));
2684 self.status_message = Some(format!("Attached image: {description}"));
2685 }
2686 }
2687 }
2688
2689 pub fn paste_api_key_from_clipboard(&mut self) {
2690 if let Some(ClipboardContent::Text(text)) = self.clipboard.read(self.workspace.as_path()) {
2691 self.insert_api_key_str(&text);
2692 }
2693 }
2694
2695 pub fn scroll_up(&mut self, amount: usize) {
2696 let delta = i32::try_from(amount).unwrap_or(i32::MAX);
2697 self.viewport.pending_scroll_delta =
2698 self.viewport.pending_scroll_delta.saturating_sub(delta);
2699 self.user_scrolled_during_stream = true;
2700 self.needs_redraw = true;
2701 }
2702
2703 pub fn scroll_down(&mut self, amount: usize) {
2704 let delta = i32::try_from(amount).unwrap_or(i32::MAX);
2705 self.viewport.pending_scroll_delta =
2706 self.viewport.pending_scroll_delta.saturating_add(delta);
2707 self.user_scrolled_during_stream = true;
2708 self.needs_redraw = true;
2709 }
2710
2711 pub fn scroll_to_bottom(&mut self) {
2712 self.viewport.transcript_scroll = TranscriptScroll::to_bottom();
2713 self.viewport.pending_scroll_delta = 0;
2714 self.user_scrolled_during_stream = false;
2715 self.needs_redraw = true;
2716 }
2717
2718 pub fn insert_char(&mut self, c: char) {
2719 self.clear_input_history_navigation();
2720 self.selected_attachment_index = None;
2721 let cursor = self.cursor_position.min(char_count(&self.input));
2722 let byte_index = byte_index_at_char(&self.input, cursor);
2723 self.input.insert(byte_index, c);
2724 self.cursor_position = cursor + 1;
2725 self.slash_menu_hidden = false;
2726 self.mention_menu_hidden = false;
2727 self.mention_menu_selected = 0;
2728 self.needs_redraw = true;
2729 }
2730
2731 pub fn delete_char(&mut self) {
2732 self.clear_input_history_navigation();
2733 self.selected_attachment_index = None;
2734 if self.cursor_position == 0 {
2735 return;
2736 }
2737 let target = self.cursor_position.saturating_sub(1);
2738 let removed = remove_char_at(&mut self.input, target);
2739 if removed {
2740 self.cursor_position = target;
2741 self.slash_menu_hidden = false;
2742 self.mention_menu_hidden = false;
2743 self.mention_menu_selected = 0;
2744 self.needs_redraw = true;
2745 }
2746 }
2747
2748 pub fn delete_char_forward(&mut self) {
2749 self.clear_input_history_navigation();
2750 self.selected_attachment_index = None;
2751 if self.input.is_empty() {
2752 return;
2753 }
2754 let target = self.cursor_position;
2755 let removed = remove_char_at(&mut self.input, target);
2756 if !removed {
2757 self.cursor_position = char_count(&self.input);
2758 }
2759 self.slash_menu_hidden = false;
2760 self.mention_menu_hidden = false;
2761 self.mention_menu_selected = 0;
2762 self.needs_redraw = true;
2763 }
2764
2765 /// Delete the word before the cursor.
2766 pub fn delete_word_backward(&mut self) {
2767 self.clear_input_history_navigation();
2768 self.selected_attachment_index = None;
2769 if self.cursor_position == 0 {
2770 return;
2771 }
2772
2773 let cursor_byte = byte_index_at_char(&self.input, self.cursor_position);
2774 let mut word_start = cursor_byte;
2775
2776 while word_start > 0 {
2777 let Some((prev, ch)) = self.input[..word_start].char_indices().next_back() else {
2778 break;
2779 };
2780 if !ch.is_whitespace() {
2781 break;
2782 }
2783 word_start = prev;
2784 }
2785
2786 while word_start > 0 {
2787 let Some((prev, ch)) = self.input[..word_start].char_indices().next_back() else {
2788 break;
2789 };
2790 if ch.is_whitespace() {
2791 break;
2792 }
2793 word_start = prev;
2794 }
2795
2796 if word_start < cursor_byte {
2797 self.input.replace_range(word_start..cursor_byte, "");
2798 self.cursor_position = char_count(&self.input[..word_start]);
2799 self.slash_menu_hidden = false;
2800 self.mention_menu_hidden = false;
2801 self.mention_menu_selected = 0;
2802 self.needs_redraw = true;
2803 }
2804 }
2805
2806 /// Delete from the cursor to the start of the line.
2807 pub fn delete_to_start_of_line(&mut self) {
2808 self.clear_input_history_navigation();
2809 self.selected_attachment_index = None;
2810 if self.cursor_position == 0 {
2811 return;
2812 }
2813
2814 let cursor_byte = byte_index_at_char(&self.input, self.cursor_position);
2815 // Find the start of the current line (last newline or start of string)
2816 let line_start = self.input[..cursor_byte]
2817 .rfind('\n')
2818 .map(|idx| idx + 1)
2819 .unwrap_or(0);
2820
2821 if line_start < cursor_byte {
2822 self.input.replace_range(line_start..cursor_byte, "");
2823 self.cursor_position = char_count(&self.input[..line_start]);
2824 self.slash_menu_hidden = false;
2825 self.mention_menu_hidden = false;
2826 self.mention_menu_selected = 0;
2827 self.needs_redraw = true;
2828 }
2829 }
2830
2831 /// Delete the word after the cursor.
2832 pub fn delete_word_forward(&mut self) {
2833 self.clear_input_history_navigation();
2834 self.selected_attachment_index = None;
2835 let cursor_byte = byte_index_at_char(&self.input, self.cursor_position);
2836 if cursor_byte >= self.input.len() {
2837 return;
2838 }
2839
2840 let mut word_end = cursor_byte;
2841 while word_end < self.input.len() {
2842 let Some(ch) = self.input[word_end..].chars().next() else {
2843 break;
2844 };
2845 if !ch.is_whitespace() {
2846 break;
2847 }
2848 word_end += ch.len_utf8();
2849 }
2850
2851 while word_end < self.input.len() {
2852 let Some(ch) = self.input[word_end..].chars().next() else {
2853 break;
2854 };
2855 if ch.is_whitespace() {
2856 break;
2857 }
2858 word_end += ch.len_utf8();
2859 }
2860
2861 if cursor_byte < word_end {
2862 self.input.replace_range(cursor_byte..word_end, "");
2863 self.slash_menu_hidden = false;
2864 self.mention_menu_hidden = false;
2865 self.mention_menu_selected = 0;
2866 self.needs_redraw = true;
2867 }
2868 }
2869
2870 /// Cut from the cursor to the end of the current logical line into the
2871 /// kill buffer. If the cursor is already at end-of-line and a trailing
2872 /// newline exists, that newline is consumed so repeated invocations
2873 /// continue to make progress (matching emacs/codex semantics).
2874 ///
2875 /// Returns `true` when bytes were moved into the kill buffer.
2876 pub fn kill_to_end_of_line(&mut self) -> bool {
2877 self.clear_input_history_navigation();
2878 let total_chars = char_count(&self.input);
2879 let cursor = self.cursor_position.min(total_chars);
2880 let start_byte = byte_index_at_char(&self.input, cursor);
2881
2882 // Find the byte offset of the next '\n' (relative to the whole string)
2883 // or the end of the buffer if no newline exists at/after the cursor.
2884 let eol_byte = self.input[start_byte..]
2885 .find('\n')
2886 .map(|rel| start_byte + rel)
2887 .unwrap_or_else(|| self.input.len());
2888
2889 let end_byte = if start_byte == eol_byte {
2890 // Cursor is at EOL — consume the newline itself if one is there.
2891 if eol_byte < self.input.len() {
2892 eol_byte + 1
2893 } else {
2894 return false;
2895 }
2896 } else {
2897 eol_byte
2898 };
2899
2900 let removed: String = self.input[start_byte..end_byte].to_string();
2901 if removed.is_empty() {
2902 return false;
2903 }
2904
2905 self.kill_buffer = removed;
2906 self.input.replace_range(start_byte..end_byte, "");
2907 // Cursor stays at the same character index (start of removed range).
2908 self.cursor_position = cursor;
2909 self.slash_menu_hidden = false;
2910 self.mention_menu_hidden = false;
2911 self.mention_menu_selected = 0;
2912 self.needs_redraw = true;
2913 true
2914 }
2915
2916 /// Insert the contents of the kill buffer at the cursor, advancing it.
2917 /// The kill buffer is left intact so multiple yanks duplicate the text.
2918 /// Returns `true` if any text was inserted.
2919 pub fn yank(&mut self) -> bool {
2920 if self.kill_buffer.is_empty() {
2921 return false;
2922 }
2923 self.clear_input_history_navigation();
2924 let text = self.kill_buffer.clone();
2925 let cursor = self.cursor_position.min(char_count(&self.input));
2926 let byte_index = byte_index_at_char(&self.input, cursor);
2927 self.input.insert_str(byte_index, &text);
2928 self.cursor_position = cursor + char_count(&text);
2929 self.slash_menu_hidden = false;
2930 self.mention_menu_hidden = false;
2931 self.mention_menu_selected = 0;
2932 self.needs_redraw = true;
2933 true
2934 }
2935
2936 pub fn move_cursor_left(&mut self) {
2937 self.cursor_position = self.cursor_position.saturating_sub(1);
2938 self.needs_redraw = true;
2939 }
2940
2941 pub fn move_cursor_right(&mut self) {
2942 if self.cursor_position < char_count(&self.input) {
2943 self.cursor_position += 1;
2944 self.needs_redraw = true;
2945 }
2946 }
2947
2948 pub fn move_cursor_start(&mut self) {
2949 self.cursor_position = 0;
2950 self.needs_redraw = true;
2951 }
2952
2953 pub fn move_cursor_end(&mut self) {
2954 self.cursor_position = char_count(&self.input);
2955 self.needs_redraw = true;
2956 }
2957
2958 // === Vim composer mode helpers ===
2959
2960 /// Move the cursor to the start of the current logical line (vim `0`).
2961 pub fn vim_move_line_start(&mut self) {
2962 let text = self.input.clone();
2963 let cursor_byte = byte_index_at_char(&text, self.cursor_position);
2964 // Walk backward until we find a newline or the start of the string.
2965 let line_start_byte = text[..cursor_byte].rfind('\n').map_or(0, |idx| idx + 1);
2966 self.cursor_position = char_count(&text[..line_start_byte]);
2967 self.needs_redraw = true;
2968 }
2969
2970 /// Move the cursor to the end of the current logical line (vim `$`).
2971 pub fn vim_move_line_end(&mut self) {
2972 let text = self.input.clone();
2973 let cursor_byte = byte_index_at_char(&text, self.cursor_position);
2974 // Walk forward to the next newline or end-of-string.
2975 let line_end_char = text[cursor_byte..].find('\n').map_or_else(
2976 || char_count(&text),
2977 |rel| char_count(&text[..cursor_byte + rel]),
2978 );
2979 self.cursor_position = line_end_char;
2980 self.needs_redraw = true;
2981 }
2982
2983 /// Move forward one word (vim `w`). Skips over the current word then any
2984 /// trailing whitespace to land on the first character of the next word.
2985 pub fn vim_move_word_forward(&mut self) {
2986 let text = self.input.clone();
2987 let total = char_count(&text);
2988 let mut pos = self.cursor_position;
2989 if pos >= total {
2990 return;
2991 }
2992 // Skip non-whitespace (current word).
2993 while pos < total {
2994 let byte = byte_index_at_char(&text, pos);
2995 let ch = text[byte..].chars().next().unwrap_or(' ');
2996 if ch.is_whitespace() {
2997 break;
2998 }
2999 pos += 1;
3000 }
3001 // Skip whitespace.
3002 while pos < total {
3003 let byte = byte_index_at_char(&text, pos);
3004 let ch = text[byte..].chars().next().unwrap_or(' ');
3005 if !ch.is_whitespace() {
3006 break;
3007 }
3008 pos += 1;
3009 }
3010 self.cursor_position = pos;
3011 self.needs_redraw = true;
3012 }
3013
3014 /// Move backward one word (vim `b`). Skips leading whitespace then the
3015 /// preceding word to land on its first character.
3016 pub fn vim_move_word_backward(&mut self) {
3017 let text = self.input.clone();
3018 let mut pos = self.cursor_position;
3019 if pos == 0 {
3020 return;
3021 }
3022 // Step back one so we're not already at the word start.
3023 pos -= 1;
3024 // Skip whitespace.
3025 while pos > 0 {
3026 let byte = byte_index_at_char(&text, pos);
3027 let ch = text[byte..].chars().next().unwrap_or(' ');
3028 if !ch.is_whitespace() {
3029 break;
3030 }
3031 pos -= 1;
3032 }
3033 // Skip non-whitespace.
3034 while pos > 0 {
3035 let byte = byte_index_at_char(&text, pos - 1);
3036 let ch = text[byte..].chars().next().unwrap_or(' ');
3037 if ch.is_whitespace() {
3038 break;
3039 }
3040 pos -= 1;
3041 }
3042 self.cursor_position = pos;
3043 self.needs_redraw = true;
3044 }
3045
3046 /// Delete the character under the cursor (vim `x`).
3047 pub fn vim_delete_char_under_cursor(&mut self) {
3048 let total = char_count(&self.input);
3049 if self.cursor_position >= total {
3050 return;
3051 }
3052 let pos = self.cursor_position;
3053 remove_char_at(&mut self.input, pos);
3054 // Keep cursor in bounds after deletion.
3055 let new_total = char_count(&self.input);
3056 if self.cursor_position > 0 && self.cursor_position >= new_total {
3057 self.cursor_position = new_total.saturating_sub(1);
3058 }
3059 self.needs_redraw = true;
3060 }
3061
3062 /// Delete the entire current logical line (vim `dd`).
3063 pub fn vim_delete_line(&mut self) {
3064 let text = self.input.clone();
3065 let cursor_byte = byte_index_at_char(&text, self.cursor_position);
3066 let line_start_byte = text[..cursor_byte].rfind('\n').map_or(0, |idx| idx + 1);
3067 let line_end_byte = text[cursor_byte..]
3068 .find('\n')
3069 .map_or(text.len(), |rel| cursor_byte + rel);
3070
3071 // Include the trailing newline if present, or the leading newline for the
3072 // very last non-terminated line to avoid leaving a dangling newline.
3073 let (remove_start, remove_end) = if line_end_byte < text.len() {
3074 // There is a newline after the line — remove it too.
3075 (line_start_byte, line_end_byte + 1)
3076 } else if line_start_byte > 0 {
3077 // Last line without trailing newline — remove the preceding newline.
3078 (line_start_byte - 1, line_end_byte)
3079 } else {
3080 // Only line in the buffer.
3081 (line_start_byte, line_end_byte)
3082 };
3083
3084 self.input.replace_range(remove_start..remove_end, "");
3085 self.cursor_position = char_count(&self.input[..remove_start]);
3086 self.needs_redraw = true;
3087 }
3088
3089 /// Enter insert mode at the cursor (vim `i`).
3090 pub fn vim_enter_insert(&mut self) {
3091 self.vim_mode = VimMode::Insert;
3092 self.needs_redraw = true;
3093 }
3094
3095 /// Enter insert mode after the cursor (vim `a`).
3096 pub fn vim_enter_append(&mut self) {
3097 let total = char_count(&self.input);
3098 if self.cursor_position < total {
3099 self.cursor_position += 1;
3100 }
3101 self.vim_mode = VimMode::Insert;
3102 self.needs_redraw = true;
3103 }
3104
3105 /// Open a new line below and enter insert mode (vim `o`).
3106 pub fn vim_open_line_below(&mut self) {
3107 // Move to end of line, then insert a newline.
3108 self.vim_move_line_end();
3109 self.insert_char('\n');
3110 self.vim_mode = VimMode::Insert;
3111 }
3112
3113 /// Return to Normal mode from Insert or Visual (vim `Esc`).
3114 pub fn vim_enter_normal(&mut self) {
3115 self.vim_mode = VimMode::Normal;
3116 self.vim_pending_d = false;
3117 // In Normal mode the cursor sits on a character, not after the last one.
3118 let total = char_count(&self.input);
3119 if self.cursor_position > 0 && self.cursor_position >= total {
3120 self.cursor_position = total.saturating_sub(1);
3121 }
3122 self.needs_redraw = true;
3123 }
3124
3125 /// Returns `true` when vim mode is active and the composer is in Normal
3126 /// mode, which means character keys should NOT be inserted as text.
3127 #[must_use]
3128 pub fn vim_is_normal_mode(&self) -> bool {
3129 self.composer.vim_enabled && self.composer.vim_mode == VimMode::Normal
3130 }
3131
3132 /// Returns `true` when vim mode is active and the composer is in Visual mode.
3133 #[must_use]
3134 pub fn vim_is_visual_mode(&self) -> bool {
3135 self.composer.vim_enabled && self.composer.vim_mode == VimMode::Visual
3136 }
3137
3138 /// Move the cursor down one logical line within the buffer (vim `j`).
3139 /// Falls back to history-down when already on the last line.
3140 pub fn vim_move_down(&mut self) {
3141 let text = self.input.clone();
3142 let total = char_count(&text);
3143 if self.cursor_position >= total {
3144 self.history_down();
3145 return;
3146 }
3147 let cursor_byte = byte_index_at_char(&text, self.cursor_position);
3148 let rest = &text[cursor_byte..];
3149 if let Some(rel_nl) = rest.find('\n') {
3150 // Column offset on the current line.
3151 let line_start_byte = text[..cursor_byte].rfind('\n').map_or(0, |i| i + 1);
3152 let col = char_count(&text[line_start_byte..cursor_byte]);
3153 let next_line_start = cursor_byte + rel_nl + 1;
3154 let next_line = &text[next_line_start..];
3155 let next_line_len = next_line.find('\n').unwrap_or(next_line.len());
3156 let next_line_char_len =
3157 char_count(&text[next_line_start..next_line_start + next_line_len]);
3158 let target_col = col.min(next_line_char_len);
3159 self.cursor_position = char_count(&text[..next_line_start]) + target_col;
3160 self.needs_redraw = true;
3161 } else {
3162 self.history_down();
3163 }
3164 }
3165
3166 /// Move the cursor up one logical line within the buffer (vim `k`).
3167 /// Falls back to history-up when already on the first line.
3168 pub fn vim_move_up(&mut self) {
3169 let text = self.input.clone();
3170 let cursor_byte = byte_index_at_char(&text, self.cursor_position);
3171 if let Some(prev_nl) = text[..cursor_byte].rfind('\n') {
3172 // Column on the current line.
3173 let line_start_byte = prev_nl + 1;
3174 let col = char_count(&text[line_start_byte..cursor_byte]);
3175 // Find start of the previous line.
3176 let prev_line_end = prev_nl; // byte of the newline itself
3177 let prev_start = text[..prev_line_end].rfind('\n').map_or(0, |i| i + 1);
3178 let prev_line_len = char_count(&text[prev_start..prev_line_end]);
3179 let target_col = col.min(prev_line_len);
3180 self.cursor_position = char_count(&text[..prev_start]) + target_col;
3181 self.needs_redraw = true;
3182 } else {
3183 self.history_up();
3184 }
3185 }
3186
3187 pub fn clear_input(&mut self) {
3188 self.clear_input_history_navigation();
3189 self.input.clear();
3190 self.cursor_position = 0;
3191 self.selected_attachment_index = None;
3192 self.slash_menu_selected = 0;
3193 self.slash_menu_hidden = false;
3194 self.paste_burst.clear_after_explicit_paste();
3195 self.needs_redraw = true;
3196 }
3197
3198 pub fn clear_input_recoverable(&mut self) {
3199 self.stash_current_input_for_recovery();
3200 self.clear_input();
3201 }
3202
3203 pub fn stash_current_input_for_recovery(&mut self) {
3204 let draft = self.input.clone();
3205 self.remember_draft_for_recovery(draft);
3206 }
3207
3208 fn remember_draft_for_recovery(&mut self, draft: String) {
3209 if draft.trim().is_empty() {
3210 return;
3211 }
3212 self.draft_history.retain(|existing| existing != &draft);
3213 self.draft_history.push_back(draft);
3214 while self.draft_history.len() > MAX_DRAFT_HISTORY {
3215 let _ = self.draft_history.pop_front();
3216 }
3217 }
3218
3219 pub fn start_history_search(&mut self) {
3220 if self.composer_history_search.is_some() {
3221 return;
3222 }
3223 self.composer_history_search = Some(ComposerHistorySearch::new(
3224 self.input.clone(),
3225 self.cursor_position,
3226 ));
3227 self.slash_menu_hidden = true;
3228 self.mention_menu_hidden = true;
3229 self.paste_burst.clear_after_explicit_paste();
3230 self.status_message = Some("History search: type to filter, Enter accepts".to_string());
3231 self.needs_redraw = true;
3232 }
3233
3234 pub fn is_history_search_active(&self) -> bool {
3235 self.composer_history_search.is_some()
3236 }
3237
3238 pub fn history_search_query(&self) -> Option<&str> {
3239 self.composer_history_search
3240 .as_ref()
3241 .map(|search| search.query.as_str())
3242 }
3243
3244 pub fn history_search_selected_index(&self) -> usize {
3245 self.composer_history_search
3246 .as_ref()
3247 .map_or(0, |search| search.selected)
3248 }
3249
3250 pub fn composer_display_input(&self) -> &str {
3251 self.history_search_query().unwrap_or(&self.input)
3252 }
3253
3254 pub fn composer_display_cursor(&self) -> usize {
3255 self.composer_history_search
3256 .as_ref()
3257 .map_or(self.cursor_position, |search| char_count(&search.query))
3258 }
3259
3260 pub fn history_search_matches(&self) -> Vec<String> {
3261 let Some(query) = self.history_search_query() else {
3262 return Vec::new();
3263 };
3264 self.history_search_matches_for_query(query)
3265 }
3266
3267 fn history_search_matches_for_query(&self, query: &str) -> Vec<String> {
3268 let normalized_query = query.trim().to_lowercase();
3269 let mut seen: HashSet<&str> = HashSet::new();
3270 let mut matches = Vec::new();
3271
3272 for candidate in self
3273 .draft_history
3274 .iter()
3275 .rev()
3276 .chain(self.input_history.iter().rev())
3277 {
3278 if candidate.trim().is_empty() || !seen.insert(candidate.as_str()) {
3279 continue;
3280 }
3281 if normalized_query.is_empty() || candidate.to_lowercase().contains(&normalized_query) {
3282 matches.push(candidate.clone());
3283 }
3284 }
3285
3286 matches
3287 }
3288
3289 fn clamp_history_search_selection(&mut self) {
3290 let Some(search) = self.composer_history_search.as_ref() else {
3291 return;
3292 };
3293 let selected = search.selected;
3294 let query = search.query.clone();
3295 let match_count = self.history_search_matches_for_query(&query).len();
3296 if let Some(search) = self.composer_history_search.as_mut() {
3297 search.selected = if match_count == 0 {
3298 0
3299 } else {
3300 selected.min(match_count.saturating_sub(1))
3301 };
3302 }
3303 }
3304
3305 pub fn history_search_insert_char(&mut self, ch: char) {
3306 if let Some(search) = self.composer_history_search.as_mut() {
3307 search.query.push(ch);
3308 search.selected = 0;
3309 self.status_message = Some("History search: Enter accepts, Esc restores".to_string());
3310 self.needs_redraw = true;
3311 }
3312 }
3313
3314 pub fn history_search_insert_str(&mut self, text: &str) {
3315 if text.is_empty() {
3316 return;
3317 }
3318 if let Some(search) = self.composer_history_search.as_mut() {
3319 search.query.push_str(&normalize_paste_text(text));
3320 search.selected = 0;
3321 self.status_message = Some("History search: Enter accepts, Esc restores".to_string());
3322 self.needs_redraw = true;
3323 }
3324 }
3325
3326 pub fn history_search_backspace(&mut self) {
3327 if let Some(search) = self.composer_history_search.as_mut() {
3328 search.query.pop();
3329 search.selected = 0;
3330 self.needs_redraw = true;
3331 }
3332 self.clamp_history_search_selection();
3333 }
3334
3335 pub fn history_search_select_previous(&mut self) {
3336 if let Some(search) = self.composer_history_search.as_mut() {
3337 search.selected = search.selected.saturating_sub(1);
3338 self.needs_redraw = true;
3339 }
3340 }
3341
3342 pub fn history_search_select_next(&mut self) {
3343 let Some(search) = self.composer_history_search.as_ref() else {
3344 return;
3345 };
3346 let query = search.query.clone();
3347 let selected = search.selected;
3348 let match_count = self.history_search_matches_for_query(&query).len();
3349 if let Some(search) = self.composer_history_search.as_mut()
3350 && match_count > 0
3351 {
3352 search.selected = (selected + 1).min(match_count.saturating_sub(1));
3353 self.needs_redraw = true;
3354 }
3355 }
3356
3357 pub fn accept_history_search(&mut self) -> bool {
3358 let Some(search) = self.composer_history_search.take() else {
3359 return false;
3360 };
3361 let matches = self.history_search_matches_for_query(&search.query);
3362 if let Some(selected) = matches
3363 .get(search.selected.min(matches.len().saturating_sub(1)))
3364 .cloned()
3365 {
3366 self.input = selected;
3367 self.cursor_position = char_count(&self.input);
3368 self.history_index = None;
3369 self.status_message = Some("History match inserted into composer".to_string());
3370 self.needs_redraw = true;
3371 true
3372 } else {
3373 self.composer_history_search = Some(search);
3374 self.status_message = Some("No history matches".to_string());
3375 self.needs_redraw = true;
3376 false
3377 }
3378 }
3379
3380 pub fn cancel_history_search(&mut self) {
3381 let Some(search) = self.composer_history_search.take() else {
3382 return;
3383 };
3384 self.input = search.pre_search_input;
3385 self.cursor_position = search.pre_search_cursor.min(char_count(&self.input));
3386 self.status_message = Some("History search canceled".to_string());
3387 self.needs_redraw = true;
3388 }
3389
3390 pub fn submit_input(&mut self) -> Option<String> {
3391 if self.input.trim().is_empty() {
3392 self.paste_burst.clear_after_explicit_paste();
3393 return None;
3394 }
3395 // When the input exceeds the safety cap, consolidate it into a
3396 // workspace paste file and replace it with an @mention so the
3397 // model can read the full content at turn time (#553).
3398 if char_count(&self.input) > MAX_SUBMITTED_INPUT_CHARS {
3399 self.consolidate_large_input();
3400 }
3401 let input = self.input.clone();
3402 if !input.starts_with('/') {
3403 self.input_history.push(input.clone());
3404 if self.max_input_history == 0 {
3405 self.input_history.clear();
3406 } else if self.input_history.len() > self.max_input_history {
3407 let excess = self.input_history.len() - self.max_input_history;
3408 self.input_history.drain(0..excess);
3409 }
3410 // Mirror to the persisted cross-session history (#366) so
3411 // arrow-up recall works across restarts. Best-effort write —
3412 // see `composer_history::append_history` for failure modes.
3413 crate::composer_history::append_history(&input);
3414 }
3415 self.history_index = None;
3416 self.history_navigation_draft = None;
3417 self.clear_input();
3418 Some(input)
3419 }
3420
3421 /// When the composer input exceeds [`MAX_SUBMITTED_INPUT_CHARS`], write
3422 /// the full content to a timestamped paste file under
3423 /// `.deepseek/pastes/` and replace `self.input` with an `@`-mention
3424 /// pointing at it so the model can read the full content via the
3425 /// normal file-mention resolution path (#553).
3426 fn consolidate_large_input(&mut self) {
3427 let full_input = std::mem::take(&mut self.input);
3428 self.cursor_position = 0;
3429
3430 let now = chrono::Local::now();
3431 let suffix = uuid::Uuid::new_v4().to_string()[..8].to_string();
3432 let filename = format!("paste-{}-{}.md", now.format("%Y-%m-%d-%H%M%S"), suffix);
3433 let rel_path = format!(".deepseek/pastes/{filename}");
3434
3435 let pastes_dir = self.workspace.join(".deepseek/pastes");
3436 if let Err(e) = std::fs::create_dir_all(&pastes_dir) {
3437 // Fallback: keep a truncated version so we don't lose the
3438 // user's input entirely when the filesystem is unhappy.
3439 self.input = full_input.chars().take(MAX_SUBMITTED_INPUT_CHARS).collect();
3440 self.cursor_position = char_count(&self.input);
3441 self.push_status_toast(
3442 format!("Failed to create paste directory: {e}"),
3443 StatusToastLevel::Error,
3444 Some(8_000),
3445 );
3446 return;
3447 }
3448
3449 let file_path = self.workspace.join(&rel_path);
3450 if let Err(e) = std::fs::write(&file_path, &full_input) {
3451 self.input = full_input.chars().take(MAX_SUBMITTED_INPUT_CHARS).collect();
3452 self.cursor_position = char_count(&self.input);
3453 self.push_status_toast(
3454 format!("Failed to write paste file: {e}"),
3455 StatusToastLevel::Error,
3456 Some(8_000),
3457 );
3458 return;
3459 }
3460
3461 self.input = format!("@{rel_path}");
3462 self.cursor_position = char_count(&self.input);
3463 self.push_status_toast(
3464 "Large paste consolidated — sent as @mention",
3465 StatusToastLevel::Info,
3466 Some(5_000),
3467 );
3468 }
3469
3470 pub fn queue_message(&mut self, message: QueuedMessage) {
3471 self.queued_messages.push_back(message);
3472 }
3473
3474 pub fn pop_queued_message(&mut self) -> Option<QueuedMessage> {
3475 self.queued_messages.pop_front()
3476 }
3477
3478 pub fn remove_queued_message(&mut self, index: usize) -> Option<QueuedMessage> {
3479 self.queued_messages.remove(index)
3480 }
3481
3482 pub fn queued_message_count(&self) -> usize {
3483 self.queued_messages.len()
3484 }
3485
3486 /// Pop the most-recently queued message back into the composer for editing
3487 /// (issue #85 — ↑ affordance). The popped message is parked in
3488 /// [`Self::queued_draft`] so the next Enter re-queues it carrying its
3489 /// original skill instruction. No-op if the composer already has typed
3490 /// content or a draft is already being edited — surfacing the affordance
3491 /// would be ambiguous in either case.
3492 ///
3493 /// Returns `true` when the composer state was mutated.
3494 pub fn pop_last_queued_into_draft(&mut self) -> bool {
3495 if !self.input.is_empty() || self.queued_draft.is_some() {
3496 return false;
3497 }
3498 let Some(msg) = self.queued_messages.pop_back() else {
3499 return false;
3500 };
3501 self.input = msg.display.clone();
3502 self.cursor_position = char_count(&self.input);
3503 self.selected_attachment_index = None;
3504 self.queued_draft = Some(msg);
3505 self.needs_redraw = true;
3506 true
3507 }
3508
3509 /// Park a legacy pending steer. New keyboard handling routes running-turn
3510 /// drafts through Enter (same-turn steer) or Tab (next-turn follow-up).
3511 #[allow(dead_code)]
3512 pub fn push_pending_steer(&mut self, message: QueuedMessage) {
3513 self.pending_steers.push_back(message);
3514 self.submit_pending_steers_after_interrupt = true;
3515 self.needs_redraw = true;
3516 }
3517
3518 /// Drain the pending-steer queue and clear the resend flag. Returns the
3519 /// messages in submit order (oldest first).
3520 pub fn drain_pending_steers(&mut self) -> Vec<QueuedMessage> {
3521 self.submit_pending_steers_after_interrupt = false;
3522 if self.pending_steers.is_empty() {
3523 return Vec::new();
3524 }
3525 self.needs_redraw = true;
3526 self.pending_steers.drain(..).collect()
3527 }
3528
3529 /// Decide how to route a fresh composer submit.
3530 ///
3531 /// #382: default to Queue when busy — the user shouldn't have to distinguish
3532 /// "streaming" from "tool execution". Ctrl+Enter overrides to Steer.
3533 ///
3534 /// Truth table:
3535 /// offline=F, busy=F → Immediate
3536 /// offline=F, busy=T → Queue (was Steer for non-streaming; now unified)
3537 /// offline=T, busy=* → Queue
3538 #[must_use]
3539 pub fn decide_submit_disposition(&self) -> SubmitDisposition {
3540 if self.offline_mode {
3541 return SubmitDisposition::Queue;
3542 }
3543 if !self.is_loading {
3544 return SubmitDisposition::Immediate;
3545 }
3546 // Busy: always queue. Ctrl+Enter routes through steer_user_message directly.
3547 SubmitDisposition::Queue
3548 }
3549
3550 /// Mark the in-flight streaming Assistant cell as interrupted: prepend
3551 /// `[interrupted]` to whatever streamed so far (so the user can see what
3552 /// was salvaged) and flip `streaming` off so the spinner halts. No-op if
3553 /// no Assistant cell is currently streaming.
3554 ///
3555 /// Deliberate divergence from openai/codex which discards partial output
3556 /// on abort — V4 thinking is expensive and the user usually wants to see
3557 /// what the model produced before steering.
3558 pub fn finalize_streaming_assistant_as_interrupted(&mut self) {
3559 let Some(index) = self.streaming_message_index.take() else {
3560 return;
3561 };
3562 if let Some(HistoryCell::Assistant { content, streaming }) = self.history.get_mut(index) {
3563 *streaming = false;
3564 if content.is_empty() {
3565 *content = "[interrupted]".to_string();
3566 } else if !content.starts_with("[interrupted]") {
3567 content.insert_str(0, "[interrupted] ");
3568 }
3569 }
3570 self.bump_history_cell(index);
3571 }
3572
3573 pub fn history_up(&mut self) {
3574 if self.input_history.is_empty() {
3575 return;
3576 }
3577 if self.history_index.is_none() {
3578 self.history_navigation_draft = Some(InputHistoryDraft {
3579 input: self.input.clone(),
3580 cursor: self.cursor_position,
3581 });
3582 }
3583 let new_index = match self.history_index {
3584 None => self.input_history.len().saturating_sub(1),
3585 Some(i) => i.saturating_sub(1),
3586 };
3587 self.history_index = Some(new_index);
3588 self.input = self.input_history[new_index].clone();
3589 self.cursor_position = char_count(&self.input);
3590 self.selected_attachment_index = None;
3591 self.slash_menu_hidden = false;
3592 self.paste_burst.clear_after_explicit_paste();
3593 }
3594
3595 pub fn history_down(&mut self) {
3596 if self.input_history.is_empty() {
3597 return;
3598 }
3599 match self.history_index {
3600 None => {}
3601 Some(i) => {
3602 if i + 1 < self.input_history.len() {
3603 self.history_index = Some(i + 1);
3604 self.input = self.input_history[i + 1].clone();
3605 self.cursor_position = char_count(&self.input);
3606 self.selected_attachment_index = None;
3607 self.slash_menu_hidden = false;
3608 self.paste_burst.clear_after_explicit_paste();
3609 } else {
3610 self.history_index = None;
3611 if let Some(draft) = self.history_navigation_draft.take() {
3612 self.input = draft.input;
3613 self.cursor_position = draft.cursor.min(char_count(&self.input));
3614 self.selected_attachment_index = None;
3615 self.slash_menu_hidden = false;
3616 self.paste_burst.clear_after_explicit_paste();
3617 self.needs_redraw = true;
3618 } else {
3619 self.clear_input();
3620 }
3621 }
3622 }
3623 }
3624 }
3625
3626 fn clear_input_history_navigation(&mut self) {
3627 self.history_index = None;
3628 self.history_navigation_draft = None;
3629 }
3630
3631 pub fn clear_todos(&mut self) -> bool {
3632 if let Ok(mut plan) = self.plan_state.try_lock() {
3633 *plan = crate::tools::plan::PlanState::default();
3634 return true;
3635 }
3636 false
3637 }
3638
3639 pub fn update_model_compaction_budget(&mut self) {
3640 let model = self.effective_model_for_budget().to_string();
3641 self.compact_threshold =
3642 compaction_threshold_for_model_and_effort(&model, self.reasoning_effort.api_value());
3643 }
3644
3645 pub fn effective_model_for_budget(&self) -> &str {
3646 if self.auto_model {
3647 return self
3648 .last_effective_model
3649 .as_deref()
3650 .filter(|model| *model != "auto")
3651 .unwrap_or(DEFAULT_TEXT_MODEL);
3652 }
3653 &self.model
3654 }
3655
3656 pub fn model_display_label(&self) -> String {
3657 if self.auto_model {
3658 if let Some(effective) = self.last_effective_model.as_deref()
3659 && effective != "auto"
3660 {
3661 return format!("auto: {effective}");
3662 }
3663 return "auto".to_string();
3664 }
3665 self.model.clone()
3666 }
3667
3668 pub fn reasoning_effort_display_label(&self) -> String {
3669 if self.auto_model || self.reasoning_effort == ReasoningEffort::Auto {
3670 if let Some(effective) = self.last_effective_reasoning_effort {
3671 return format!("auto: {}", effective.short_label());
3672 }
3673 return "auto".to_string();
3674 }
3675 self.reasoning_effort.short_label().to_string()
3676 }
3677
3678 pub fn compaction_config(&self) -> CompactionConfig {
3679 CompactionConfig {
3680 enabled: self.auto_compact,
3681 token_threshold: self.compact_threshold,
3682 model: self.model.clone(),
3683 ..Default::default()
3684 }
3685 }
3686
3687 /// Forward the active cycle configuration to the engine. Cloned so the
3688 /// engine has its own copy to mutate per-session.
3689 pub fn cycle_config(&self) -> CycleConfig {
3690 self.cycle.clone()
3691 }
3692 }
3693
3694 pub fn media_attachment_reference(kind: &str, path: &Path, description: Option<&str>) -> String {
3695 match description {
3696 Some(description) if !description.trim().is_empty() => {
3697 format!(
3698 "[Attached {kind}: {} at {}]",
3699 description.trim(),
3700 path.display()
3701 )
3702 }
3703 _ => format!("[Attached {kind}: {}]", path.display()),
3704 }
3705 }
3706
3707 // === Actions ===
3708
3709 /// Actions emitted by the UI event loop.
3710 #[derive(Debug, Clone, PartialEq)]
3711 pub enum AppAction {
3712 Quit,
3713 #[allow(dead_code)] // For explicit /save command
3714 SaveSession(PathBuf),
3715 #[allow(dead_code)] // For explicit /load command
3716 LoadSession(PathBuf),
3717 SyncSession {
3718 messages: Vec<Message>,
3719 system_prompt: Option<SystemPrompt>,
3720 model: String,
3721 workspace: PathBuf,
3722 },
3723 OpenConfigEditor(ConfigUiMode),
3724 OpenConfigView,
3725 /// Open the `/model` two-pane picker (Pro/Flash + Off/High/Max).
3726 OpenModelPicker,
3727 /// Open the `/provider` picker modal — DeepSeek / NVIDIA NIM / OpenRouter
3728 /// / Novita with inline API-key prompt for un-configured providers (#52).
3729 OpenProviderPicker,
3730 /// Open the `/statusline` multi-select picker for footer items.
3731 OpenStatusPicker,
3732 /// Send a message to the AI (normal chat mode).
3733 SendMessage(String),
3734 /// Run a Recursive Language Model (RLM) turn — Algorithm 1 from
3735 /// Zhang et al. (arXiv:2512.24601). The prompt is stored in the REPL;
3736 /// the root LLM only sees metadata.
3737 Rlm {
3738 /// The user's prompt — stored in REPL, NOT in LLM context.
3739 prompt: String,
3740 /// Model for the root LLM.
3741 model: String,
3742 /// Model for sub-LLM (llm_query) calls.
3743 child_model: String,
3744 /// Recursion budget for `sub_rlm()` calls.
3745 max_depth: u32,
3746 },
3747 ListSubAgents,
3748 FetchModels,
3749 /// Switch the active LLM backend (DeepSeek vs NVIDIA NIM) without
3750 /// restarting the process. The runtime rebuilds its API client from
3751 /// the updated config. `model` overrides the post-switch model
3752 /// (already normalized but not yet provider-prefixed).
3753 SwitchProvider {
3754 provider: ApiProvider,
3755 model: Option<String>,
3756 },
3757 UpdateCompaction(CompactionConfig),
3758 OpenContextInspector,
3759 CompactContext,
3760 TaskAdd {
3761 prompt: String,
3762 },
3763 TaskList,
3764 TaskShow {
3765 id: String,
3766 },
3767 TaskCancel {
3768 id: String,
3769 },
3770 ShellJob(ShellJobAction),
3771 Mcp(McpUiAction),
3772 /// Switch to a different config profile without restarting.
3773 SwitchProfile {
3774 /// Profile name to load.
3775 profile: String,
3776 },
3777 /// Export and share the current session as a web URL.
3778 ShareSession {
3779 history_len: usize,
3780 model: String,
3781 mode: String,
3782 },
3783 }
3784
3785 #[derive(Debug, Clone, PartialEq, Eq)]
3786 pub enum ShellJobAction {
3787 List,
3788 Show {
3789 id: String,
3790 },
3791 Poll {
3792 id: String,
3793 wait: bool,
3794 },
3795 SendStdin {
3796 id: String,
3797 input: String,
3798 close: bool,
3799 },
3800 Cancel {
3801 id: String,
3802 },
3803 }
3804
3805 #[derive(Debug, Clone, PartialEq, Eq)]
3806 pub enum McpUiAction {
3807 Show,
3808 Init {
3809 force: bool,
3810 },
3811 AddStdio {
3812 name: String,
3813 command: String,
3814 args: Vec<String>,
3815 },
3816 AddHttp {
3817 name: String,
3818 url: String,
3819 },
3820 Enable {
3821 name: String,
3822 },
3823 Disable {
3824 name: String,
3825 },
3826 Remove {
3827 name: String,
3828 },
3829 Validate,
3830 Reload,
3831 }
3832
3833 #[cfg(test)]
3834 mod tests {
3835 use super::*;
3836 use crate::config::Config;
3837 use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
3838 use crate::tui::clipboard::PastedImage;
3839
3840 fn test_options(yolo: bool) -> TuiOptions {
3841 TuiOptions {
3842 model: "test-model".to_string(),
3843 workspace: PathBuf::from("."),
3844 config_path: None,
3845 config_profile: None,
3846 allow_shell: yolo,
3847 use_alt_screen: true,
3848 use_mouse_capture: false,
3849 use_bracketed_paste: true,
3850 max_subagents: 1,
3851 skills_dir: PathBuf::from("."),
3852 memory_path: PathBuf::from("memory.md"),
3853 notes_path: PathBuf::from("notes.txt"),
3854 mcp_config_path: PathBuf::from("mcp.json"),
3855 use_memory: false,
3856 start_in_agent_mode: yolo,
3857 skip_onboarding: false,
3858 yolo,
3859 resume_session_id: None,
3860 initial_input: None,
3861 }
3862 }
3863
3864 #[test]
3865 fn test_trust_mode_follows_yolo_on_startup() {
3866 let app = App::new(test_options(true), &Config::default());
3867 assert!(app.trust_mode);
3868 }
3869
3870 #[test]
3871 fn onboarded_user_still_gets_workspace_trust_prompt_when_needed() {
3872 assert_eq!(
3873 initial_onboarding_state(false, true, false, true),
3874 OnboardingState::TrustDirectory
3875 );
3876 }
3877
3878 #[test]
3879 fn new_caches_workspace_skills_for_slash_menu() {
3880 let tmp = tempfile::TempDir::new().expect("tempdir");
3881 let workspace = tmp.path().join("workspace");
3882 let skill_dir = workspace.join(".agents").join("skills").join("local-skill");
3883 std::fs::create_dir_all(&skill_dir).expect("skill dir");
3884 std::fs::write(
3885 skill_dir.join("SKILL.md"),
3886 "---\nname: local-skill\ndescription: Local workspace skill\n---\nUse the local skill.\n",
3887 )
3888 .expect("skill file");
3889
3890 let mut options = test_options(false);
3891 options.workspace = workspace.clone();
3892 options.skills_dir = tmp.path().join("global-skills");
3893 let app = App::new(options, &Config::default());
3894
3895 assert_eq!(app.skills_dir, workspace.join(".agents").join("skills"));
3896 assert!(app.cached_skills.iter().any(|(name, description)| {
3897 name == "local-skill" && description == "Local workspace skill"
3898 }));
3899 }
3900
3901 #[test]
3902 fn submit_input_consolidates_oversized_input_into_paste_file() {
3903 let tmp = tempfile::TempDir::new().expect("tempdir");
3904 let mut opts = test_options(false);
3905 opts.workspace = tmp.path().to_path_buf();
3906 let mut app = App::new(opts, &Config::default());
3907 let full_content = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128);
3908 app.input = full_content.clone();
3909 app.cursor_position = app.input.chars().count();
3910
3911 let submitted = app.submit_input().expect("expected submitted input");
3912
3913 // The submitted text should be the @mention, not the truncated
3914 // original (#553).
3915 assert!(
3916 submitted.starts_with("@.deepseek/pastes/paste-"),
3917 "expected @mention, got: {submitted}"
3918 );
3919 assert!(
3920 submitted.ends_with(".md"),
3921 "expected .md extension, got: {submitted}"
3922 );
3923
3924 // The paste file must exist on disk with the full original content.
3925 let rel_path = &submitted[1..]; // strip leading '@'
3926 let abs_path = tmp.path().join(rel_path);
3927 assert!(abs_path.is_file(), "paste file must exist at {abs_path:?}");
3928 let written = std::fs::read_to_string(&abs_path).expect("read paste file");
3929 assert_eq!(written, full_content);
3930
3931 // A status toast should have been pushed.
3932 assert!(
3933 app.status_toasts
3934 .iter()
3935 .any(|toast| toast.text.contains("consolidated")),
3936 "expected consolidation toast, got: {:?}",
3937 app.status_toasts
3938 .iter()
3939 .map(|t| &t.text)
3940 .collect::<Vec<_>>()
3941 );
3942
3943 // The composer must be clear after submit.
3944 assert!(app.input.is_empty());
3945 }
3946
3947 #[test]
3948 fn app_starts_without_seeded_transcript_messages() {
3949 let app = App::new(test_options(false), &Config::default());
3950 assert!(app.history.is_empty());
3951 assert_eq!(app.history_version, 0);
3952 }
3953
3954 #[test]
3955 fn clear_todos_resets_plan_state() {
3956 let mut app = App::new(test_options(false), &Config::default());
3957
3958 {
3959 let mut plan = app
3960 .plan_state
3961 .try_lock()
3962 .expect("plan lock should be available");
3963 plan.update(UpdatePlanArgs {
3964 explanation: Some("test plan".to_string()),
3965 plan: vec![PlanItemArg {
3966 step: "step 1".to_string(),
3967 status: StepStatus::InProgress,
3968 }],
3969 });
3970 assert!(!plan.is_empty());
3971 }
3972
3973 assert!(app.clear_todos());
3974
3975 let plan = app
3976 .plan_state
3977 .try_lock()
3978 .expect("plan lock should be available");
3979 assert!(plan.is_empty());
3980 }
3981
3982 #[test]
3983 fn test_cycle_mode_transitions() {
3984 let mut app = App::new(test_options(false), &Config::default());
3985 // Default mode should be Agent based on settings
3986 let initial_mode = app.mode;
3987 app.cycle_mode();
3988 // Mode should have changed
3989 assert_ne!(app.mode, initial_mode);
3990 }
3991
3992 #[test]
3993 fn test_cycle_mode_reverse_transitions() {
3994 let mut app = App::new(test_options(false), &Config::default());
3995
3996 app.mode = AppMode::Plan;
3997 app.cycle_mode_reverse();
3998 assert_eq!(app.mode, AppMode::Yolo);
3999
4000 app.mode = AppMode::Agent;
4001 app.cycle_mode_reverse();
4002 assert_eq!(app.mode, AppMode::Plan);
4003 }
4004
4005 #[test]
4006 fn test_clear_input() {
4007 let mut app = App::new(test_options(false), &Config::default());
4008 app.input = "test input".to_string();
4009 app.cursor_position = app.input.len();
4010 app.clear_input();
4011 assert!(app.input.is_empty());
4012 assert_eq!(app.cursor_position, 0);
4013 }
4014
4015 #[test]
4016 fn test_queue_message() {
4017 let mut app = App::new(test_options(false), &Config::default());
4018 app.queue_message(QueuedMessage::new("test message".to_string(), None));
4019 assert_eq!(app.queued_message_count(), 1);
4020 assert!(app.queued_messages.front().is_some());
4021 }
4022
4023 #[test]
4024 fn test_remove_queued_message() {
4025 let mut app = App::new(test_options(false), &Config::default());
4026 app.queue_message(QueuedMessage::new("first".to_string(), None));
4027 app.queue_message(QueuedMessage::new("second".to_string(), None));
4028
4029 // Remove first (index 0)
4030 let removed = app.remove_queued_message(0);
4031 assert!(removed.is_some());
4032 assert_eq!(app.queued_message_count(), 1);
4033
4034 // Remove second (now at index 0)
4035 let removed = app.remove_queued_message(0);
4036 assert!(removed.is_some());
4037 assert_eq!(app.queued_message_count(), 0);
4038 }
4039
4040 #[test]
4041 fn test_remove_queued_message_invalid_index() {
4042 let mut app = App::new(test_options(false), &Config::default());
4043 app.queue_message(QueuedMessage::new("test".to_string(), None));
4044
4045 // Try to remove non-existent index
4046 let removed = app.remove_queued_message(100);
4047 assert!(removed.is_none());
4048 }
4049
4050 #[test]
4051 fn test_set_mode_updates_state() {
4052 let mut app = App::new(test_options(false), &Config::default());
4053 let initial_mode = app.mode;
4054 app.set_mode(AppMode::Yolo);
4055 assert_eq!(app.mode, AppMode::Yolo);
4056 assert_ne!(app.mode, initial_mode);
4057 // Yolo mode should enable trust and shell
4058 assert!(app.trust_mode);
4059 assert!(app.allow_shell);
4060 }
4061
4062 #[test]
4063 fn app_new_respects_allow_shell_option_when_not_yolo() {
4064 let mut options = test_options(false);
4065 options.allow_shell = false;
4066 options.start_in_agent_mode = true; // avoid coupling to settings.default_mode
4067 let app = App::new(options, &Config::default());
4068 assert!(!app.allow_shell);
4069 }
4070
4071 #[test]
4072 fn set_mode_yolo_restores_previous_policies_on_exit() {
4073 let mut options = test_options(false);
4074 options.allow_shell = false;
4075 options.start_in_agent_mode = true; // avoid coupling to settings.default_mode
4076 let mut app = App::new(options, &Config::default());
4077 app.allow_shell = false;
4078 app.trust_mode = false;
4079 app.approval_mode = ApprovalMode::Never;
4080
4081 app.set_mode(AppMode::Yolo);
4082 assert!(app.allow_shell);
4083 assert!(app.trust_mode);
4084 assert_eq!(app.approval_mode, ApprovalMode::Auto);
4085
4086 app.set_mode(AppMode::Agent);
4087 assert!(!app.allow_shell);
4088 assert!(!app.trust_mode);
4089 assert_eq!(app.approval_mode, ApprovalMode::Never);
4090 }
4091
4092 #[test]
4093 fn leaving_yolo_after_startup_restores_baseline_policies() {
4094 let config = Config {
4095 allow_shell: Some(false),
4096 ..Default::default()
4097 };
4098
4099 let mut app = App::new(test_options(true), &config);
4100 assert_eq!(app.mode, AppMode::Yolo);
4101 assert!(app.allow_shell);
4102 assert!(app.trust_mode);
4103 assert_eq!(app.approval_mode, ApprovalMode::Auto);
4104
4105 app.set_mode(AppMode::Agent);
4106 assert!(!app.allow_shell);
4107 assert!(!app.trust_mode);
4108 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
4109 }
4110
4111 #[test]
4112 fn configured_approval_policy_initializes_live_approval_mode() {
4113 let config = Config {
4114 approval_policy: Some("never".to_string()),
4115 ..Default::default()
4116 };
4117 let mut options = test_options(false);
4118 options.start_in_agent_mode = true;
4119
4120 let app = App::new(options, &config);
4121
4122 assert_eq!(app.mode, AppMode::Agent);
4123 assert_eq!(app.approval_mode, ApprovalMode::Never);
4124 }
4125
4126 #[test]
4127 fn test_mark_history_updated() {
4128 let mut app = App::new(test_options(false), &Config::default());
4129 let initial_version = app.history_version;
4130 app.mark_history_updated();
4131 assert!(app.history_version > initial_version);
4132 }
4133
4134 #[test]
4135 fn test_scroll_operations() {
4136 let mut app = App::new(test_options(false), &Config::default());
4137 // Just verify scroll methods can be called without panic
4138 app.scroll_up(5);
4139 app.scroll_down(3);
4140 }
4141
4142 #[test]
4143 fn test_add_message() {
4144 let mut app = App::new(test_options(false), &Config::default());
4145 let initial_len = app.history.len();
4146 app.add_message(HistoryCell::User {
4147 content: "test".to_string(),
4148 });
4149 assert_eq!(app.history.len(), initial_len + 1);
4150 }
4151
4152 #[test]
4153 fn test_compaction_config() {
4154 let app = App::new(test_options(false), &Config::default());
4155 let config = app.compaction_config();
4156 // Config should be valid (just checking it returns something)
4157 let _ = config.enabled;
4158 }
4159
4160 #[test]
4161 fn test_update_model_compaction_budget() {
4162 let mut app = App::new(test_options(false), &Config::default());
4163 app.model = "unknown-test-model".to_string();
4164 app.update_model_compaction_budget();
4165 let initial_threshold = app.compact_threshold;
4166 app.model = "deepseek-v3.2-128k".to_string();
4167 app.update_model_compaction_budget();
4168 // Threshold may have changed based on model
4169 // Explicit 128k DeepSeek model IDs have a higher threshold than unknown models.
4170 assert!(app.compact_threshold >= initial_threshold);
4171 }
4172
4173 #[test]
4174 fn test_input_history_navigation() {
4175 let mut app = App::new(test_options(false), &Config::default());
4176 app.input_history.push("first".to_string());
4177 app.input_history.push("second".to_string());
4178
4179 // Navigate up
4180 app.history_up();
4181 assert!(app.history_index.is_some());
4182
4183 // Navigate down
4184 app.history_down();
4185 }
4186
4187 #[test]
4188 fn input_history_down_restores_live_draft_after_accidental_up() {
4189 let mut app = App::new(test_options(false), &Config::default());
4190 app.input_history.push("previous prompt".to_string());
4191 app.input = "careful current draft".to_string();
4192 app.cursor_position = "careful".chars().count();
4193
4194 app.history_up();
4195 assert_eq!(app.input, "previous prompt");
4196
4197 app.history_down();
4198 assert_eq!(app.input, "careful current draft");
4199 assert_eq!(app.cursor_position, "careful".chars().count());
4200 assert!(app.history_index.is_none());
4201 }
4202
4203 #[test]
4204 fn input_history_restores_empty_draft_at_end_of_navigation() {
4205 let mut app = App::new(test_options(false), &Config::default());
4206 app.input_history.push("previous prompt".to_string());
4207
4208 app.history_up();
4209 assert_eq!(app.input, "previous prompt");
4210
4211 app.history_down();
4212 assert!(app.input.is_empty());
4213 assert_eq!(app.cursor_position, 0);
4214 assert!(app.history_index.is_none());
4215 }
4216
4217 #[test]
4218 fn editing_history_entry_leaves_navigation_mode() {
4219 let mut app = App::new(test_options(false), &Config::default());
4220 app.input_history.push("previous prompt".to_string());
4221 app.input = "current draft".to_string();
4222 app.cursor_position = app.input.chars().count();
4223
4224 app.history_up();
4225 app.insert_char('!');
4226 app.history_down();
4227
4228 assert_eq!(app.input, "previous prompt!");
4229 assert!(app.history_index.is_none());
4230 }
4231
4232 #[test]
4233 fn history_search_filters_matches_and_skips_duplicates() {
4234 let mut app = App::new(test_options(false), &Config::default());
4235 app.input_history.push("alpha one".to_string());
4236 app.input_history.push("beta two".to_string());
4237 app.input_history.push("alpha one".to_string());
4238 app.draft_history.push_back("draft alpha".to_string());
4239
4240 app.start_history_search();
4241 app.history_search_insert_str("alpha");
4242
4243 assert_eq!(
4244 app.history_search_matches(),
4245 vec!["draft alpha".to_string(), "alpha one".to_string()]
4246 );
4247 }
4248
4249 #[test]
4250 fn history_search_matches_unicode_case_insensitively() {
4251 let mut app = App::new(test_options(false), &Config::default());
4252 app.input_history.push("CAFÉ prompt".to_string());
4253
4254 app.start_history_search();
4255 app.history_search_insert_str("café");
4256
4257 assert_eq!(
4258 app.history_search_matches(),
4259 vec!["CAFÉ prompt".to_string()]
4260 );
4261 }
4262
4263 #[test]
4264 fn history_search_accepts_match_without_submitting() {
4265 let mut app = App::new(test_options(false), &Config::default());
4266 app.input_history.push("older prompt".to_string());
4267
4268 app.start_history_search();
4269 app.history_search_insert_str("older");
4270
4271 assert!(app.accept_history_search());
4272 assert_eq!(app.input, "older prompt");
4273 assert_eq!(app.cursor_position, "older prompt".chars().count());
4274 assert!(app.composer_history_search.is_none());
4275 }
4276
4277 #[test]
4278 fn history_search_cancel_restores_pre_search_draft() {
4279 let mut app = App::new(test_options(false), &Config::default());
4280 app.input = "current draft".to_string();
4281 app.cursor_position = 7;
4282 app.input_history.push("older prompt".to_string());
4283
4284 app.start_history_search();
4285 app.history_search_insert_str("older");
4286 app.cancel_history_search();
4287
4288 assert_eq!(app.input, "current draft");
4289 assert_eq!(app.cursor_position, 7);
4290 assert!(app.composer_history_search.is_none());
4291 }
4292
4293 #[test]
4294 fn recoverable_clear_stashes_nonempty_draft() {
4295 let mut app = App::new(test_options(false), &Config::default());
4296 app.input = "recover this".to_string();
4297 app.cursor_position = app.input.chars().count();
4298
4299 app.clear_input_recoverable();
4300 app.start_history_search();
4301 app.history_search_insert_str("recover");
4302
4303 assert_eq!(
4304 app.history_search_matches(),
4305 vec!["recover this".to_string()]
4306 );
4307 }
4308
4309 #[test]
4310 fn composer_paste_flushes_pending_burst_and_normalizes_crlf() {
4311 let mut app = App::new(test_options(false), &Config::default());
4312 app.use_paste_burst_detection = true;
4313 let now = Instant::now();
4314 let key = crossterm::event::KeyEvent::new(
4315 crossterm::event::KeyCode::Char('x'),
4316 crossterm::event::KeyModifiers::NONE,
4317 );
4318
4319 assert!(crate::tui::paste::handle_paste_burst_key(
4320 &mut app, &key, now
4321 ));
4322 assert!(
4323 app.input.is_empty(),
4324 "first burst char should stay buffered"
4325 );
4326
4327 app.insert_paste_text("a\r\nb\rc");
4328
4329 assert_eq!(app.input, "xa\nbc");
4330 assert_eq!(app.cursor_position, "xa\nbc".chars().count());
4331 assert!(!app.paste_burst.is_active());
4332 }
4333
4334 #[test]
4335 fn clipboard_text_paste_matches_bracketed_paste_state() {
4336 let text = "alpha\r\nbeta";
4337 let mut bracketed = App::new(test_options(false), &Config::default());
4338 let mut clipboard = App::new(test_options(false), &Config::default());
4339
4340 bracketed.insert_paste_text(text);
4341 clipboard.apply_clipboard_content(ClipboardContent::Text(text.to_string()));
4342
4343 assert_eq!(clipboard.input, bracketed.input);
4344 assert_eq!(clipboard.cursor_position, bracketed.cursor_position);
4345 assert_eq!(clipboard.slash_menu_hidden, bracketed.slash_menu_hidden);
4346 assert_eq!(clipboard.mention_menu_hidden, bracketed.mention_menu_hidden);
4347 }
4348
4349 #[test]
4350 fn clipboard_image_paste_keeps_adjacent_text_and_concise_status() {
4351 let mut app = App::new(test_options(false), &Config::default());
4352 app.input = "before after".to_string();
4353 app.cursor_position = "before".chars().count();
4354
4355 app.apply_clipboard_content(ClipboardContent::Image(PastedImage {
4356 path: PathBuf::from("/tmp/pasted.png"),
4357 width: 8,
4358 height: 4,
4359 byte_len: 2048,
4360 }));
4361
4362 assert!(
4363 app.input
4364 .contains("before\n[Attached image: 8x4 PNG (2KB) at /tmp/pasted.png]")
4365 );
4366 assert!(app.input.contains("] after"));
4367 let status = app.status_message.as_deref().expect("status message");
4368 assert_eq!(status, "Attached image: 8x4 PNG (2KB)");
4369 }
4370
4371 #[test]
4372 fn pasted_text_and_image_placeholders_survive_history_and_queue_paths() {
4373 let mut app = App::new(test_options(false), &Config::default());
4374 app.insert_paste_text("line 1\r\nline 2");
4375 app.insert_media_attachment("image", Path::new("/tmp/pasted.png"), Some("8x4 PNG (2KB)"));
4376
4377 let submitted = app.submit_input().expect("submitted input");
4378 assert!(submitted.contains("line 1\nline 2"));
4379 assert!(submitted.contains("[Attached image: 8x4 PNG (2KB) at /tmp/pasted.png]"));
4380
4381 app.history_up();
4382 assert_eq!(app.input, submitted);
4383 assert_eq!(app.composer_attachment_count(), 1);
4384
4385 app.clear_input();
4386 app.queue_message(QueuedMessage::new(
4387 submitted.clone(),
4388 Some("Use this skill".to_string()),
4389 ));
4390 assert!(app.pop_last_queued_into_draft());
4391 assert_eq!(app.input, submitted);
4392 assert_eq!(app.composer_attachment_count(), 1);
4393 assert_eq!(
4394 app.queued_draft
4395 .as_ref()
4396 .and_then(|draft| draft.skill_instruction.as_deref()),
4397 Some("Use this skill")
4398 );
4399
4400 app.push_pending_steer(QueuedMessage::new(submitted.clone(), None));
4401 let steers = app.drain_pending_steers();
4402 assert_eq!(steers[0].display, submitted);
4403 }
4404
4405 #[test]
4406 fn selected_attachment_row_removes_placeholder_without_manual_editing() {
4407 let mut app = App::new(test_options(false), &Config::default());
4408 app.input = "before".to_string();
4409 app.cursor_position = "before".chars().count();
4410 app.insert_media_attachment("image", Path::new("/tmp/pasted.png"), Some("8x4 PNG"));
4411 app.insert_str("after");
4412
4413 app.move_cursor_start();
4414 assert!(app.select_previous_composer_attachment());
4415 assert_eq!(app.selected_composer_attachment_index(), Some(0));
4416 assert!(app.remove_selected_composer_attachment());
4417
4418 assert!(!app.input.contains("[Attached image:"));
4419 assert!(app.input.contains("before"));
4420 assert!(app.input.contains("after"));
4421 assert_eq!(app.composer_attachment_count(), 0);
4422 assert!(app.selected_composer_attachment_index().is_none());
4423 }
4424
4425 #[test]
4426 fn kill_to_end_of_line_cuts_from_middle_of_word() {
4427 let mut app = App::new(test_options(false), &Config::default());
4428 app.input = "hello world".to_string();
4429 app.cursor_position = 6; // before 'w'
4430 assert!(app.kill_to_end_of_line());
4431 assert_eq!(app.input, "hello ");
4432 assert_eq!(app.cursor_position, 6);
4433 assert_eq!(app.kill_buffer, "world");
4434 }
4435
4436 #[test]
4437 fn kill_at_eol_consumes_following_newline() {
4438 let mut app = App::new(test_options(false), &Config::default());
4439 app.input = "line one\nline two".to_string();
4440 app.cursor_position = 8; // sitting on the '\n'
4441 assert!(app.kill_to_end_of_line());
4442 assert_eq!(app.input, "line oneline two");
4443 assert_eq!(app.cursor_position, 8);
4444 assert_eq!(app.kill_buffer, "\n");
4445
4446 // Empty input: kill is a no-op and the buffer is untouched.
4447 let mut empty = App::new(test_options(false), &Config::default());
4448 assert!(!empty.kill_to_end_of_line());
4449 assert!(empty.input.is_empty());
4450 assert!(empty.kill_buffer.is_empty());
4451 }
4452
4453 #[test]
4454 fn yank_inserts_kill_buffer_and_preserves_it() {
4455 let mut app = App::new(test_options(false), &Config::default());
4456 app.input = "abc def".to_string();
4457 app.cursor_position = 4; // before 'd'
4458 assert!(app.kill_to_end_of_line());
4459 assert_eq!(app.input, "abc ");
4460 assert_eq!(app.kill_buffer, "def");
4461
4462 // Move cursor to the start and yank twice — kill_buffer must persist.
4463 app.cursor_position = 0;
4464 assert!(app.yank());
4465 assert!(app.yank());
4466 assert_eq!(app.input, "defdefabc ");
4467 assert_eq!(app.cursor_position, 6);
4468 assert_eq!(app.kill_buffer, "def");
4469
4470 // Yank with empty buffer is a no-op.
4471 let mut empty = App::new(test_options(false), &Config::default());
4472 assert!(!empty.yank());
4473 assert!(empty.input.is_empty());
4474 }
4475
4476 // ---- Issue #90: quit confirmation timeout ----
4477
4478 #[test]
4479 fn quit_is_not_armed_by_default() {
4480 let app = App::new(test_options(false), &Config::default());
4481 assert!(!app.quit_is_armed());
4482 assert!(app.quit_armed_until.is_none());
4483 }
4484
4485 #[test]
4486 fn arm_quit_sets_two_second_window() {
4487 let mut app = App::new(test_options(false), &Config::default());
4488 app.arm_quit();
4489 assert!(app.quit_is_armed());
4490 let deadline = app.quit_armed_until.expect("deadline set");
4491 let remaining = deadline.saturating_duration_since(Instant::now());
4492 // Allow a generous margin for slow CI machines: 1.5s..=2.0s.
4493 assert!(
4494 remaining >= Duration::from_millis(1500) && remaining <= Duration::from_secs(2),
4495 "expected ~2s window, got {remaining:?}",
4496 );
4497 assert!(app.needs_redraw, "armed prompt should request a redraw");
4498 }
4499
4500 #[test]
4501 fn disarm_quit_clears_the_timer() {
4502 let mut app = App::new(test_options(false), &Config::default());
4503 app.arm_quit();
4504 app.needs_redraw = false;
4505 app.disarm_quit();
4506 assert!(!app.quit_is_armed());
4507 assert!(app.quit_armed_until.is_none());
4508 assert!(app.needs_redraw, "disarming should request a redraw");
4509 }
4510
4511 #[test]
4512 fn disarm_quit_when_not_armed_is_a_noop() {
4513 let mut app = App::new(test_options(false), &Config::default());
4514 app.needs_redraw = false;
4515 app.disarm_quit();
4516 assert!(!app.needs_redraw, "no redraw when nothing changed");
4517 }
4518
4519 #[test]
4520 fn quit_armed_expires_after_window() {
4521 let mut app = App::new(test_options(false), &Config::default());
4522 // Pin the deadline in the past to simulate a stale timer.
4523 app.quit_armed_until = Some(Instant::now() - Duration::from_millis(10));
4524 assert!(
4525 !app.quit_is_armed(),
4526 "expired timer must not count as armed"
4527 );
4528
4529 app.needs_redraw = false;
4530 app.tick_quit_armed();
4531 assert!(app.quit_armed_until.is_none(), "tick clears expired timer");
4532 assert!(
4533 app.needs_redraw,
4534 "expiry triggers a redraw to repaint footer"
4535 );
4536 }
4537
4538 #[test]
4539 fn quit_armed_tick_is_noop_within_window() {
4540 let mut app = App::new(test_options(false), &Config::default());
4541 app.arm_quit();
4542 app.needs_redraw = false;
4543 app.tick_quit_armed();
4544 assert!(
4545 app.quit_is_armed(),
4546 "tick within window keeps the timer armed"
4547 );
4548 assert!(!app.needs_redraw, "no redraw when nothing changed");
4549 }
4550
4551 #[test]
4552 fn re_arming_after_expiry_starts_a_fresh_window() {
4553 let mut app = App::new(test_options(false), &Config::default());
4554 app.quit_armed_until = Some(Instant::now() - Duration::from_secs(5));
4555 app.tick_quit_armed();
4556 assert!(app.quit_armed_until.is_none());
4557 app.arm_quit();
4558 let deadline = app.quit_armed_until.expect("re-armed");
4559 assert!(deadline > Instant::now(), "fresh deadline in the future");
4560 }
4561
4562 // ---- Issue #208: in-flight input routing ----
4563
4564 #[test]
4565 fn submit_disposition_immediate_when_idle_and_online() {
4566 let app = App::new(test_options(false), &Config::default());
4567 assert!(!app.is_loading);
4568 assert!(!app.offline_mode);
4569 assert_eq!(
4570 app.decide_submit_disposition(),
4571 SubmitDisposition::Immediate
4572 );
4573 }
4574
4575 #[test]
4576 fn submit_disposition_queue_when_busy_and_online_not_streaming() {
4577 // #382: Busy + not streaming → Queue (was Steer; now unified)
4578 let mut app = App::new(test_options(false), &Config::default());
4579 app.is_loading = true;
4580 app.offline_mode = false;
4581 // streaming_message_index is None (default) → tool execution phase
4582 assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
4583 }
4584
4585 #[test]
4586 fn submit_disposition_queue_when_busy_and_streaming() {
4587 // #382: Busy + streaming → Queue (was QueueFollowUp; now unified)
4588 let mut app = App::new(test_options(false), &Config::default());
4589 app.is_loading = true;
4590 app.offline_mode = false;
4591 app.streaming_message_index = Some(0);
4592 assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
4593 }
4594
4595 #[test]
4596 fn submit_disposition_queue_when_offline_and_idle() {
4597 let mut app = App::new(test_options(false), &Config::default());
4598 app.is_loading = false;
4599 app.offline_mode = true;
4600 assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
4601 }
4602
4603 #[test]
4604 fn submit_disposition_offline_busy_queues() {
4605 let mut app = App::new(test_options(false), &Config::default());
4606 app.is_loading = true;
4607 app.offline_mode = true;
4608 // Offline mode always queues, even when streaming
4609 app.streaming_message_index = Some(0);
4610 assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
4611 }
4612
4613 #[test]
4614 fn push_pending_steer_arms_resend_flag() {
4615 let mut app = App::new(test_options(false), &Config::default());
4616 assert!(!app.submit_pending_steers_after_interrupt);
4617 app.push_pending_steer(QueuedMessage::new("steer me".to_string(), None));
4618 assert_eq!(app.pending_steers.len(), 1);
4619 assert!(app.submit_pending_steers_after_interrupt);
4620 }
4621
4622 #[test]
4623 fn drain_pending_steers_clears_flag_and_returns_in_order() {
4624 let mut app = App::new(test_options(false), &Config::default());
4625 app.push_pending_steer(QueuedMessage::new("first".to_string(), None));
4626 app.push_pending_steer(QueuedMessage::new("second".to_string(), None));
4627 app.push_pending_steer(QueuedMessage::new("third".to_string(), None));
4628
4629 let drained = app.drain_pending_steers();
4630 assert_eq!(drained.len(), 3);
4631 assert_eq!(drained[0].display, "first");
4632 assert_eq!(drained[2].display, "third");
4633 assert!(app.pending_steers.is_empty());
4634 assert!(!app.submit_pending_steers_after_interrupt);
4635 }
4636
4637 #[test]
4638 fn drain_pending_steers_when_empty_is_safe() {
4639 let mut app = App::new(test_options(false), &Config::default());
4640 // Flag-only set (someone armed it manually): drain still clears it.
4641 app.submit_pending_steers_after_interrupt = true;
4642 let drained = app.drain_pending_steers();
4643 assert!(drained.is_empty());
4644 assert!(!app.submit_pending_steers_after_interrupt);
4645 }
4646
4647 #[test]
4648 fn double_push_pending_steer_is_idempotent_on_flag() {
4649 let mut app = App::new(test_options(false), &Config::default());
4650 app.push_pending_steer(QueuedMessage::new("a".to_string(), None));
4651 app.push_pending_steer(QueuedMessage::new("b".to_string(), None));
4652 assert!(app.submit_pending_steers_after_interrupt);
4653 assert_eq!(app.pending_steers.len(), 2);
4654 }
4655
4656 #[test]
4657 fn pop_last_queued_into_draft_pops_back_and_arms_draft() {
4658 let mut app = App::new(test_options(false), &Config::default());
4659 app.queue_message(QueuedMessage::new(
4660 "first".to_string(),
4661 Some("skill-A".to_string()),
4662 ));
4663 app.queue_message(QueuedMessage::new(
4664 "last".to_string(),
4665 Some("skill-B".to_string()),
4666 ));
4667
4668 assert!(app.pop_last_queued_into_draft());
4669 assert_eq!(app.input, "last");
4670 assert_eq!(app.cursor_position, "last".chars().count());
4671 assert_eq!(app.queued_messages.len(), 1);
4672 let draft = app.queued_draft.clone().expect("draft is set");
4673 assert_eq!(draft.display, "last");
4674 assert_eq!(draft.skill_instruction.as_deref(), Some("skill-B"));
4675 }
4676
4677 #[test]
4678 fn pop_last_queued_into_draft_noop_when_composer_dirty() {
4679 let mut app = App::new(test_options(false), &Config::default());
4680 app.queue_message(QueuedMessage::new("queued".to_string(), None));
4681 app.input = "typing".to_string();
4682 app.cursor_position = char_count(&app.input);
4683
4684 assert!(!app.pop_last_queued_into_draft());
4685 assert_eq!(app.input, "typing");
4686 assert_eq!(app.queued_messages.len(), 1);
4687 assert!(app.queued_draft.is_none());
4688 }
4689
4690 #[test]
4691 fn pop_last_queued_into_draft_noop_when_draft_already_armed() {
4692 let mut app = App::new(test_options(false), &Config::default());
4693 app.queue_message(QueuedMessage::new("queued".to_string(), None));
4694 app.queued_draft = Some(QueuedMessage::new("editing".to_string(), None));
4695
4696 assert!(!app.pop_last_queued_into_draft());
4697 assert_eq!(app.queued_messages.len(), 1);
4698 assert_eq!(
4699 app.queued_draft.as_ref().map(|d| d.display.as_str()),
4700 Some("editing")
4701 );
4702 }
4703
4704 #[test]
4705 fn pop_last_queued_into_draft_noop_when_queue_empty() {
4706 let mut app = App::new(test_options(false), &Config::default());
4707 assert!(!app.pop_last_queued_into_draft());
4708 assert!(app.input.is_empty());
4709 assert!(app.queued_draft.is_none());
4710 }
4711
4712 #[test]
4713 fn finalize_streaming_assistant_marks_existing_cell_interrupted() {
4714 let mut app = App::new(test_options(false), &Config::default());
4715 app.add_message(HistoryCell::Assistant {
4716 content: "partial reply so far".to_string(),
4717 streaming: true,
4718 });
4719 let idx = app.history.len() - 1;
4720 app.streaming_message_index = Some(idx);
4721
4722 app.finalize_streaming_assistant_as_interrupted();
4723
4724 assert!(app.streaming_message_index.is_none());
4725 match &app.history[idx] {
4726 HistoryCell::Assistant { content, streaming } => {
4727 assert!(content.starts_with("[interrupted]"), "got: {content}");
4728 assert!(content.contains("partial reply so far"));
4729 assert!(!*streaming);
4730 }
4731 other => panic!("expected Assistant cell, got {other:?}"),
4732 }
4733 }
4734
4735 #[test]
4736 fn finalize_streaming_assistant_handles_empty_content() {
4737 let mut app = App::new(test_options(false), &Config::default());
4738 app.add_message(HistoryCell::Assistant {
4739 content: String::new(),
4740 streaming: true,
4741 });
4742 let idx = app.history.len() - 1;
4743 app.streaming_message_index = Some(idx);
4744
4745 app.finalize_streaming_assistant_as_interrupted();
4746
4747 match &app.history[idx] {
4748 HistoryCell::Assistant { content, streaming } => {
4749 assert_eq!(content, "[interrupted]");
4750 assert!(!*streaming);
4751 }
4752 other => panic!("expected Assistant cell, got {other:?}"),
4753 }
4754 }
4755
4756 #[test]
4757 fn finalize_streaming_assistant_no_op_without_index() {
4758 let mut app = App::new(test_options(false), &Config::default());
4759 // No streaming index set; should not panic and should leave history unchanged.
4760 let prev_len = app.history.len();
4761 app.finalize_streaming_assistant_as_interrupted();
4762 assert_eq!(app.history.len(), prev_len);
4763 assert!(app.streaming_message_index.is_none());
4764 }
4765
4766 #[test]
4767 fn finalize_streaming_assistant_is_idempotent_on_double_call() {
4768 let mut app = App::new(test_options(false), &Config::default());
4769 app.add_message(HistoryCell::Assistant {
4770 content: "something".to_string(),
4771 streaming: true,
4772 });
4773 let idx = app.history.len() - 1;
4774 app.streaming_message_index = Some(idx);
4775
4776 app.finalize_streaming_assistant_as_interrupted();
4777 // Second call without resetting state must be safe.
4778 app.finalize_streaming_assistant_as_interrupted();
4779
4780 match &app.history[idx] {
4781 HistoryCell::Assistant { content, .. } => {
4782 // Second call still finds index None — content unchanged from first.
4783 assert!(content.starts_with("[interrupted] "));
4784 assert_eq!(content.matches("[interrupted]").count(), 1);
4785 }
4786 other => panic!("expected Assistant cell, got {other:?}"),
4787 }
4788 }
4789
4790 #[test]
4791 fn delete_word_backward_removes_previous_word_only() {
4792 let mut app = App::new(test_options(false), &Config::default());
4793 app.input = "hello world".to_string();
4794 app.cursor_position = char_count(&app.input);
4795
4796 app.delete_word_backward();
4797
4798 assert_eq!(app.input, "hello ");
4799 assert_eq!(app.cursor_position, char_count("hello "));
4800 }
4801
4802 #[test]
4803 fn delete_word_backward_handles_trailing_space_and_utf8() {
4804 let mut app = App::new(test_options(false), &Config::default());
4805 app.input = "cafe 你好 ".to_string();
4806 app.cursor_position = char_count(&app.input);
4807
4808 app.delete_word_backward();
4809
4810 assert_eq!(app.input, "cafe ");
4811 assert_eq!(app.cursor_position, char_count("cafe "));
4812 }
4813
4814 #[test]
4815 fn delete_word_forward_handles_leading_space_and_utf8() {
4816 let mut app = App::new(test_options(false), &Config::default());
4817 app.input = "hello 你好 world".to_string();
4818 app.cursor_position = char_count("hello");
4819
4820 app.delete_word_forward();
4821
4822 assert_eq!(app.input, "hello world");
4823 assert_eq!(app.cursor_position, char_count("hello"));
4824 }
4825
4826 #[test]
4827 fn delete_to_start_of_line_respects_multiline_cursor() {
4828 let mut app = App::new(test_options(false), &Config::default());
4829 app.input = "first\nsecond line".to_string();
4830 app.cursor_position = char_count("first\nsecond");
4831
4832 app.delete_to_start_of_line();
4833
4834 assert_eq!(app.input, "first\n line");
4835 assert_eq!(app.cursor_position, char_count("first\n"));
4836 }
4837
4838 #[test]
4839 fn kill_and_yank_handle_multibyte_utf8() {
4840 let mut app = App::new(test_options(false), &Config::default());
4841 // "café 你好" — char_count = 7 (c,a,f,é, ,你,好); UTF-8 bytes differ.
4842 app.input = "café 你好".to_string();
4843 app.cursor_position = 5; // before '你'
4844 assert!(app.kill_to_end_of_line());
4845 assert_eq!(app.input, "café ");
4846 assert_eq!(app.cursor_position, 5);
4847 assert_eq!(app.kill_buffer, "你好");
4848
4849 // Yank back at the same spot — must not panic on char boundaries.
4850 assert!(app.yank());
4851 assert_eq!(app.input, "café 你好");
4852 assert_eq!(app.cursor_position, 7);
4853 }
4854 }
4855
4855 lines RUST