Skip to main content

claude_code_rust/app/
state.rs

1// Claude Code Rust - A native Rust terminal interface for Claude Code
2// Copyright (C) 2025  Simon Peter Rothgang
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as
6// published by the Free Software Foundation, either version 3 of the
7// License, or (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17use crate::acp::client::ClientEvent;
18use agent_client_protocol as acp;
19use std::collections::{HashMap, HashSet};
20use std::rc::Rc;
21use std::time::Instant;
22use tokio::sync::mpsc;
23
24use super::focus::{FocusContext, FocusManager, FocusOwner, FocusTarget};
25use super::input::InputState;
26use super::mention;
27use super::slash;
28
29#[derive(Debug)]
30pub struct ModeInfo {
31    pub id: String,
32    pub name: String,
33}
34
35#[derive(Debug)]
36pub struct ModeState {
37    pub current_mode_id: String,
38    pub current_mode_name: String,
39    pub available_modes: Vec<ModeInfo>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum HelpView {
44    #[default]
45    Keys,
46    SlashCommands,
47}
48
49/// Login hint displayed when authentication is required during connection.
50/// Rendered as a banner above the input field.
51pub struct LoginHint {
52    pub method_name: String,
53    pub method_description: String,
54}
55
56/// A single todo item from Claude's `TodoWrite` tool call.
57#[derive(Debug, Clone)]
58pub struct TodoItem {
59    pub content: String,
60    pub status: TodoStatus,
61    pub active_form: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum TodoStatus {
66    Pending,
67    InProgress,
68    Completed,
69}
70
71#[allow(clippy::struct_excessive_bools)]
72pub struct App {
73    pub messages: Vec<ChatMessage>,
74    /// Single owner of all chat layout state: scroll, per-message heights, prefix sums.
75    pub viewport: ChatViewport,
76    pub input: InputState,
77    pub status: AppStatus,
78    pub should_quit: bool,
79    pub session_id: Option<acp::SessionId>,
80    /// ACP connection handle. `None` while connecting (before adapter is ready).
81    pub conn: Option<Rc<acp::ClientSideConnection>>,
82    /// Adapter child process handle. Held solely to keep the process alive --
83    /// dropping `Child` kills the subprocess. Never read after being stored.
84    pub adapter_child: Option<tokio::process::Child>,
85    pub model_name: String,
86    pub cwd: String,
87    pub cwd_raw: String,
88    pub files_accessed: usize,
89    pub mode: Option<ModeState>,
90    /// Login hint shown when authentication is required. Rendered above the input field.
91    pub login_hint: Option<LoginHint>,
92    /// When true, the current/next turn completion should clear local conversation history.
93    /// Set by `/compact` once the command is accepted for ACP forwarding.
94    pub pending_compact_clear: bool,
95    /// Active help overlay view when `?` help is open.
96    pub help_view: HelpView,
97    /// Tool call IDs with pending permission prompts, ordered by arrival.
98    /// The first entry is the "focused" permission that receives keyboard input.
99    /// Up / Down arrow keys cycle focus through the list.
100    pub pending_permission_ids: Vec<String>,
101    /// Set when a cancel notification succeeds; consumed on `TurnComplete`
102    /// to render a red interruption hint in chat.
103    pub cancelled_turn_pending_hint: bool,
104    pub event_tx: mpsc::UnboundedSender<ClientEvent>,
105    pub event_rx: mpsc::UnboundedReceiver<ClientEvent>,
106    pub spinner_frame: usize,
107    /// Session-level default for tool call collapsed state.
108    /// Toggled by Ctrl+O - new tool calls inherit this value.
109    pub tools_collapsed: bool,
110    /// IDs of Task tool calls currently `InProgress` -- their children get hidden.
111    /// Use `insert_active_task()`, `remove_active_task()`.
112    pub active_task_ids: HashSet<String>,
113    /// Shared terminal process map - used to snapshot output on completion.
114    pub terminals: crate::acp::client::TerminalMap,
115    /// Force a full terminal clear on next render frame.
116    pub force_redraw: bool,
117    /// O(1) lookup: `tool_call_id` -> `(message_index, block_index)`.
118    /// Use `lookup_tool_call()`, `index_tool_call()`.
119    pub tool_call_index: HashMap<String, (usize, usize)>,
120    /// Current todo list from Claude's `TodoWrite` tool calls.
121    pub todos: Vec<TodoItem>,
122    /// Whether the header bar is visible.
123    /// Toggled by Ctrl+H.
124    pub show_header: bool,
125    /// Whether the todo panel is expanded (true) or shows compact status line (false).
126    /// Toggled by Ctrl+T.
127    pub show_todo_panel: bool,
128    /// Scroll offset for the expanded todo panel (capped at 5 visible lines).
129    pub todo_scroll: usize,
130    /// Selected todo index used for keyboard navigation in the open todo panel.
131    pub todo_selected: usize,
132    /// Focus manager for directional/navigation key ownership.
133    pub focus: FocusManager,
134    /// Commands advertised by the agent via `AvailableCommandsUpdate`.
135    pub available_commands: Vec<acp::AvailableCommand>,
136    /// Last known frame area (for mouse selection mapping).
137    pub cached_frame_area: ratatui::layout::Rect,
138    /// Current selection state for mouse-based selection.
139    pub selection: Option<SelectionState>,
140    /// Active scrollbar drag state while left mouse button is held on the rail.
141    pub scrollbar_drag: Option<ScrollbarDragState>,
142    /// Cached rendered chat lines for selection/copy.
143    pub rendered_chat_lines: Vec<String>,
144    /// Area where chat content was rendered (for selection mapping).
145    pub rendered_chat_area: ratatui::layout::Rect,
146    /// Cached rendered input lines for selection/copy.
147    pub rendered_input_lines: Vec<String>,
148    /// Area where input content was rendered (for selection mapping).
149    pub rendered_input_area: ratatui::layout::Rect,
150    /// Active `@` file mention autocomplete state.
151    pub mention: Option<mention::MentionState>,
152    /// Active slash-command autocomplete state.
153    pub slash: Option<slash::SlashState>,
154    /// Deferred submit: set `true` when Enter is pressed. If another key event
155    /// arrives during the same drain cycle (paste), this is cleared and the Enter
156    /// becomes a newline. After the drain, the main loop checks: if still `true`,
157    /// strips the trailing newline and submits.
158    pub pending_submit: bool,
159    /// Count of key events processed in the current drain cycle. Used to detect
160    /// paste: if >1 key events arrive in a single cycle, Enter is treated as a
161    /// newline rather than submit.
162    pub drain_key_count: usize,
163    /// Timing-based paste burst detector. Tracks rapid key events to distinguish
164    /// paste from typing when `Event::Paste` is not available (Windows).
165    pub paste_burst: super::paste_burst::PasteBurstDetector,
166    /// Buffered `Event::Paste` payload for this drain cycle.
167    /// Some terminals split one clipboard paste into multiple chunks; we merge
168    /// them and apply placeholder threshold to the merged content once per cycle.
169    pub pending_paste_text: String,
170    /// Cached file list from cwd (scanned on first `@` trigger).
171    pub file_cache: Option<Vec<mention::FileCandidate>>,
172    /// Cached input wrap result (keyed by input version + width).
173    pub input_wrap_cache: Option<InputWrapCache>,
174    /// Cached todo compact line (invalidated on `set_todos()`).
175    pub cached_todo_compact: Option<ratatui::text::Line<'static>>,
176    /// Current git branch (refreshed on focus gain + turn complete).
177    pub git_branch: Option<String>,
178    /// Cached header line (invalidated when git branch changes).
179    pub cached_header_line: Option<ratatui::text::Line<'static>>,
180    /// Cached footer line (invalidated on mode change).
181    pub cached_footer_line: Option<ratatui::text::Line<'static>>,
182
183    /// Indexed terminal tool calls: `(terminal_id, msg_idx, block_idx)`.
184    /// Avoids O(n*m) scan of all messages/blocks every frame.
185    pub terminal_tool_calls: Vec<(String, usize, usize)>,
186    /// Dirty flag: skip `terminal.draw()` when nothing changed since last frame.
187    pub needs_redraw: bool,
188    /// Performance logger. Present only when built with `--features perf`.
189    /// Taken out (`Option::take`) during render, used, then put back to avoid
190    /// borrow conflicts with `&mut App`.
191    pub perf: Option<crate::perf::PerfLogger>,
192    /// Smoothed frames-per-second (EMA of presented frame cadence).
193    pub fps_ema: Option<f32>,
194    /// Timestamp of the previous presented frame.
195    pub last_frame_at: Option<Instant>,
196}
197
198impl App {
199    /// Mark one presented frame at `now`, updating smoothed FPS.
200    pub fn mark_frame_presented(&mut self, now: Instant) {
201        let Some(prev) = self.last_frame_at.replace(now) else {
202            return;
203        };
204        let dt = now.saturating_duration_since(prev).as_secs_f32();
205        if dt <= f32::EPSILON {
206            return;
207        }
208        let fps = (1.0 / dt).clamp(0.0, 240.0);
209        self.fps_ema = Some(match self.fps_ema {
210            Some(current) => current * 0.9 + fps * 0.1,
211            None => fps,
212        });
213    }
214
215    #[must_use]
216    pub fn frame_fps(&self) -> Option<f32> {
217        self.fps_ema
218    }
219
220    /// Ensure the synthetic welcome message exists at index 0.
221    pub fn ensure_welcome_message(&mut self) {
222        if self.messages.first().is_some_and(|m| matches!(m.role, MessageRole::Welcome)) {
223            return;
224        }
225        self.messages.insert(0, ChatMessage::welcome(&self.model_name, &self.cwd));
226        self.viewport.message_heights_width = 0;
227        self.viewport.prefix_sums_width = 0;
228    }
229
230    /// Update the welcome message's model name, but only before chat starts.
231    pub fn update_welcome_model_if_pristine(&mut self) {
232        if self.messages.len() != 1 {
233            return;
234        }
235        let Some(first) = self.messages.first_mut() else {
236            return;
237        };
238        if !matches!(first.role, MessageRole::Welcome) {
239            return;
240        }
241        let Some(MessageBlock::Welcome(welcome)) = first.blocks.first_mut() else {
242            return;
243        };
244        welcome.model_name.clone_from(&self.model_name);
245        welcome.cache.invalidate();
246        self.viewport.message_heights_width = 0;
247        self.viewport.prefix_sums_width = 0;
248    }
249
250    /// Track a Task tool call as active (in-progress subagent).
251    pub fn insert_active_task(&mut self, id: String) {
252        self.active_task_ids.insert(id);
253    }
254
255    /// Remove a Task tool call from the active set (completed/failed).
256    pub fn remove_active_task(&mut self, id: &str) {
257        self.active_task_ids.remove(id);
258    }
259
260    /// Look up the (`message_index`, `block_index`) for a tool call ID.
261    #[must_use]
262    pub fn lookup_tool_call(&self, id: &str) -> Option<(usize, usize)> {
263        self.tool_call_index.get(id).copied()
264    }
265
266    /// Register a tool call's position in the message/block arrays.
267    pub fn index_tool_call(&mut self, id: String, msg_idx: usize, block_idx: usize) {
268        self.tool_call_index.insert(id, (msg_idx, block_idx));
269    }
270
271    /// Force-finish any lingering in-progress tool calls.
272    /// Returns the number of tool calls that were transitioned.
273    pub fn finalize_in_progress_tool_calls(&mut self, new_status: acp::ToolCallStatus) -> usize {
274        let mut changed = 0usize;
275        let mut cleared_permission = false;
276
277        for msg in &mut self.messages {
278            for block in &mut msg.blocks {
279                if let MessageBlock::ToolCall(tc) = block {
280                    let tc = tc.as_mut();
281                    if matches!(
282                        tc.status,
283                        acp::ToolCallStatus::InProgress | acp::ToolCallStatus::Pending
284                    ) {
285                        tc.status = new_status;
286                        tc.cache.invalidate();
287                        if tc.pending_permission.take().is_some() {
288                            cleared_permission = true;
289                        }
290                        changed += 1;
291                    }
292                }
293            }
294        }
295
296        if changed > 0 || cleared_permission {
297            self.pending_permission_ids.clear();
298            self.release_focus_target(FocusTarget::Permission);
299        }
300
301        changed
302    }
303
304    /// Build a minimal `App` for unit/integration tests.
305    /// All fields get sensible defaults; the `mpsc` channel is wired up internally.
306    #[doc(hidden)]
307    #[must_use]
308    pub fn test_default() -> Self {
309        let (tx, rx) = mpsc::unbounded_channel();
310        Self {
311            messages: Vec::new(),
312            viewport: ChatViewport::new(),
313            input: InputState::new(),
314            status: AppStatus::Ready,
315            should_quit: false,
316            session_id: None,
317            conn: None,
318            adapter_child: None,
319            model_name: "test-model".into(),
320            cwd: "/test".into(),
321            cwd_raw: "/test".into(),
322            files_accessed: 0,
323            mode: None,
324            login_hint: None,
325            pending_compact_clear: false,
326            help_view: HelpView::Keys,
327            pending_permission_ids: Vec::new(),
328            cancelled_turn_pending_hint: false,
329            event_tx: tx,
330            event_rx: rx,
331            spinner_frame: 0,
332            tools_collapsed: false,
333            active_task_ids: HashSet::default(),
334            terminals: std::rc::Rc::default(),
335            force_redraw: false,
336            tool_call_index: HashMap::default(),
337            todos: Vec::new(),
338            show_header: true,
339            show_todo_panel: false,
340            todo_scroll: 0,
341            todo_selected: 0,
342            focus: FocusManager::default(),
343            available_commands: Vec::new(),
344            cached_frame_area: ratatui::layout::Rect::default(),
345            selection: None,
346            scrollbar_drag: None,
347            rendered_chat_lines: Vec::new(),
348            rendered_chat_area: ratatui::layout::Rect::default(),
349            rendered_input_lines: Vec::new(),
350            rendered_input_area: ratatui::layout::Rect::default(),
351            mention: None,
352            slash: None,
353            pending_submit: false,
354            drain_key_count: 0,
355            paste_burst: super::paste_burst::PasteBurstDetector::new(),
356            pending_paste_text: String::new(),
357            file_cache: None,
358            input_wrap_cache: None,
359            cached_todo_compact: None,
360            git_branch: None,
361            cached_header_line: None,
362            cached_footer_line: None,
363            terminal_tool_calls: Vec::new(),
364            needs_redraw: true,
365            perf: None,
366            fps_ema: None,
367            last_frame_at: None,
368        }
369    }
370
371    /// Detect the current git branch and invalidate the header cache if it changed.
372    pub fn refresh_git_branch(&mut self) {
373        let new_branch = std::process::Command::new("git")
374            .args(["branch", "--show-current"])
375            .current_dir(&self.cwd_raw)
376            .output()
377            .ok()
378            .and_then(|o| {
379                if o.status.success() {
380                    let s = String::from_utf8_lossy(&o.stdout).trim().to_owned();
381                    if s.is_empty() { None } else { Some(s) }
382                } else {
383                    None
384                }
385            });
386        if new_branch != self.git_branch {
387            self.git_branch = new_branch;
388            self.cached_header_line = None;
389        }
390    }
391
392    /// Resolve the effective focus owner for Up/Down and other directional keys.
393    #[must_use]
394    pub fn focus_owner(&self) -> FocusOwner {
395        self.focus.owner(self.focus_context())
396    }
397
398    #[must_use]
399    pub fn is_help_active(&self) -> bool {
400        self.input.text().trim() == "?"
401    }
402
403    /// Claim key routing for a navigation target.
404    /// The latest claimant wins.
405    pub fn claim_focus_target(&mut self, target: FocusTarget) {
406        let context = self.focus_context();
407        self.focus.claim(target, context);
408    }
409
410    /// Release key routing claim for a navigation target.
411    pub fn release_focus_target(&mut self, target: FocusTarget) {
412        let context = self.focus_context();
413        self.focus.release(target, context);
414    }
415
416    /// Drop claims that are no longer valid for current state.
417    pub fn normalize_focus_stack(&mut self) {
418        let context = self.focus_context();
419        self.focus.normalize(context);
420    }
421
422    #[must_use]
423    fn focus_context(&self) -> FocusContext {
424        FocusContext::with_help(
425            self.show_todo_panel && !self.todos.is_empty(),
426            self.mention.is_some() || self.slash.is_some(),
427            !self.pending_permission_ids.is_empty(),
428            self.is_help_active(),
429        )
430    }
431}
432
433/// Single owner of all chat layout state: scroll, per-message heights, and prefix sums.
434///
435/// Consolidates state previously scattered across `App` (scroll fields, prefix sums),
436/// `ChatMessage` (`cached_visual_height`/`cached_visual_width`), and `BlockCache` (`wrapped_height`/`wrapped_width`).
437/// Per-block heights remain on `BlockCache` via `set_height()` / `height_at()`, but
438/// the viewport owns the validity width that governs whether those caches are considered
439/// current. On resize, `on_frame()` zeroes message heights and clears prefix sums,
440/// causing the next `update_visual_heights()` pass to re-measure every message
441/// using ground-truth `Paragraph::line_count()`.
442pub struct ChatViewport {
443    // --- Scroll ---
444    /// Rendered scroll offset (rounded from `scroll_pos`).
445    pub scroll_offset: usize,
446    /// Target scroll offset requested by user input or auto-scroll.
447    pub scroll_target: usize,
448    /// Smooth scroll position (fractional) for animation.
449    pub scroll_pos: f32,
450    /// Smoothed scrollbar thumb top row (fractional) for animation.
451    pub scrollbar_thumb_top: f32,
452    /// Smoothed scrollbar thumb height (fractional) for animation.
453    pub scrollbar_thumb_size: f32,
454    /// Whether to auto-scroll to bottom on new content.
455    pub auto_scroll: bool,
456
457    // --- Layout ---
458    /// Current terminal width. Set by `on_frame()` each render cycle.
459    pub width: u16,
460
461    // --- Per-message heights ---
462    /// Visual height (in terminal rows) of each message, indexed by message position.
463    /// Zeroed on resize; rebuilt by `measure_message_height()`.
464    pub message_heights: Vec<usize>,
465    /// Width at which `message_heights` was last computed.
466    pub message_heights_width: u16,
467
468    // --- Prefix sums ---
469    /// Cumulative heights: `height_prefix_sums[i]` = sum of heights `0..=i`.
470    /// Enables O(log n) binary search for first visible message and O(1) total height.
471    pub height_prefix_sums: Vec<usize>,
472    /// Width at which prefix sums were last computed.
473    pub prefix_sums_width: u16,
474}
475
476impl ChatViewport {
477    /// Create a new viewport with default scroll state (auto-scroll enabled).
478    #[must_use]
479    pub fn new() -> Self {
480        Self {
481            scroll_offset: 0,
482            scroll_target: 0,
483            scroll_pos: 0.0,
484            scrollbar_thumb_top: 0.0,
485            scrollbar_thumb_size: 0.0,
486            auto_scroll: true,
487            width: 0,
488            message_heights: Vec::new(),
489            message_heights_width: 0,
490            height_prefix_sums: Vec::new(),
491            prefix_sums_width: 0,
492        }
493    }
494
495    /// Called at top of each render frame. Detects width change and invalidates
496    /// all cached heights so they get re-measured at the new width.
497    pub fn on_frame(&mut self, width: u16) {
498        if self.width != 0 && self.width != width {
499            tracing::debug!(
500                "RESIZE: width {} -> {}, scroll_target={}, auto_scroll={}",
501                self.width,
502                width,
503                self.scroll_target,
504                self.auto_scroll
505            );
506            self.handle_resize();
507        }
508        self.width = width;
509    }
510
511    /// Invalidate height caches on terminal resize.
512    ///
513    /// Setting `message_heights_width = 0` forces `update_visual_heights()`
514    /// to re-measure every message at the new width using ground-truth
515    /// `line_count()`. Old message heights are kept as approximations so
516    /// `content_height` stays reasonable on the resize frame.
517    fn handle_resize(&mut self) {
518        self.message_heights_width = 0;
519        self.prefix_sums_width = 0;
520    }
521
522    // --- Per-message height ---
523
524    /// Get the cached visual height for message `idx`. Returns 0 if not yet computed.
525    #[must_use]
526    pub fn message_height(&self, idx: usize) -> usize {
527        self.message_heights.get(idx).copied().unwrap_or(0)
528    }
529
530    /// Set the visual height for message `idx`, growing the vec if needed.
531    ///
532    /// Does NOT update `message_heights_width` - the caller must call
533    /// `mark_heights_valid()` after the full re-measurement pass completes.
534    pub fn set_message_height(&mut self, idx: usize, h: usize) {
535        if idx >= self.message_heights.len() {
536            self.message_heights.resize(idx + 1, 0);
537        }
538        self.message_heights[idx] = h;
539    }
540
541    /// Mark all message heights as valid at the current width.
542    /// Call after `update_visual_heights()` finishes re-measuring.
543    pub fn mark_heights_valid(&mut self) {
544        self.message_heights_width = self.width;
545    }
546
547    // --- Prefix sums ---
548
549    /// Rebuild prefix sums from `message_heights`.
550    /// O(1) fast path when width unchanged and only the last message changed (streaming).
551    pub fn rebuild_prefix_sums(&mut self) {
552        let n = self.message_heights.len();
553        if self.prefix_sums_width == self.width && self.height_prefix_sums.len() == n && n > 0 {
554            // Streaming fast path: only last message's height changed.
555            let prev = if n >= 2 { self.height_prefix_sums[n - 2] } else { 0 };
556            self.height_prefix_sums[n - 1] = prev + self.message_heights[n - 1];
557            return;
558        }
559        // Full rebuild (resize or new messages added)
560        self.height_prefix_sums.clear();
561        self.height_prefix_sums.reserve(n);
562        let mut acc = 0;
563        for &h in &self.message_heights {
564            acc += h;
565            self.height_prefix_sums.push(acc);
566        }
567        self.prefix_sums_width = self.width;
568    }
569
570    /// Total height of all messages (O(1) via prefix sums).
571    #[must_use]
572    pub fn total_message_height(&self) -> usize {
573        self.height_prefix_sums.last().copied().unwrap_or(0)
574    }
575
576    /// Cumulative height of messages `0..idx` (O(1) via prefix sums).
577    #[must_use]
578    pub fn cumulative_height_before(&self, idx: usize) -> usize {
579        if idx == 0 { 0 } else { self.height_prefix_sums.get(idx - 1).copied().unwrap_or(0) }
580    }
581
582    /// Binary search for the first message whose cumulative range overlaps `scroll_offset`.
583    #[must_use]
584    pub fn find_first_visible(&self, scroll_offset: usize) -> usize {
585        if self.height_prefix_sums.is_empty() {
586            return 0;
587        }
588        self.height_prefix_sums
589            .partition_point(|&h| h <= scroll_offset)
590            .min(self.message_heights.len().saturating_sub(1))
591    }
592
593    // --- Scroll ---
594
595    /// Scroll up by `lines`. Disables auto-scroll.
596    pub fn scroll_up(&mut self, lines: usize) {
597        self.scroll_target = self.scroll_target.saturating_sub(lines);
598        self.auto_scroll = false;
599    }
600
601    /// Scroll down by `lines`. Auto-scroll re-engagement handled by render.
602    pub fn scroll_down(&mut self, lines: usize) {
603        self.scroll_target = self.scroll_target.saturating_add(lines);
604    }
605
606    /// Re-engage auto-scroll (stick to bottom).
607    pub fn engage_auto_scroll(&mut self) {
608        self.auto_scroll = true;
609    }
610}
611
612impl Default for ChatViewport {
613    fn default() -> Self {
614        Self::new()
615    }
616}
617
618#[derive(Debug, PartialEq, Eq)]
619pub enum AppStatus {
620    /// Waiting for ACP adapter connection (TUI shown, input disabled).
621    Connecting,
622    Ready,
623    Thinking,
624    Running,
625    Error,
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629pub enum SelectionKind {
630    Chat,
631    Input,
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub struct SelectionPoint {
636    pub row: usize,
637    pub col: usize,
638}
639
640#[derive(Debug, Clone, Copy, PartialEq, Eq)]
641pub struct SelectionState {
642    pub kind: SelectionKind,
643    pub start: SelectionPoint,
644    pub end: SelectionPoint,
645    pub dragging: bool,
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649pub struct ScrollbarDragState {
650    /// Row offset from thumb top where the initial click happened.
651    pub thumb_grab_offset: usize,
652}
653
654pub struct ChatMessage {
655    pub role: MessageRole,
656    pub blocks: Vec<MessageBlock>,
657}
658
659impl ChatMessage {
660    #[must_use]
661    pub fn welcome(model_name: &str, cwd: &str) -> Self {
662        Self {
663            role: MessageRole::Welcome,
664            blocks: vec![MessageBlock::Welcome(WelcomeBlock {
665                model_name: model_name.to_owned(),
666                cwd: cwd.to_owned(),
667                cache: BlockCache::default(),
668            })],
669        }
670    }
671}
672
673/// Cached result of `wrap_lines_and_cursor()` for the input field.
674/// Keyed by input version + width so the expensive wrapping runs at most once per frame.
675pub struct InputWrapCache {
676    pub version: u64,
677    pub content_width: u16,
678    pub wrapped_lines: Vec<ratatui::text::Line<'static>>,
679    pub cursor_pos: Option<(u16, u16)>,
680}
681
682/// Cached rendered lines for a block. Stores a version counter so the cache
683/// is only recomputed when the block content actually changes.
684///
685/// Fields are private - use `invalidate()` to mark stale, `is_stale()` to check,
686/// `get()` to read cached lines, and `store()` to populate.
687#[derive(Default)]
688pub struct BlockCache {
689    version: u64,
690    lines: Option<Vec<ratatui::text::Line<'static>>>,
691    /// Wrapped line count of the cached lines at `wrapped_width`.
692    /// Computed via `Paragraph::line_count(width)` on the same lines stored in `lines`.
693    wrapped_height: usize,
694    /// The viewport width used to compute `wrapped_height`.
695    wrapped_width: u16,
696}
697
698impl BlockCache {
699    /// Bump the version to invalidate cached lines and height.
700    pub fn invalidate(&mut self) {
701        self.version += 1;
702    }
703
704    /// Get a reference to the cached lines, if fresh.
705    #[must_use]
706    pub fn get(&self) -> Option<&Vec<ratatui::text::Line<'static>>> {
707        if self.version == 0 { self.lines.as_ref() } else { None }
708    }
709
710    /// Store freshly rendered lines, marking the cache as clean.
711    /// Height is set separately via `set_height()` after measurement.
712    pub fn store(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
713        self.lines = Some(lines);
714        self.version = 0;
715    }
716
717    /// Set the wrapped height for the cached lines at the given width.
718    /// Called by the viewport/chat layer after `Paragraph::line_count(width)`.
719    /// Separate from `store()` so height measurement is the viewport's job.
720    pub fn set_height(&mut self, height: usize, width: u16) {
721        self.wrapped_height = height;
722        self.wrapped_width = width;
723    }
724
725    /// Store lines and set height in one call.
726    /// Deprecated: prefer `store()` + `set_height()` to keep concerns separate.
727    pub fn store_with_height(
728        &mut self,
729        lines: Vec<ratatui::text::Line<'static>>,
730        height: usize,
731        width: u16,
732    ) {
733        self.store(lines);
734        self.set_height(height, width);
735    }
736
737    /// Get the cached wrapped height if cache is valid and was computed at the given width.
738    #[must_use]
739    pub fn height_at(&self, width: u16) -> Option<usize> {
740        if self.version == 0 && self.wrapped_width == width {
741            Some(self.wrapped_height)
742        } else {
743            None
744        }
745    }
746}
747
748/// Paragraph-level incremental markdown cache.
749///
750/// During streaming, text arrives in small chunks appended to a growing block.
751/// Instead of re-parsing the entire block every frame, we split on paragraph
752/// boundaries (`\n\n` outside code fences) and cache rendered lines for each
753/// completed paragraph. Only the in-progress tail paragraph gets re-rendered.
754#[derive(Default)]
755pub struct IncrementalMarkdown {
756    /// Completed paragraphs: `(source_text, rendered_lines)`.
757    paragraphs: Vec<(String, Vec<ratatui::text::Line<'static>>)>,
758    /// The in-progress tail paragraph being streamed into.
759    tail: String,
760    /// Whether we are currently inside a code fence.
761    in_code_fence: bool,
762    /// Byte offset into `tail` where the next scan should start.
763    /// Avoids re-scanning already-processed bytes (which would re-toggle fence state).
764    scan_offset: usize,
765}
766
767impl IncrementalMarkdown {
768    /// Create from existing full text (e.g. user messages, connection errors).
769    /// Treats the entire text as a single completed paragraph.
770    #[must_use]
771    pub fn from_complete(text: &str) -> Self {
772        Self { paragraphs: Vec::new(), tail: text.to_owned(), in_code_fence: false, scan_offset: 0 }
773    }
774
775    /// Append a streaming text chunk. Splits completed paragraphs off the tail.
776    pub fn append(&mut self, chunk: &str) {
777        // Back up scan_offset by 1 to catch \n\n spanning old/new boundary
778        self.scan_offset = self.scan_offset.min(self.tail.len().saturating_sub(1));
779        self.tail.push_str(chunk);
780        self.split_completed_paragraphs();
781    }
782
783    /// Get the full source text (all paragraphs + tail).
784    #[must_use]
785    pub fn full_text(&self) -> String {
786        let mut out = String::new();
787        for (src, _) in &self.paragraphs {
788            out.push_str(src);
789            out.push_str("\n\n");
790        }
791        out.push_str(&self.tail);
792        out
793    }
794
795    /// Render all lines: cached paragraphs + fresh tail.
796    /// `render_fn` converts a markdown source string into `Vec<Line>`.
797    /// Lazily renders any paragraph whose cache is still empty.
798    pub fn lines(
799        &mut self,
800        render_fn: &impl Fn(&str) -> Vec<ratatui::text::Line<'static>>,
801    ) -> Vec<ratatui::text::Line<'static>> {
802        let mut out = Vec::new();
803        for (src, lines) in &mut self.paragraphs {
804            if lines.is_empty() {
805                *lines = render_fn(src);
806            }
807            out.extend(lines.iter().cloned());
808        }
809        if !self.tail.is_empty() {
810            out.extend(render_fn(&self.tail));
811        }
812        out
813    }
814
815    /// Clear all cached paragraph renders (e.g. after toggle collapse).
816    /// Source text is preserved; re-rendering will rebuild caches.
817    pub fn invalidate_renders(&mut self) {
818        for (src, lines) in &mut self.paragraphs {
819            let _ = src; // keep source
820            lines.clear();
821        }
822    }
823
824    /// Re-render any paragraph whose cached lines are empty (after `invalidate_renders`).
825    pub fn ensure_rendered(
826        &mut self,
827        render_fn: &impl Fn(&str) -> Vec<ratatui::text::Line<'static>>,
828    ) {
829        for (src, lines) in &mut self.paragraphs {
830            if lines.is_empty() {
831                *lines = render_fn(src);
832            }
833        }
834    }
835
836    /// Split completed paragraphs off the tail.
837    /// A paragraph boundary is `\n\n` that is NOT inside a code fence.
838    fn split_completed_paragraphs(&mut self) {
839        loop {
840            let (boundary, fence_state, scanned_to) = self.scan_tail_for_boundary();
841            if let Some(offset) = boundary {
842                let completed = self.tail[..offset].to_owned();
843                self.tail = self.tail[offset + 2..].to_owned();
844                self.in_code_fence = fence_state;
845                // Reset scan_offset: the split removed bytes before the boundary,
846                // so scanned_to is no longer valid. Start from 0 for the new tail.
847                self.scan_offset = 0;
848                self.paragraphs.push((completed, Vec::new()));
849            } else {
850                // No more boundaries -- save the final fence state + scan position
851                self.in_code_fence = fence_state;
852                self.scan_offset = scanned_to;
853                break;
854            }
855        }
856    }
857
858    /// Scan `self.tail` starting from `self.scan_offset` for the first `\n\n`
859    /// outside a code fence.
860    /// Returns `(boundary, fence_state, scanned_to)`.
861    fn scan_tail_for_boundary(&self) -> (Option<usize>, bool, usize) {
862        let bytes = self.tail.as_bytes();
863        let mut in_fence = self.in_code_fence;
864        let mut i = self.scan_offset;
865        while i < bytes.len() {
866            // Check for code fence: line starting with ```
867            if (i == 0 || bytes[i - 1] == b'\n') && bytes[i..].starts_with(b"```") {
868                in_fence = !in_fence;
869            }
870            // Check for \n\n paragraph boundary (only outside code fences)
871            if !in_fence && i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
872                return (Some(i), in_fence, i);
873            }
874            i += 1;
875        }
876        (None, in_fence, i)
877    }
878}
879
880/// Ordered content block - text and tool calls interleaved as they arrive.
881pub enum MessageBlock {
882    Text(String, BlockCache, IncrementalMarkdown),
883    ToolCall(Box<ToolCallInfo>),
884    Welcome(WelcomeBlock),
885}
886
887#[derive(Debug)]
888pub enum MessageRole {
889    User,
890    Assistant,
891    System,
892    Welcome,
893}
894
895pub struct WelcomeBlock {
896    pub model_name: String,
897    pub cwd: String,
898    pub cache: BlockCache,
899}
900
901pub struct ToolCallInfo {
902    pub id: String,
903    pub title: String,
904    pub kind: acp::ToolKind,
905    pub status: acp::ToolCallStatus,
906    pub content: Vec<acp::ToolCallContent>,
907    pub collapsed: bool,
908    /// The actual Claude Code tool name from `meta.claudeCode.toolName`
909    /// (e.g. "Task", "Glob", "`mcp__acp__Read`", "`WebSearch`")
910    pub claude_tool_name: Option<String>,
911    /// Hidden tool calls are subagent children - not rendered directly.
912    pub hidden: bool,
913    /// Terminal ID if this is an Execute tool call with a running/completed terminal.
914    pub terminal_id: Option<String>,
915    /// The shell command that was executed (e.g. "echo hello && ls -la").
916    pub terminal_command: Option<String>,
917    /// Snapshot of terminal output, updated each frame while `InProgress`.
918    pub terminal_output: Option<String>,
919    /// Length of terminal buffer at last snapshot - used to skip O(n) re-snapshots
920    /// when the buffer hasn't grown.
921    pub terminal_output_len: usize,
922    /// Per-block render cache for this tool call.
923    pub cache: BlockCache,
924    /// Inline permission prompt - rendered inside this tool call block.
925    pub pending_permission: Option<InlinePermission>,
926}
927
928/// Permission state stored inline on a `ToolCallInfo`, so the permission
929/// controls render inside the tool call block (unified edit/permission UX).
930pub struct InlinePermission {
931    pub options: Vec<acp::PermissionOption>,
932    pub response_tx: tokio::sync::oneshot::Sender<acp::RequestPermissionResponse>,
933    pub selected_index: usize,
934    /// Whether this permission currently has keyboard focus.
935    /// When multiple permissions are pending, only the focused one
936    /// shows the selection arrow and accepts Left/Right/Enter input.
937    pub focused: bool,
938}
939
940#[cfg(test)]
941mod tests {
942    // =====
943    // TESTS: 26
944    // =====
945
946    use super::*;
947    use pretty_assertions::assert_eq;
948    use ratatui::style::{Color, Style};
949    use ratatui::text::{Line, Span};
950
951    // BlockCache
952
953    #[test]
954    fn cache_default_returns_none() {
955        let cache = BlockCache::default();
956        assert!(cache.get().is_none());
957    }
958
959    #[test]
960    fn cache_store_then_get() {
961        let mut cache = BlockCache::default();
962        cache.store(vec![Line::from("hello")]);
963        assert!(cache.get().is_some());
964        assert_eq!(cache.get().unwrap().len(), 1);
965    }
966
967    #[test]
968    fn cache_invalidate_then_get_returns_none() {
969        let mut cache = BlockCache::default();
970        cache.store(vec![Line::from("data")]);
971        cache.invalidate();
972        assert!(cache.get().is_none());
973    }
974
975    // BlockCache
976
977    #[test]
978    fn cache_store_after_invalidate() {
979        let mut cache = BlockCache::default();
980        cache.store(vec![Line::from("old")]);
981        cache.invalidate();
982        assert!(cache.get().is_none());
983        cache.store(vec![Line::from("new")]);
984        let lines = cache.get().unwrap();
985        assert_eq!(lines.len(), 1);
986        let span_content: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
987        assert_eq!(span_content, "new");
988    }
989
990    #[test]
991    fn cache_multiple_invalidations() {
992        let mut cache = BlockCache::default();
993        cache.store(vec![Line::from("data")]);
994        cache.invalidate();
995        cache.invalidate();
996        cache.invalidate();
997        assert!(cache.get().is_none());
998        cache.store(vec![Line::from("fresh")]);
999        assert!(cache.get().is_some());
1000    }
1001
1002    #[test]
1003    fn cache_store_empty_lines() {
1004        let mut cache = BlockCache::default();
1005        cache.store(Vec::new());
1006        let lines = cache.get().unwrap();
1007        assert!(lines.is_empty());
1008    }
1009
1010    /// Store twice without invalidating - second store overwrites first.
1011    #[test]
1012    fn cache_store_overwrite_without_invalidate() {
1013        let mut cache = BlockCache::default();
1014        cache.store(vec![Line::from("first")]);
1015        cache.store(vec![Line::from("second"), Line::from("line2")]);
1016        let lines = cache.get().unwrap();
1017        assert_eq!(lines.len(), 2);
1018        let content: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
1019        assert_eq!(content, "second");
1020    }
1021
1022    /// `get()` called twice returns consistent data.
1023    #[test]
1024    fn cache_get_twice_consistent() {
1025        let mut cache = BlockCache::default();
1026        cache.store(vec![Line::from("stable")]);
1027        let first = cache.get().unwrap().len();
1028        let second = cache.get().unwrap().len();
1029        assert_eq!(first, second);
1030    }
1031
1032    // BlockCache
1033
1034    #[test]
1035    fn cache_store_many_lines() {
1036        let mut cache = BlockCache::default();
1037        let lines: Vec<Line<'static>> =
1038            (0..1000).map(|i| Line::from(Span::raw(format!("line {i}")))).collect();
1039        cache.store(lines);
1040        assert_eq!(cache.get().unwrap().len(), 1000);
1041    }
1042
1043    #[test]
1044    fn cache_invalidate_without_store() {
1045        let mut cache = BlockCache::default();
1046        cache.invalidate();
1047        assert!(cache.get().is_none());
1048    }
1049
1050    #[test]
1051    fn cache_rapid_store_invalidate_cycle() {
1052        let mut cache = BlockCache::default();
1053        for i in 0..50 {
1054            cache.store(vec![Line::from(format!("v{i}"))]);
1055            assert!(cache.get().is_some());
1056            cache.invalidate();
1057            assert!(cache.get().is_none());
1058        }
1059        cache.store(vec![Line::from("final")]);
1060        assert!(cache.get().is_some());
1061    }
1062
1063    /// Store styled lines with multiple spans per line.
1064    #[test]
1065    fn cache_store_styled_lines() {
1066        let mut cache = BlockCache::default();
1067        let line = Line::from(vec![
1068            Span::styled("bold", Style::default().fg(Color::Red)),
1069            Span::raw(" normal "),
1070            Span::styled("blue", Style::default().fg(Color::Blue)),
1071        ]);
1072        cache.store(vec![line]);
1073        let lines = cache.get().unwrap();
1074        assert_eq!(lines[0].spans.len(), 3);
1075    }
1076
1077    /// Version counter after many invalidations - verify it doesn't
1078    /// accidentally wrap to 0 (which would make stale data appear fresh).
1079    /// With u64, 10K invalidations is nowhere near overflow.
1080    #[test]
1081    fn cache_version_no_false_fresh_after_many_invalidations() {
1082        let mut cache = BlockCache::default();
1083        cache.store(vec![Line::from("data")]);
1084        for _ in 0..10_000 {
1085            cache.invalidate();
1086        }
1087        // Cache was invalidated 10K times without re-storing - must be stale
1088        assert!(cache.get().is_none());
1089    }
1090
1091    /// Invalidate, store, invalidate, store - alternating pattern.
1092    #[test]
1093    fn cache_alternating_invalidate_store() {
1094        let mut cache = BlockCache::default();
1095        for i in 0..100 {
1096            cache.invalidate();
1097            assert!(cache.get().is_none(), "stale after invalidate at iter {i}");
1098            cache.store(vec![Line::from(format!("v{i}"))]);
1099            assert!(cache.get().is_some(), "fresh after store at iter {i}");
1100        }
1101    }
1102
1103    // BlockCache height
1104
1105    #[test]
1106    fn cache_height_default_returns_none() {
1107        let cache = BlockCache::default();
1108        assert!(cache.height_at(80).is_none());
1109    }
1110
1111    #[test]
1112    fn cache_store_with_height_then_height_at() {
1113        let mut cache = BlockCache::default();
1114        cache.store_with_height(vec![Line::from("hello")], 1, 80);
1115        assert_eq!(cache.height_at(80), Some(1));
1116        assert!(cache.get().is_some());
1117    }
1118
1119    #[test]
1120    fn cache_height_at_wrong_width_returns_none() {
1121        let mut cache = BlockCache::default();
1122        cache.store_with_height(vec![Line::from("hello")], 1, 80);
1123        assert!(cache.height_at(120).is_none());
1124    }
1125
1126    #[test]
1127    fn cache_height_invalidated_returns_none() {
1128        let mut cache = BlockCache::default();
1129        cache.store_with_height(vec![Line::from("hello")], 1, 80);
1130        cache.invalidate();
1131        assert!(cache.height_at(80).is_none());
1132    }
1133
1134    #[test]
1135    fn cache_store_without_height_has_no_height() {
1136        let mut cache = BlockCache::default();
1137        cache.store(vec![Line::from("hello")]);
1138        // store() without height leaves wrapped_width at 0
1139        assert!(cache.height_at(80).is_none());
1140    }
1141
1142    #[test]
1143    fn cache_store_with_height_overwrite() {
1144        let mut cache = BlockCache::default();
1145        cache.store_with_height(vec![Line::from("old")], 1, 80);
1146        cache.invalidate();
1147        cache.store_with_height(vec![Line::from("new long line")], 3, 120);
1148        assert_eq!(cache.height_at(120), Some(3));
1149        assert!(cache.height_at(80).is_none());
1150    }
1151
1152    // BlockCache set_height (separate from store)
1153
1154    #[test]
1155    fn cache_set_height_after_store() {
1156        let mut cache = BlockCache::default();
1157        cache.store(vec![Line::from("hello")]);
1158        assert!(cache.height_at(80).is_none()); // no height yet
1159        cache.set_height(1, 80);
1160        assert_eq!(cache.height_at(80), Some(1));
1161        assert!(cache.get().is_some()); // lines still valid
1162    }
1163
1164    #[test]
1165    fn cache_set_height_update_width() {
1166        let mut cache = BlockCache::default();
1167        cache.store(vec![Line::from("hello world")]);
1168        cache.set_height(1, 80);
1169        assert_eq!(cache.height_at(80), Some(1));
1170        // Re-measure at new width
1171        cache.set_height(2, 40);
1172        assert_eq!(cache.height_at(40), Some(2));
1173        assert!(cache.height_at(80).is_none()); // old width no longer valid
1174    }
1175
1176    #[test]
1177    fn cache_set_height_invalidate_clears_height() {
1178        let mut cache = BlockCache::default();
1179        cache.store(vec![Line::from("data")]);
1180        cache.set_height(3, 80);
1181        cache.invalidate();
1182        assert!(cache.height_at(80).is_none()); // version mismatch
1183    }
1184
1185    #[test]
1186    fn cache_set_height_on_invalidated_cache_returns_none() {
1187        let mut cache = BlockCache::default();
1188        cache.store(vec![Line::from("data")]);
1189        cache.invalidate(); // version != 0
1190        cache.set_height(5, 80);
1191        // height_at returns None because cache is stale (version != 0)
1192        assert!(cache.height_at(80).is_none());
1193    }
1194
1195    #[test]
1196    fn cache_store_then_set_height_matches_store_with_height() {
1197        let mut cache_a = BlockCache::default();
1198        cache_a.store(vec![Line::from("test")]);
1199        cache_a.set_height(2, 100);
1200
1201        let mut cache_b = BlockCache::default();
1202        cache_b.store_with_height(vec![Line::from("test")], 2, 100);
1203
1204        assert_eq!(cache_a.height_at(100), cache_b.height_at(100));
1205        assert_eq!(cache_a.get().unwrap().len(), cache_b.get().unwrap().len());
1206    }
1207
1208    // App tool_call_index
1209
1210    fn make_test_app() -> App {
1211        App::test_default()
1212    }
1213
1214    #[test]
1215    fn lookup_missing_returns_none() {
1216        let app = make_test_app();
1217        assert!(app.lookup_tool_call("nonexistent").is_none());
1218    }
1219
1220    #[test]
1221    fn index_and_lookup() {
1222        let mut app = make_test_app();
1223        app.index_tool_call("tc-123".into(), 2, 5);
1224        assert_eq!(app.lookup_tool_call("tc-123"), Some((2, 5)));
1225    }
1226
1227    // App tool_call_index
1228
1229    /// Index same ID twice - second write overwrites first.
1230    #[test]
1231    fn index_overwrite_existing() {
1232        let mut app = make_test_app();
1233        app.index_tool_call("tc-1".into(), 0, 0);
1234        app.index_tool_call("tc-1".into(), 5, 10);
1235        assert_eq!(app.lookup_tool_call("tc-1"), Some((5, 10)));
1236    }
1237
1238    /// Empty string as tool call ID.
1239    #[test]
1240    fn index_empty_string_id() {
1241        let mut app = make_test_app();
1242        app.index_tool_call(String::new(), 1, 2);
1243        assert_eq!(app.lookup_tool_call(""), Some((1, 2)));
1244    }
1245
1246    /// Stress: 1000 tool calls indexed and looked up.
1247    #[test]
1248    fn index_stress_1000_entries() {
1249        let mut app = make_test_app();
1250        for i in 0..1000 {
1251            app.index_tool_call(format!("tc-{i}"), i, i * 2);
1252        }
1253        // Spot check first, middle, last
1254        assert_eq!(app.lookup_tool_call("tc-0"), Some((0, 0)));
1255        assert_eq!(app.lookup_tool_call("tc-500"), Some((500, 1000)));
1256        assert_eq!(app.lookup_tool_call("tc-999"), Some((999, 1998)));
1257        // Non-existent still returns None
1258        assert!(app.lookup_tool_call("tc-1000").is_none());
1259    }
1260
1261    /// Unicode in tool call ID.
1262    #[test]
1263    fn index_unicode_id() {
1264        let mut app = make_test_app();
1265        app.index_tool_call("\u{1F600}-tool".into(), 3, 7);
1266        assert_eq!(app.lookup_tool_call("\u{1F600}-tool"), Some((3, 7)));
1267    }
1268
1269    // active_task_ids
1270
1271    #[test]
1272    fn active_task_insert_remove() {
1273        let mut app = make_test_app();
1274        app.insert_active_task("task-1".into());
1275        assert!(app.active_task_ids.contains("task-1"));
1276        app.remove_active_task("task-1");
1277        assert!(!app.active_task_ids.contains("task-1"));
1278    }
1279
1280    #[test]
1281    fn remove_nonexistent_task_is_noop() {
1282        let mut app = make_test_app();
1283        app.remove_active_task("does-not-exist");
1284        assert!(app.active_task_ids.is_empty());
1285    }
1286
1287    // active_task_ids
1288
1289    /// Insert same ID twice - set deduplicates; one remove clears it.
1290    #[test]
1291    fn active_task_insert_duplicate() {
1292        let mut app = make_test_app();
1293        app.insert_active_task("task-1".into());
1294        app.insert_active_task("task-1".into());
1295        assert_eq!(app.active_task_ids.len(), 1);
1296        app.remove_active_task("task-1");
1297        assert!(app.active_task_ids.is_empty());
1298    }
1299
1300    /// Insert many tasks, remove in different order.
1301    #[test]
1302    fn active_task_insert_many_remove_out_of_order() {
1303        let mut app = make_test_app();
1304        for i in 0..100 {
1305            app.insert_active_task(format!("task-{i}"));
1306        }
1307        assert_eq!(app.active_task_ids.len(), 100);
1308        // Remove in reverse order
1309        for i in (0..100).rev() {
1310            app.remove_active_task(&format!("task-{i}"));
1311        }
1312        assert!(app.active_task_ids.is_empty());
1313    }
1314
1315    /// Mixed insert/remove interleaving.
1316    #[test]
1317    fn active_task_interleaved_insert_remove() {
1318        let mut app = make_test_app();
1319        app.insert_active_task("a".into());
1320        app.insert_active_task("b".into());
1321        app.remove_active_task("a");
1322        app.insert_active_task("c".into());
1323        assert!(!app.active_task_ids.contains("a"));
1324        assert!(app.active_task_ids.contains("b"));
1325        assert!(app.active_task_ids.contains("c"));
1326        assert_eq!(app.active_task_ids.len(), 2);
1327    }
1328
1329    /// Remove from empty set multiple times - no panic.
1330    #[test]
1331    fn active_task_remove_from_empty_repeatedly() {
1332        let mut app = make_test_app();
1333        for i in 0..100 {
1334            app.remove_active_task(&format!("ghost-{i}"));
1335        }
1336        assert!(app.active_task_ids.is_empty());
1337    }
1338
1339    // IncrementalMarkdown
1340
1341    /// Simple render function for tests: wraps each line in a `Line`.
1342    fn test_render(src: &str) -> Vec<Line<'static>> {
1343        src.lines().map(|l| Line::from(l.to_owned())).collect()
1344    }
1345
1346    #[test]
1347    fn incr_default_empty() {
1348        let incr = IncrementalMarkdown::default();
1349        assert!(incr.full_text().is_empty());
1350    }
1351
1352    #[test]
1353    fn incr_from_complete() {
1354        let incr = IncrementalMarkdown::from_complete("hello world");
1355        assert_eq!(incr.full_text(), "hello world");
1356    }
1357
1358    #[test]
1359    fn incr_append_single_chunk() {
1360        let mut incr = IncrementalMarkdown::default();
1361        incr.append("hello");
1362        assert_eq!(incr.full_text(), "hello");
1363    }
1364
1365    #[test]
1366    fn incr_append_no_paragraph_break() {
1367        let mut incr = IncrementalMarkdown::default();
1368        incr.append("line1\nline2\nline3");
1369        assert_eq!(incr.paragraphs.len(), 0);
1370        assert_eq!(incr.tail, "line1\nline2\nline3");
1371    }
1372
1373    #[test]
1374    fn incr_append_splits_on_double_newline() {
1375        let mut incr = IncrementalMarkdown::default();
1376        incr.append("para1\n\npara2");
1377        assert_eq!(incr.paragraphs.len(), 1);
1378        assert_eq!(incr.paragraphs[0].0, "para1");
1379        assert_eq!(incr.tail, "para2");
1380    }
1381
1382    #[test]
1383    fn incr_append_multiple_paragraphs() {
1384        let mut incr = IncrementalMarkdown::default();
1385        incr.append("p1\n\np2\n\np3\n\np4");
1386        assert_eq!(incr.paragraphs.len(), 3);
1387        assert_eq!(incr.paragraphs[0].0, "p1");
1388        assert_eq!(incr.paragraphs[1].0, "p2");
1389        assert_eq!(incr.paragraphs[2].0, "p3");
1390        assert_eq!(incr.tail, "p4");
1391    }
1392
1393    #[test]
1394    fn incr_append_incremental_chunks() {
1395        let mut incr = IncrementalMarkdown::default();
1396        incr.append("hel");
1397        incr.append("lo\n");
1398        incr.append("\nworld");
1399        assert_eq!(incr.paragraphs.len(), 1);
1400        assert_eq!(incr.paragraphs[0].0, "hello");
1401        assert_eq!(incr.tail, "world");
1402    }
1403
1404    #[test]
1405    fn incr_code_fence_preserves_double_newlines() {
1406        let mut incr = IncrementalMarkdown::default();
1407        incr.append("before\n\n```\ncode\n\nmore code\n```\n\nafter");
1408        // "before" split off, then code fence block stays as one paragraph
1409        assert_eq!(incr.paragraphs.len(), 2);
1410        assert_eq!(incr.paragraphs[0].0, "before");
1411        assert_eq!(incr.paragraphs[1].0, "```\ncode\n\nmore code\n```");
1412        assert_eq!(incr.tail, "after");
1413    }
1414
1415    #[test]
1416    fn incr_code_fence_incremental() {
1417        let mut incr = IncrementalMarkdown::default();
1418        incr.append("text\n\n```\nfn main() {\n");
1419        assert_eq!(incr.paragraphs.len(), 1); // "text" split off
1420        assert!(incr.in_code_fence); // inside fence
1421        incr.append("    println!(\"hi\");\n\n}\n```\n\nafter");
1422        assert!(!incr.in_code_fence); // fence closed
1423        assert_eq!(incr.tail, "after");
1424    }
1425
1426    #[test]
1427    fn incr_full_text_reconstruction() {
1428        let mut incr = IncrementalMarkdown::default();
1429        incr.append("p1\n\np2\n\np3");
1430        assert_eq!(incr.full_text(), "p1\n\np2\n\np3");
1431    }
1432
1433    #[test]
1434    fn incr_lines_renders_all() {
1435        let mut incr = IncrementalMarkdown::default();
1436        incr.append("line1\n\nline2\n\nline3");
1437        let lines = incr.lines(&test_render);
1438        // 3 paragraphs total (2 completed + 1 tail), each has 1 line
1439        assert_eq!(lines.len(), 3);
1440    }
1441
1442    #[test]
1443    fn incr_lines_caches_paragraphs() {
1444        let mut incr = IncrementalMarkdown::default();
1445        incr.append("p1\n\np2\n\ntail");
1446        // First call renders all paragraphs
1447        let _ = incr.lines(&test_render);
1448        assert!(!incr.paragraphs[0].1.is_empty());
1449        assert!(!incr.paragraphs[1].1.is_empty());
1450        // Second call reuses cached paragraph renders
1451        let lines = incr.lines(&test_render);
1452        assert_eq!(lines.len(), 3);
1453    }
1454
1455    #[test]
1456    fn incr_ensure_rendered_fills_empty() {
1457        let mut incr = IncrementalMarkdown::default();
1458        incr.append("p1\n\np2\n\ntail");
1459        // Paragraphs have empty renders initially
1460        assert!(incr.paragraphs[0].1.is_empty());
1461        incr.ensure_rendered(&test_render);
1462        assert!(!incr.paragraphs[0].1.is_empty());
1463        assert!(!incr.paragraphs[1].1.is_empty());
1464    }
1465
1466    #[test]
1467    fn incr_invalidate_clears_renders() {
1468        let mut incr = IncrementalMarkdown::default();
1469        incr.append("p1\n\np2\n\ntail");
1470        incr.ensure_rendered(&test_render);
1471        assert!(!incr.paragraphs[0].1.is_empty());
1472        incr.invalidate_renders();
1473        assert!(incr.paragraphs[0].1.is_empty());
1474        assert!(incr.paragraphs[1].1.is_empty());
1475    }
1476
1477    #[test]
1478    fn incr_streaming_simulation() {
1479        // Simulate a realistic streaming scenario
1480        let mut incr = IncrementalMarkdown::default();
1481        let chunks = ["Here is ", "some text.\n", "\nNext para", "graph here.\n\n", "Final."];
1482        for chunk in chunks {
1483            incr.append(chunk);
1484        }
1485        assert_eq!(incr.paragraphs.len(), 2);
1486        assert_eq!(incr.paragraphs[0].0, "Here is some text.");
1487        assert_eq!(incr.paragraphs[1].0, "Next paragraph here.");
1488        assert_eq!(incr.tail, "Final.");
1489    }
1490
1491    #[test]
1492    fn incr_empty_paragraphs() {
1493        let mut incr = IncrementalMarkdown::default();
1494        incr.append("\n\n\n\n");
1495        // Two \n\n boundaries: empty string before first, empty between, remaining empty tail
1496        assert!(!incr.paragraphs.is_empty());
1497    }
1498
1499    // ChatViewport
1500
1501    #[test]
1502    fn viewport_new_defaults() {
1503        let vp = ChatViewport::new();
1504        assert_eq!(vp.scroll_offset, 0);
1505        assert_eq!(vp.scroll_target, 0);
1506        assert!(vp.auto_scroll);
1507        assert_eq!(vp.width, 0);
1508        assert!(vp.message_heights.is_empty());
1509        assert!(vp.height_prefix_sums.is_empty());
1510    }
1511
1512    #[test]
1513    fn viewport_on_frame_sets_width() {
1514        let mut vp = ChatViewport::new();
1515        vp.on_frame(80);
1516        assert_eq!(vp.width, 80);
1517    }
1518
1519    #[test]
1520    fn viewport_on_frame_resize_invalidates() {
1521        let mut vp = ChatViewport::new();
1522        vp.on_frame(80);
1523        vp.set_message_height(0, 10);
1524        vp.set_message_height(1, 20);
1525        vp.rebuild_prefix_sums();
1526
1527        // Resize: old heights are kept as approximations,
1528        // but width markers are invalidated so re-measurement happens.
1529        vp.on_frame(120);
1530        assert_eq!(vp.message_height(0), 10); // kept, not zeroed
1531        assert_eq!(vp.message_height(1), 20); // kept, not zeroed
1532        assert_eq!(vp.message_heights_width, 0); // forces re-measure
1533        assert_eq!(vp.prefix_sums_width, 0); // forces rebuild
1534    }
1535
1536    #[test]
1537    fn viewport_on_frame_same_width_no_invalidation() {
1538        let mut vp = ChatViewport::new();
1539        vp.on_frame(80);
1540        vp.set_message_height(0, 10);
1541        vp.on_frame(80); // same width
1542        assert_eq!(vp.message_height(0), 10); // not zeroed
1543    }
1544
1545    #[test]
1546    fn viewport_message_height_set_and_get() {
1547        let mut vp = ChatViewport::new();
1548        vp.on_frame(80);
1549        vp.set_message_height(0, 5);
1550        vp.set_message_height(1, 10);
1551        assert_eq!(vp.message_height(0), 5);
1552        assert_eq!(vp.message_height(1), 10);
1553        assert_eq!(vp.message_height(2), 0); // out of bounds
1554    }
1555
1556    #[test]
1557    fn viewport_message_height_grows_vec() {
1558        let mut vp = ChatViewport::new();
1559        vp.on_frame(80);
1560        vp.set_message_height(5, 42);
1561        assert_eq!(vp.message_heights.len(), 6);
1562        assert_eq!(vp.message_height(5), 42);
1563        assert_eq!(vp.message_height(3), 0); // gap filled with 0
1564    }
1565
1566    #[test]
1567    fn viewport_prefix_sums_basic() {
1568        let mut vp = ChatViewport::new();
1569        vp.on_frame(80);
1570        vp.set_message_height(0, 5);
1571        vp.set_message_height(1, 10);
1572        vp.set_message_height(2, 3);
1573        vp.rebuild_prefix_sums();
1574        assert_eq!(vp.total_message_height(), 18);
1575        assert_eq!(vp.cumulative_height_before(0), 0);
1576        assert_eq!(vp.cumulative_height_before(1), 5);
1577        assert_eq!(vp.cumulative_height_before(2), 15);
1578    }
1579
1580    #[test]
1581    fn viewport_prefix_sums_streaming_fast_path() {
1582        let mut vp = ChatViewport::new();
1583        vp.on_frame(80);
1584        vp.set_message_height(0, 5);
1585        vp.set_message_height(1, 10);
1586        vp.rebuild_prefix_sums();
1587        assert_eq!(vp.total_message_height(), 15);
1588
1589        // Simulate streaming: last message grows
1590        vp.set_message_height(1, 20);
1591        vp.rebuild_prefix_sums(); // should hit fast path
1592        assert_eq!(vp.total_message_height(), 25);
1593        assert_eq!(vp.cumulative_height_before(1), 5);
1594    }
1595
1596    #[test]
1597    fn viewport_find_first_visible() {
1598        let mut vp = ChatViewport::new();
1599        vp.on_frame(80);
1600        vp.set_message_height(0, 10);
1601        vp.set_message_height(1, 10);
1602        vp.set_message_height(2, 10);
1603        vp.rebuild_prefix_sums();
1604
1605        assert_eq!(vp.find_first_visible(0), 0);
1606        assert_eq!(vp.find_first_visible(10), 1);
1607        assert_eq!(vp.find_first_visible(15), 1);
1608        assert_eq!(vp.find_first_visible(20), 2);
1609    }
1610
1611    #[test]
1612    fn viewport_find_first_visible_handles_offsets_before_first_boundary() {
1613        let mut vp = ChatViewport::new();
1614        vp.on_frame(80);
1615        vp.set_message_height(0, 10);
1616        vp.set_message_height(1, 10);
1617        vp.rebuild_prefix_sums();
1618
1619        assert_eq!(vp.find_first_visible(0), 0);
1620        assert_eq!(vp.find_first_visible(5), 0);
1621        assert_eq!(vp.find_first_visible(15), 1);
1622    }
1623
1624    #[test]
1625    fn viewport_scroll_up_down() {
1626        let mut vp = ChatViewport::new();
1627        vp.scroll_target = 20;
1628        vp.auto_scroll = true;
1629
1630        vp.scroll_up(5);
1631        assert_eq!(vp.scroll_target, 15);
1632        assert!(!vp.auto_scroll); // disabled on manual scroll
1633
1634        vp.scroll_down(3);
1635        assert_eq!(vp.scroll_target, 18);
1636        assert!(!vp.auto_scroll); // not re-engaged by scroll_down
1637    }
1638
1639    #[test]
1640    fn viewport_scroll_up_saturates() {
1641        let mut vp = ChatViewport::new();
1642        vp.scroll_target = 2;
1643        vp.scroll_up(10);
1644        assert_eq!(vp.scroll_target, 0);
1645    }
1646
1647    #[test]
1648    fn viewport_engage_auto_scroll() {
1649        let mut vp = ChatViewport::new();
1650        vp.auto_scroll = false;
1651        vp.engage_auto_scroll();
1652        assert!(vp.auto_scroll);
1653    }
1654
1655    #[test]
1656    fn viewport_default_eq_new() {
1657        let a = ChatViewport::new();
1658        let b = ChatViewport::default();
1659        assert_eq!(a.width, b.width);
1660        assert_eq!(a.auto_scroll, b.auto_scroll);
1661        assert_eq!(a.message_heights.len(), b.message_heights.len());
1662    }
1663
1664    #[test]
1665    fn focus_owner_defaults_to_input() {
1666        let app = make_test_app();
1667        assert_eq!(app.focus_owner(), FocusOwner::Input);
1668    }
1669
1670    #[test]
1671    fn focus_owner_todo_when_panel_open_and_focused() {
1672        let mut app = make_test_app();
1673        app.todos.push(TodoItem {
1674            content: "Task".into(),
1675            status: TodoStatus::Pending,
1676            active_form: String::new(),
1677        });
1678        app.show_todo_panel = true;
1679        app.claim_focus_target(FocusTarget::TodoList);
1680        assert_eq!(app.focus_owner(), FocusOwner::TodoList);
1681    }
1682
1683    #[test]
1684    fn focus_owner_permission_overrides_todo() {
1685        let mut app = make_test_app();
1686        app.todos.push(TodoItem {
1687            content: "Task".into(),
1688            status: TodoStatus::Pending,
1689            active_form: String::new(),
1690        });
1691        app.show_todo_panel = true;
1692        app.claim_focus_target(FocusTarget::TodoList);
1693        app.pending_permission_ids.push("perm-1".into());
1694        app.claim_focus_target(FocusTarget::Permission);
1695        assert_eq!(app.focus_owner(), FocusOwner::Permission);
1696    }
1697
1698    #[test]
1699    fn focus_owner_mention_overrides_permission_and_todo() {
1700        let mut app = make_test_app();
1701        app.todos.push(TodoItem {
1702            content: "Task".into(),
1703            status: TodoStatus::Pending,
1704            active_form: String::new(),
1705        });
1706        app.show_todo_panel = true;
1707        app.claim_focus_target(FocusTarget::TodoList);
1708        app.pending_permission_ids.push("perm-1".into());
1709        app.claim_focus_target(FocusTarget::Permission);
1710        app.mention = Some(mention::MentionState {
1711            trigger_row: 0,
1712            trigger_col: 0,
1713            query: String::new(),
1714            candidates: Vec::new(),
1715            dialog: super::super::dialog::DialogState::default(),
1716        });
1717        app.claim_focus_target(FocusTarget::Mention);
1718        assert_eq!(app.focus_owner(), FocusOwner::Mention);
1719    }
1720
1721    #[test]
1722    fn focus_owner_falls_back_to_input_when_claim_is_not_available() {
1723        let mut app = make_test_app();
1724        app.claim_focus_target(FocusTarget::TodoList);
1725        assert_eq!(app.focus_owner(), FocusOwner::Input);
1726    }
1727
1728    #[test]
1729    fn claim_and_release_focus_target() {
1730        let mut app = make_test_app();
1731        app.todos.push(TodoItem {
1732            content: "Task".into(),
1733            status: TodoStatus::Pending,
1734            active_form: String::new(),
1735        });
1736        app.show_todo_panel = true;
1737        app.claim_focus_target(FocusTarget::TodoList);
1738        assert_eq!(app.focus_owner(), FocusOwner::TodoList);
1739        app.release_focus_target(FocusTarget::TodoList);
1740        assert_eq!(app.focus_owner(), FocusOwner::Input);
1741    }
1742
1743    #[test]
1744    fn latest_claim_wins_across_equal_targets() {
1745        let mut app = make_test_app();
1746        app.todos.push(TodoItem {
1747            content: "Task".into(),
1748            status: TodoStatus::Pending,
1749            active_form: String::new(),
1750        });
1751        app.show_todo_panel = true;
1752        app.mention = Some(mention::MentionState {
1753            trigger_row: 0,
1754            trigger_col: 0,
1755            query: String::new(),
1756            candidates: Vec::new(),
1757            dialog: super::super::dialog::DialogState::default(),
1758        });
1759        app.pending_permission_ids.push("perm-1".into());
1760
1761        app.claim_focus_target(FocusTarget::TodoList);
1762        assert_eq!(app.focus_owner(), FocusOwner::TodoList);
1763
1764        app.claim_focus_target(FocusTarget::Permission);
1765        assert_eq!(app.focus_owner(), FocusOwner::Permission);
1766
1767        app.claim_focus_target(FocusTarget::Mention);
1768        assert_eq!(app.focus_owner(), FocusOwner::Mention);
1769
1770        app.release_focus_target(FocusTarget::Mention);
1771        assert_eq!(app.focus_owner(), FocusOwner::Permission);
1772    }
1773}