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