1use 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
49pub struct LoginHint {
52 pub method_name: String,
53 pub method_description: String,
54}
55
56#[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 pub viewport: ChatViewport,
76 pub input: InputState,
77 pub status: AppStatus,
78 pub should_quit: bool,
79 pub session_id: Option<acp::SessionId>,
80 pub conn: Option<Rc<acp::ClientSideConnection>>,
82 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 pub login_hint: Option<LoginHint>,
92 pub pending_compact_clear: bool,
95 pub help_view: HelpView,
97 pub pending_permission_ids: Vec<String>,
101 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 pub tools_collapsed: bool,
110 pub active_task_ids: HashSet<String>,
113 pub terminals: crate::acp::client::TerminalMap,
115 pub force_redraw: bool,
117 pub tool_call_index: HashMap<String, (usize, usize)>,
120 pub todos: Vec<TodoItem>,
122 pub show_header: bool,
125 pub show_todo_panel: bool,
128 pub todo_scroll: usize,
130 pub todo_selected: usize,
132 pub focus: FocusManager,
134 pub available_commands: Vec<acp::AvailableCommand>,
136 pub cached_frame_area: ratatui::layout::Rect,
138 pub selection: Option<SelectionState>,
140 pub scrollbar_drag: Option<ScrollbarDragState>,
142 pub rendered_chat_lines: Vec<String>,
144 pub rendered_chat_area: ratatui::layout::Rect,
146 pub rendered_input_lines: Vec<String>,
148 pub rendered_input_area: ratatui::layout::Rect,
150 pub mention: Option<mention::MentionState>,
152 pub slash: Option<slash::SlashState>,
154 pub pending_submit: bool,
159 pub drain_key_count: usize,
163 pub paste_burst: super::paste_burst::PasteBurstDetector,
166 pub pending_paste_text: String,
170 pub file_cache: Option<Vec<mention::FileCandidate>>,
172 pub cached_todo_compact: Option<ratatui::text::Line<'static>>,
174 pub git_branch: Option<String>,
176 pub cached_header_line: Option<ratatui::text::Line<'static>>,
178 pub cached_footer_line: Option<ratatui::text::Line<'static>>,
180 pub update_check_hint: Option<String>,
182
183 pub terminal_tool_calls: Vec<(String, usize, usize)>,
186 pub needs_redraw: bool,
188 pub perf: Option<crate::perf::PerfLogger>,
192 pub fps_ema: Option<f32>,
194 pub last_frame_at: Option<Instant>,
196}
197
198impl App {
199 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 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 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 pub fn insert_active_task(&mut self, id: String) {
250 self.active_task_ids.insert(id);
251 }
252
253 pub fn remove_active_task(&mut self, id: &str) {
255 self.active_task_ids.remove(id);
256 }
257
258 #[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 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 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 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 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 #[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 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 #[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 pub fn claim_focus_target(&mut self, target: FocusTarget) {
429 let context = self.focus_context();
430 self.focus.claim(target, context);
431 }
432
433 pub fn release_focus_target(&mut self, target: FocusTarget) {
435 let context = self.focus_context();
436 self.focus.release(target, context);
437 }
438
439 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
456pub struct ChatViewport {
466 pub scroll_offset: usize,
469 pub scroll_target: usize,
471 pub scroll_pos: f32,
473 pub scrollbar_thumb_top: f32,
475 pub scrollbar_thumb_size: f32,
477 pub auto_scroll: bool,
479
480 pub width: u16,
483
484 pub message_heights: Vec<usize>,
488 pub message_heights_width: u16,
490 pub dirty_from: Option<usize>,
492
493 pub height_prefix_sums: Vec<usize>,
497 pub prefix_sums_width: u16,
499}
500
501impl ChatViewport {
502 #[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 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 fn handle_resize(&mut self) {
544 self.message_heights_width = 0;
545 self.prefix_sums_width = 0;
546 }
547
548 #[must_use]
552 pub fn message_height(&self, idx: usize) -> usize {
553 self.message_heights.get(idx).copied().unwrap_or(0)
554 }
555
556 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 pub fn mark_heights_valid(&mut self) {
570 self.message_heights_width = self.width;
571 self.dirty_from = None;
572 }
573
574 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 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 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 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 #[must_use]
604 pub fn total_message_height(&self) -> usize {
605 self.height_prefix_sums.last().copied().unwrap_or(0)
606 }
607
608 #[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 #[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 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 pub fn scroll_down(&mut self, lines: usize) {
635 self.scroll_target = self.scroll_target.saturating_add(lines);
636 }
637
638 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 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 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#[derive(Default)]
711pub struct BlockCache {
712 version: u64,
713 lines: Option<Vec<ratatui::text::Line<'static>>>,
714 wrapped_height: usize,
717 wrapped_width: u16,
719}
720
721impl BlockCache {
722 pub fn invalidate(&mut self) {
724 self.version += 1;
725 }
726
727 #[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 pub fn store(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
736 self.lines = Some(lines);
737 self.version = 0;
738 }
739
740 pub fn set_height(&mut self, height: usize, width: u16) {
744 self.wrapped_height = height;
745 self.wrapped_width = width;
746 }
747
748 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 #[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#[derive(Default)]
778pub struct IncrementalMarkdown {
779 paragraphs: Vec<(String, Vec<ratatui::text::Line<'static>>)>,
781 tail: String,
783 in_code_fence: bool,
785 scan_offset: usize,
788}
789
790impl IncrementalMarkdown {
791 #[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 pub fn append(&mut self, chunk: &str) {
800 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 #[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 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 pub fn invalidate_renders(&mut self) {
841 for (src, lines) in &mut self.paragraphs {
842 let _ = src; lines.clear();
844 }
845 }
846
847 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 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 self.scan_offset = 0;
871 self.paragraphs.push((completed, Vec::new()));
872 } else {
873 self.in_code_fence = fence_state;
875 self.scan_offset = scanned_to;
876 break;
877 }
878 }
879 }
880
881 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 if (i == 0 || bytes[i - 1] == b'\n') && bytes[i..].starts_with(b"```") {
891 in_fence = !in_fence;
892 }
893 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
903pub 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 pub claude_tool_name: Option<String>,
934 pub hidden: bool,
936 pub terminal_id: Option<String>,
938 pub terminal_command: Option<String>,
940 pub terminal_output: Option<String>,
942 pub terminal_output_len: usize,
945 pub cache: BlockCache,
947 pub pending_permission: Option<InlinePermission>,
949}
950
951pub struct InlinePermission {
954 pub options: Vec<acp::PermissionOption>,
955 pub response_tx: tokio::sync::oneshot::Sender<acp::RequestPermissionResponse>,
956 pub selected_index: usize,
957 pub focused: bool,
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
970 use pretty_assertions::assert_eq;
971 use ratatui::style::{Color, Style};
972 use ratatui::text::{Line, Span};
973
974 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert!(cache.get().is_none());
1112 }
1113
1114 #[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 #[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 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 #[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()); cache.set_height(1, 80);
1183 assert_eq!(cache.height_at(80), Some(1));
1184 assert!(cache.get().is_some()); }
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 cache.set_height(2, 40);
1195 assert_eq!(cache.height_at(40), Some(2));
1196 assert!(cache.height_at(80).is_none()); }
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()); }
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(); cache.set_height(5, 80);
1214 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 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 #[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 #[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 #[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 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 assert!(app.lookup_tool_call("tc-1000").is_none());
1282 }
1283
1284 #[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 #[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 #[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 #[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 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 #[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 #[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 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 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); assert!(incr.in_code_fence); incr.append(" println!(\"hi\");\n\n}\n```\n\nafter");
1445 assert!(!incr.in_code_fence); 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 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 let _ = incr.lines(&test_render);
1471 assert!(!incr.paragraphs[0].1.is_empty());
1472 assert!(!incr.paragraphs[1].1.is_empty());
1473 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 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 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 assert!(!incr.paragraphs.is_empty());
1520 }
1521
1522 #[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 vp.on_frame(120);
1554 assert_eq!(vp.message_height(0), 10); assert_eq!(vp.message_height(1), 20); assert_eq!(vp.message_heights_width, 0); assert_eq!(vp.prefix_sums_width, 0); }
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); assert_eq!(vp.message_height(0), 10); }
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); }
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); }
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 vp.set_message_height(1, 20);
1634 vp.rebuild_prefix_sums(); 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); vp.scroll_down(3);
1678 assert_eq!(vp.scroll_target, 18);
1679 assert!(!vp.auto_scroll); }
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}