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 input_wrap_cache: Option<InputWrapCache>,
174 pub cached_todo_compact: Option<ratatui::text::Line<'static>>,
176 pub git_branch: Option<String>,
178 pub cached_header_line: Option<ratatui::text::Line<'static>>,
180 pub cached_footer_line: Option<ratatui::text::Line<'static>>,
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.viewport.message_heights_width = 0;
227 self.viewport.prefix_sums_width = 0;
228 }
229
230 pub fn update_welcome_model_if_pristine(&mut self) {
232 if self.messages.len() != 1 {
233 return;
234 }
235 let Some(first) = self.messages.first_mut() else {
236 return;
237 };
238 if !matches!(first.role, MessageRole::Welcome) {
239 return;
240 }
241 let Some(MessageBlock::Welcome(welcome)) = first.blocks.first_mut() else {
242 return;
243 };
244 welcome.model_name.clone_from(&self.model_name);
245 welcome.cache.invalidate();
246 self.viewport.message_heights_width = 0;
247 self.viewport.prefix_sums_width = 0;
248 }
249
250 pub fn insert_active_task(&mut self, id: String) {
252 self.active_task_ids.insert(id);
253 }
254
255 pub fn remove_active_task(&mut self, id: &str) {
257 self.active_task_ids.remove(id);
258 }
259
260 #[must_use]
262 pub fn lookup_tool_call(&self, id: &str) -> Option<(usize, usize)> {
263 self.tool_call_index.get(id).copied()
264 }
265
266 pub fn index_tool_call(&mut self, id: String, msg_idx: usize, block_idx: usize) {
268 self.tool_call_index.insert(id, (msg_idx, block_idx));
269 }
270
271 pub fn finalize_in_progress_tool_calls(&mut self, new_status: acp::ToolCallStatus) -> usize {
274 let mut changed = 0usize;
275 let mut cleared_permission = false;
276
277 for msg in &mut self.messages {
278 for block in &mut msg.blocks {
279 if let MessageBlock::ToolCall(tc) = block {
280 let tc = tc.as_mut();
281 if matches!(
282 tc.status,
283 acp::ToolCallStatus::InProgress | acp::ToolCallStatus::Pending
284 ) {
285 tc.status = new_status;
286 tc.cache.invalidate();
287 if tc.pending_permission.take().is_some() {
288 cleared_permission = true;
289 }
290 changed += 1;
291 }
292 }
293 }
294 }
295
296 if changed > 0 || cleared_permission {
297 self.pending_permission_ids.clear();
298 self.release_focus_target(FocusTarget::Permission);
299 }
300
301 changed
302 }
303
304 #[doc(hidden)]
307 #[must_use]
308 pub fn test_default() -> Self {
309 let (tx, rx) = mpsc::unbounded_channel();
310 Self {
311 messages: Vec::new(),
312 viewport: ChatViewport::new(),
313 input: InputState::new(),
314 status: AppStatus::Ready,
315 should_quit: false,
316 session_id: None,
317 conn: None,
318 adapter_child: None,
319 model_name: "test-model".into(),
320 cwd: "/test".into(),
321 cwd_raw: "/test".into(),
322 files_accessed: 0,
323 mode: None,
324 login_hint: None,
325 pending_compact_clear: false,
326 help_view: HelpView::Keys,
327 pending_permission_ids: Vec::new(),
328 cancelled_turn_pending_hint: false,
329 event_tx: tx,
330 event_rx: rx,
331 spinner_frame: 0,
332 tools_collapsed: false,
333 active_task_ids: HashSet::default(),
334 terminals: std::rc::Rc::default(),
335 force_redraw: false,
336 tool_call_index: HashMap::default(),
337 todos: Vec::new(),
338 show_header: true,
339 show_todo_panel: false,
340 todo_scroll: 0,
341 todo_selected: 0,
342 focus: FocusManager::default(),
343 available_commands: Vec::new(),
344 cached_frame_area: ratatui::layout::Rect::default(),
345 selection: None,
346 scrollbar_drag: None,
347 rendered_chat_lines: Vec::new(),
348 rendered_chat_area: ratatui::layout::Rect::default(),
349 rendered_input_lines: Vec::new(),
350 rendered_input_area: ratatui::layout::Rect::default(),
351 mention: None,
352 slash: None,
353 pending_submit: false,
354 drain_key_count: 0,
355 paste_burst: super::paste_burst::PasteBurstDetector::new(),
356 pending_paste_text: String::new(),
357 file_cache: None,
358 input_wrap_cache: None,
359 cached_todo_compact: None,
360 git_branch: None,
361 cached_header_line: None,
362 cached_footer_line: None,
363 terminal_tool_calls: Vec::new(),
364 needs_redraw: true,
365 perf: None,
366 fps_ema: None,
367 last_frame_at: None,
368 }
369 }
370
371 pub fn refresh_git_branch(&mut self) {
373 let new_branch = std::process::Command::new("git")
374 .args(["branch", "--show-current"])
375 .current_dir(&self.cwd_raw)
376 .output()
377 .ok()
378 .and_then(|o| {
379 if o.status.success() {
380 let s = String::from_utf8_lossy(&o.stdout).trim().to_owned();
381 if s.is_empty() { None } else { Some(s) }
382 } else {
383 None
384 }
385 });
386 if new_branch != self.git_branch {
387 self.git_branch = new_branch;
388 self.cached_header_line = None;
389 }
390 }
391
392 #[must_use]
394 pub fn focus_owner(&self) -> FocusOwner {
395 self.focus.owner(self.focus_context())
396 }
397
398 #[must_use]
399 pub fn is_help_active(&self) -> bool {
400 self.input.text().trim() == "?"
401 }
402
403 pub fn claim_focus_target(&mut self, target: FocusTarget) {
406 let context = self.focus_context();
407 self.focus.claim(target, context);
408 }
409
410 pub fn release_focus_target(&mut self, target: FocusTarget) {
412 let context = self.focus_context();
413 self.focus.release(target, context);
414 }
415
416 pub fn normalize_focus_stack(&mut self) {
418 let context = self.focus_context();
419 self.focus.normalize(context);
420 }
421
422 #[must_use]
423 fn focus_context(&self) -> FocusContext {
424 FocusContext::with_help(
425 self.show_todo_panel && !self.todos.is_empty(),
426 self.mention.is_some() || self.slash.is_some(),
427 !self.pending_permission_ids.is_empty(),
428 self.is_help_active(),
429 )
430 }
431}
432
433pub struct ChatViewport {
443 pub scroll_offset: usize,
446 pub scroll_target: usize,
448 pub scroll_pos: f32,
450 pub scrollbar_thumb_top: f32,
452 pub scrollbar_thumb_size: f32,
454 pub auto_scroll: bool,
456
457 pub width: u16,
460
461 pub message_heights: Vec<usize>,
465 pub message_heights_width: u16,
467
468 pub height_prefix_sums: Vec<usize>,
472 pub prefix_sums_width: u16,
474}
475
476impl ChatViewport {
477 #[must_use]
479 pub fn new() -> Self {
480 Self {
481 scroll_offset: 0,
482 scroll_target: 0,
483 scroll_pos: 0.0,
484 scrollbar_thumb_top: 0.0,
485 scrollbar_thumb_size: 0.0,
486 auto_scroll: true,
487 width: 0,
488 message_heights: Vec::new(),
489 message_heights_width: 0,
490 height_prefix_sums: Vec::new(),
491 prefix_sums_width: 0,
492 }
493 }
494
495 pub fn on_frame(&mut self, width: u16) {
498 if self.width != 0 && self.width != width {
499 tracing::debug!(
500 "RESIZE: width {} -> {}, scroll_target={}, auto_scroll={}",
501 self.width,
502 width,
503 self.scroll_target,
504 self.auto_scroll
505 );
506 self.handle_resize();
507 }
508 self.width = width;
509 }
510
511 fn handle_resize(&mut self) {
518 self.message_heights_width = 0;
519 self.prefix_sums_width = 0;
520 }
521
522 #[must_use]
526 pub fn message_height(&self, idx: usize) -> usize {
527 self.message_heights.get(idx).copied().unwrap_or(0)
528 }
529
530 pub fn set_message_height(&mut self, idx: usize, h: usize) {
535 if idx >= self.message_heights.len() {
536 self.message_heights.resize(idx + 1, 0);
537 }
538 self.message_heights[idx] = h;
539 }
540
541 pub fn mark_heights_valid(&mut self) {
544 self.message_heights_width = self.width;
545 }
546
547 pub fn rebuild_prefix_sums(&mut self) {
552 let n = self.message_heights.len();
553 if self.prefix_sums_width == self.width && self.height_prefix_sums.len() == n && n > 0 {
554 let prev = if n >= 2 { self.height_prefix_sums[n - 2] } else { 0 };
556 self.height_prefix_sums[n - 1] = prev + self.message_heights[n - 1];
557 return;
558 }
559 self.height_prefix_sums.clear();
561 self.height_prefix_sums.reserve(n);
562 let mut acc = 0;
563 for &h in &self.message_heights {
564 acc += h;
565 self.height_prefix_sums.push(acc);
566 }
567 self.prefix_sums_width = self.width;
568 }
569
570 #[must_use]
572 pub fn total_message_height(&self) -> usize {
573 self.height_prefix_sums.last().copied().unwrap_or(0)
574 }
575
576 #[must_use]
578 pub fn cumulative_height_before(&self, idx: usize) -> usize {
579 if idx == 0 { 0 } else { self.height_prefix_sums.get(idx - 1).copied().unwrap_or(0) }
580 }
581
582 #[must_use]
584 pub fn find_first_visible(&self, scroll_offset: usize) -> usize {
585 if self.height_prefix_sums.is_empty() {
586 return 0;
587 }
588 self.height_prefix_sums
589 .partition_point(|&h| h <= scroll_offset)
590 .min(self.message_heights.len().saturating_sub(1))
591 }
592
593 pub fn scroll_up(&mut self, lines: usize) {
597 self.scroll_target = self.scroll_target.saturating_sub(lines);
598 self.auto_scroll = false;
599 }
600
601 pub fn scroll_down(&mut self, lines: usize) {
603 self.scroll_target = self.scroll_target.saturating_add(lines);
604 }
605
606 pub fn engage_auto_scroll(&mut self) {
608 self.auto_scroll = true;
609 }
610}
611
612impl Default for ChatViewport {
613 fn default() -> Self {
614 Self::new()
615 }
616}
617
618#[derive(Debug, PartialEq, Eq)]
619pub enum AppStatus {
620 Connecting,
622 Ready,
623 Thinking,
624 Running,
625 Error,
626}
627
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629pub enum SelectionKind {
630 Chat,
631 Input,
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub struct SelectionPoint {
636 pub row: usize,
637 pub col: usize,
638}
639
640#[derive(Debug, Clone, Copy, PartialEq, Eq)]
641pub struct SelectionState {
642 pub kind: SelectionKind,
643 pub start: SelectionPoint,
644 pub end: SelectionPoint,
645 pub dragging: bool,
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649pub struct ScrollbarDragState {
650 pub thumb_grab_offset: usize,
652}
653
654pub struct ChatMessage {
655 pub role: MessageRole,
656 pub blocks: Vec<MessageBlock>,
657}
658
659impl ChatMessage {
660 #[must_use]
661 pub fn welcome(model_name: &str, cwd: &str) -> Self {
662 Self {
663 role: MessageRole::Welcome,
664 blocks: vec![MessageBlock::Welcome(WelcomeBlock {
665 model_name: model_name.to_owned(),
666 cwd: cwd.to_owned(),
667 cache: BlockCache::default(),
668 })],
669 }
670 }
671}
672
673pub struct InputWrapCache {
676 pub version: u64,
677 pub content_width: u16,
678 pub wrapped_lines: Vec<ratatui::text::Line<'static>>,
679 pub cursor_pos: Option<(u16, u16)>,
680}
681
682#[derive(Default)]
688pub struct BlockCache {
689 version: u64,
690 lines: Option<Vec<ratatui::text::Line<'static>>>,
691 wrapped_height: usize,
694 wrapped_width: u16,
696}
697
698impl BlockCache {
699 pub fn invalidate(&mut self) {
701 self.version += 1;
702 }
703
704 #[must_use]
706 pub fn get(&self) -> Option<&Vec<ratatui::text::Line<'static>>> {
707 if self.version == 0 { self.lines.as_ref() } else { None }
708 }
709
710 pub fn store(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
713 self.lines = Some(lines);
714 self.version = 0;
715 }
716
717 pub fn set_height(&mut self, height: usize, width: u16) {
721 self.wrapped_height = height;
722 self.wrapped_width = width;
723 }
724
725 pub fn store_with_height(
728 &mut self,
729 lines: Vec<ratatui::text::Line<'static>>,
730 height: usize,
731 width: u16,
732 ) {
733 self.store(lines);
734 self.set_height(height, width);
735 }
736
737 #[must_use]
739 pub fn height_at(&self, width: u16) -> Option<usize> {
740 if self.version == 0 && self.wrapped_width == width {
741 Some(self.wrapped_height)
742 } else {
743 None
744 }
745 }
746}
747
748#[derive(Default)]
755pub struct IncrementalMarkdown {
756 paragraphs: Vec<(String, Vec<ratatui::text::Line<'static>>)>,
758 tail: String,
760 in_code_fence: bool,
762 scan_offset: usize,
765}
766
767impl IncrementalMarkdown {
768 #[must_use]
771 pub fn from_complete(text: &str) -> Self {
772 Self { paragraphs: Vec::new(), tail: text.to_owned(), in_code_fence: false, scan_offset: 0 }
773 }
774
775 pub fn append(&mut self, chunk: &str) {
777 self.scan_offset = self.scan_offset.min(self.tail.len().saturating_sub(1));
779 self.tail.push_str(chunk);
780 self.split_completed_paragraphs();
781 }
782
783 #[must_use]
785 pub fn full_text(&self) -> String {
786 let mut out = String::new();
787 for (src, _) in &self.paragraphs {
788 out.push_str(src);
789 out.push_str("\n\n");
790 }
791 out.push_str(&self.tail);
792 out
793 }
794
795 pub fn lines(
799 &mut self,
800 render_fn: &impl Fn(&str) -> Vec<ratatui::text::Line<'static>>,
801 ) -> Vec<ratatui::text::Line<'static>> {
802 let mut out = Vec::new();
803 for (src, lines) in &mut self.paragraphs {
804 if lines.is_empty() {
805 *lines = render_fn(src);
806 }
807 out.extend(lines.iter().cloned());
808 }
809 if !self.tail.is_empty() {
810 out.extend(render_fn(&self.tail));
811 }
812 out
813 }
814
815 pub fn invalidate_renders(&mut self) {
818 for (src, lines) in &mut self.paragraphs {
819 let _ = src; lines.clear();
821 }
822 }
823
824 pub fn ensure_rendered(
826 &mut self,
827 render_fn: &impl Fn(&str) -> Vec<ratatui::text::Line<'static>>,
828 ) {
829 for (src, lines) in &mut self.paragraphs {
830 if lines.is_empty() {
831 *lines = render_fn(src);
832 }
833 }
834 }
835
836 fn split_completed_paragraphs(&mut self) {
839 loop {
840 let (boundary, fence_state, scanned_to) = self.scan_tail_for_boundary();
841 if let Some(offset) = boundary {
842 let completed = self.tail[..offset].to_owned();
843 self.tail = self.tail[offset + 2..].to_owned();
844 self.in_code_fence = fence_state;
845 self.scan_offset = 0;
848 self.paragraphs.push((completed, Vec::new()));
849 } else {
850 self.in_code_fence = fence_state;
852 self.scan_offset = scanned_to;
853 break;
854 }
855 }
856 }
857
858 fn scan_tail_for_boundary(&self) -> (Option<usize>, bool, usize) {
862 let bytes = self.tail.as_bytes();
863 let mut in_fence = self.in_code_fence;
864 let mut i = self.scan_offset;
865 while i < bytes.len() {
866 if (i == 0 || bytes[i - 1] == b'\n') && bytes[i..].starts_with(b"```") {
868 in_fence = !in_fence;
869 }
870 if !in_fence && i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
872 return (Some(i), in_fence, i);
873 }
874 i += 1;
875 }
876 (None, in_fence, i)
877 }
878}
879
880pub enum MessageBlock {
882 Text(String, BlockCache, IncrementalMarkdown),
883 ToolCall(Box<ToolCallInfo>),
884 Welcome(WelcomeBlock),
885}
886
887#[derive(Debug)]
888pub enum MessageRole {
889 User,
890 Assistant,
891 System,
892 Welcome,
893}
894
895pub struct WelcomeBlock {
896 pub model_name: String,
897 pub cwd: String,
898 pub cache: BlockCache,
899}
900
901pub struct ToolCallInfo {
902 pub id: String,
903 pub title: String,
904 pub kind: acp::ToolKind,
905 pub status: acp::ToolCallStatus,
906 pub content: Vec<acp::ToolCallContent>,
907 pub collapsed: bool,
908 pub claude_tool_name: Option<String>,
911 pub hidden: bool,
913 pub terminal_id: Option<String>,
915 pub terminal_command: Option<String>,
917 pub terminal_output: Option<String>,
919 pub terminal_output_len: usize,
922 pub cache: BlockCache,
924 pub pending_permission: Option<InlinePermission>,
926}
927
928pub struct InlinePermission {
931 pub options: Vec<acp::PermissionOption>,
932 pub response_tx: tokio::sync::oneshot::Sender<acp::RequestPermissionResponse>,
933 pub selected_index: usize,
934 pub focused: bool,
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
947 use pretty_assertions::assert_eq;
948 use ratatui::style::{Color, Style};
949 use ratatui::text::{Line, Span};
950
951 #[test]
954 fn cache_default_returns_none() {
955 let cache = BlockCache::default();
956 assert!(cache.get().is_none());
957 }
958
959 #[test]
960 fn cache_store_then_get() {
961 let mut cache = BlockCache::default();
962 cache.store(vec![Line::from("hello")]);
963 assert!(cache.get().is_some());
964 assert_eq!(cache.get().unwrap().len(), 1);
965 }
966
967 #[test]
968 fn cache_invalidate_then_get_returns_none() {
969 let mut cache = BlockCache::default();
970 cache.store(vec![Line::from("data")]);
971 cache.invalidate();
972 assert!(cache.get().is_none());
973 }
974
975 #[test]
978 fn cache_store_after_invalidate() {
979 let mut cache = BlockCache::default();
980 cache.store(vec![Line::from("old")]);
981 cache.invalidate();
982 assert!(cache.get().is_none());
983 cache.store(vec![Line::from("new")]);
984 let lines = cache.get().unwrap();
985 assert_eq!(lines.len(), 1);
986 let span_content: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
987 assert_eq!(span_content, "new");
988 }
989
990 #[test]
991 fn cache_multiple_invalidations() {
992 let mut cache = BlockCache::default();
993 cache.store(vec![Line::from("data")]);
994 cache.invalidate();
995 cache.invalidate();
996 cache.invalidate();
997 assert!(cache.get().is_none());
998 cache.store(vec![Line::from("fresh")]);
999 assert!(cache.get().is_some());
1000 }
1001
1002 #[test]
1003 fn cache_store_empty_lines() {
1004 let mut cache = BlockCache::default();
1005 cache.store(Vec::new());
1006 let lines = cache.get().unwrap();
1007 assert!(lines.is_empty());
1008 }
1009
1010 #[test]
1012 fn cache_store_overwrite_without_invalidate() {
1013 let mut cache = BlockCache::default();
1014 cache.store(vec![Line::from("first")]);
1015 cache.store(vec![Line::from("second"), Line::from("line2")]);
1016 let lines = cache.get().unwrap();
1017 assert_eq!(lines.len(), 2);
1018 let content: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
1019 assert_eq!(content, "second");
1020 }
1021
1022 #[test]
1024 fn cache_get_twice_consistent() {
1025 let mut cache = BlockCache::default();
1026 cache.store(vec![Line::from("stable")]);
1027 let first = cache.get().unwrap().len();
1028 let second = cache.get().unwrap().len();
1029 assert_eq!(first, second);
1030 }
1031
1032 #[test]
1035 fn cache_store_many_lines() {
1036 let mut cache = BlockCache::default();
1037 let lines: Vec<Line<'static>> =
1038 (0..1000).map(|i| Line::from(Span::raw(format!("line {i}")))).collect();
1039 cache.store(lines);
1040 assert_eq!(cache.get().unwrap().len(), 1000);
1041 }
1042
1043 #[test]
1044 fn cache_invalidate_without_store() {
1045 let mut cache = BlockCache::default();
1046 cache.invalidate();
1047 assert!(cache.get().is_none());
1048 }
1049
1050 #[test]
1051 fn cache_rapid_store_invalidate_cycle() {
1052 let mut cache = BlockCache::default();
1053 for i in 0..50 {
1054 cache.store(vec![Line::from(format!("v{i}"))]);
1055 assert!(cache.get().is_some());
1056 cache.invalidate();
1057 assert!(cache.get().is_none());
1058 }
1059 cache.store(vec![Line::from("final")]);
1060 assert!(cache.get().is_some());
1061 }
1062
1063 #[test]
1065 fn cache_store_styled_lines() {
1066 let mut cache = BlockCache::default();
1067 let line = Line::from(vec![
1068 Span::styled("bold", Style::default().fg(Color::Red)),
1069 Span::raw(" normal "),
1070 Span::styled("blue", Style::default().fg(Color::Blue)),
1071 ]);
1072 cache.store(vec![line]);
1073 let lines = cache.get().unwrap();
1074 assert_eq!(lines[0].spans.len(), 3);
1075 }
1076
1077 #[test]
1081 fn cache_version_no_false_fresh_after_many_invalidations() {
1082 let mut cache = BlockCache::default();
1083 cache.store(vec![Line::from("data")]);
1084 for _ in 0..10_000 {
1085 cache.invalidate();
1086 }
1087 assert!(cache.get().is_none());
1089 }
1090
1091 #[test]
1093 fn cache_alternating_invalidate_store() {
1094 let mut cache = BlockCache::default();
1095 for i in 0..100 {
1096 cache.invalidate();
1097 assert!(cache.get().is_none(), "stale after invalidate at iter {i}");
1098 cache.store(vec![Line::from(format!("v{i}"))]);
1099 assert!(cache.get().is_some(), "fresh after store at iter {i}");
1100 }
1101 }
1102
1103 #[test]
1106 fn cache_height_default_returns_none() {
1107 let cache = BlockCache::default();
1108 assert!(cache.height_at(80).is_none());
1109 }
1110
1111 #[test]
1112 fn cache_store_with_height_then_height_at() {
1113 let mut cache = BlockCache::default();
1114 cache.store_with_height(vec![Line::from("hello")], 1, 80);
1115 assert_eq!(cache.height_at(80), Some(1));
1116 assert!(cache.get().is_some());
1117 }
1118
1119 #[test]
1120 fn cache_height_at_wrong_width_returns_none() {
1121 let mut cache = BlockCache::default();
1122 cache.store_with_height(vec![Line::from("hello")], 1, 80);
1123 assert!(cache.height_at(120).is_none());
1124 }
1125
1126 #[test]
1127 fn cache_height_invalidated_returns_none() {
1128 let mut cache = BlockCache::default();
1129 cache.store_with_height(vec![Line::from("hello")], 1, 80);
1130 cache.invalidate();
1131 assert!(cache.height_at(80).is_none());
1132 }
1133
1134 #[test]
1135 fn cache_store_without_height_has_no_height() {
1136 let mut cache = BlockCache::default();
1137 cache.store(vec![Line::from("hello")]);
1138 assert!(cache.height_at(80).is_none());
1140 }
1141
1142 #[test]
1143 fn cache_store_with_height_overwrite() {
1144 let mut cache = BlockCache::default();
1145 cache.store_with_height(vec![Line::from("old")], 1, 80);
1146 cache.invalidate();
1147 cache.store_with_height(vec![Line::from("new long line")], 3, 120);
1148 assert_eq!(cache.height_at(120), Some(3));
1149 assert!(cache.height_at(80).is_none());
1150 }
1151
1152 #[test]
1155 fn cache_set_height_after_store() {
1156 let mut cache = BlockCache::default();
1157 cache.store(vec![Line::from("hello")]);
1158 assert!(cache.height_at(80).is_none()); cache.set_height(1, 80);
1160 assert_eq!(cache.height_at(80), Some(1));
1161 assert!(cache.get().is_some()); }
1163
1164 #[test]
1165 fn cache_set_height_update_width() {
1166 let mut cache = BlockCache::default();
1167 cache.store(vec![Line::from("hello world")]);
1168 cache.set_height(1, 80);
1169 assert_eq!(cache.height_at(80), Some(1));
1170 cache.set_height(2, 40);
1172 assert_eq!(cache.height_at(40), Some(2));
1173 assert!(cache.height_at(80).is_none()); }
1175
1176 #[test]
1177 fn cache_set_height_invalidate_clears_height() {
1178 let mut cache = BlockCache::default();
1179 cache.store(vec![Line::from("data")]);
1180 cache.set_height(3, 80);
1181 cache.invalidate();
1182 assert!(cache.height_at(80).is_none()); }
1184
1185 #[test]
1186 fn cache_set_height_on_invalidated_cache_returns_none() {
1187 let mut cache = BlockCache::default();
1188 cache.store(vec![Line::from("data")]);
1189 cache.invalidate(); cache.set_height(5, 80);
1191 assert!(cache.height_at(80).is_none());
1193 }
1194
1195 #[test]
1196 fn cache_store_then_set_height_matches_store_with_height() {
1197 let mut cache_a = BlockCache::default();
1198 cache_a.store(vec![Line::from("test")]);
1199 cache_a.set_height(2, 100);
1200
1201 let mut cache_b = BlockCache::default();
1202 cache_b.store_with_height(vec![Line::from("test")], 2, 100);
1203
1204 assert_eq!(cache_a.height_at(100), cache_b.height_at(100));
1205 assert_eq!(cache_a.get().unwrap().len(), cache_b.get().unwrap().len());
1206 }
1207
1208 fn make_test_app() -> App {
1211 App::test_default()
1212 }
1213
1214 #[test]
1215 fn lookup_missing_returns_none() {
1216 let app = make_test_app();
1217 assert!(app.lookup_tool_call("nonexistent").is_none());
1218 }
1219
1220 #[test]
1221 fn index_and_lookup() {
1222 let mut app = make_test_app();
1223 app.index_tool_call("tc-123".into(), 2, 5);
1224 assert_eq!(app.lookup_tool_call("tc-123"), Some((2, 5)));
1225 }
1226
1227 #[test]
1231 fn index_overwrite_existing() {
1232 let mut app = make_test_app();
1233 app.index_tool_call("tc-1".into(), 0, 0);
1234 app.index_tool_call("tc-1".into(), 5, 10);
1235 assert_eq!(app.lookup_tool_call("tc-1"), Some((5, 10)));
1236 }
1237
1238 #[test]
1240 fn index_empty_string_id() {
1241 let mut app = make_test_app();
1242 app.index_tool_call(String::new(), 1, 2);
1243 assert_eq!(app.lookup_tool_call(""), Some((1, 2)));
1244 }
1245
1246 #[test]
1248 fn index_stress_1000_entries() {
1249 let mut app = make_test_app();
1250 for i in 0..1000 {
1251 app.index_tool_call(format!("tc-{i}"), i, i * 2);
1252 }
1253 assert_eq!(app.lookup_tool_call("tc-0"), Some((0, 0)));
1255 assert_eq!(app.lookup_tool_call("tc-500"), Some((500, 1000)));
1256 assert_eq!(app.lookup_tool_call("tc-999"), Some((999, 1998)));
1257 assert!(app.lookup_tool_call("tc-1000").is_none());
1259 }
1260
1261 #[test]
1263 fn index_unicode_id() {
1264 let mut app = make_test_app();
1265 app.index_tool_call("\u{1F600}-tool".into(), 3, 7);
1266 assert_eq!(app.lookup_tool_call("\u{1F600}-tool"), Some((3, 7)));
1267 }
1268
1269 #[test]
1272 fn active_task_insert_remove() {
1273 let mut app = make_test_app();
1274 app.insert_active_task("task-1".into());
1275 assert!(app.active_task_ids.contains("task-1"));
1276 app.remove_active_task("task-1");
1277 assert!(!app.active_task_ids.contains("task-1"));
1278 }
1279
1280 #[test]
1281 fn remove_nonexistent_task_is_noop() {
1282 let mut app = make_test_app();
1283 app.remove_active_task("does-not-exist");
1284 assert!(app.active_task_ids.is_empty());
1285 }
1286
1287 #[test]
1291 fn active_task_insert_duplicate() {
1292 let mut app = make_test_app();
1293 app.insert_active_task("task-1".into());
1294 app.insert_active_task("task-1".into());
1295 assert_eq!(app.active_task_ids.len(), 1);
1296 app.remove_active_task("task-1");
1297 assert!(app.active_task_ids.is_empty());
1298 }
1299
1300 #[test]
1302 fn active_task_insert_many_remove_out_of_order() {
1303 let mut app = make_test_app();
1304 for i in 0..100 {
1305 app.insert_active_task(format!("task-{i}"));
1306 }
1307 assert_eq!(app.active_task_ids.len(), 100);
1308 for i in (0..100).rev() {
1310 app.remove_active_task(&format!("task-{i}"));
1311 }
1312 assert!(app.active_task_ids.is_empty());
1313 }
1314
1315 #[test]
1317 fn active_task_interleaved_insert_remove() {
1318 let mut app = make_test_app();
1319 app.insert_active_task("a".into());
1320 app.insert_active_task("b".into());
1321 app.remove_active_task("a");
1322 app.insert_active_task("c".into());
1323 assert!(!app.active_task_ids.contains("a"));
1324 assert!(app.active_task_ids.contains("b"));
1325 assert!(app.active_task_ids.contains("c"));
1326 assert_eq!(app.active_task_ids.len(), 2);
1327 }
1328
1329 #[test]
1331 fn active_task_remove_from_empty_repeatedly() {
1332 let mut app = make_test_app();
1333 for i in 0..100 {
1334 app.remove_active_task(&format!("ghost-{i}"));
1335 }
1336 assert!(app.active_task_ids.is_empty());
1337 }
1338
1339 fn test_render(src: &str) -> Vec<Line<'static>> {
1343 src.lines().map(|l| Line::from(l.to_owned())).collect()
1344 }
1345
1346 #[test]
1347 fn incr_default_empty() {
1348 let incr = IncrementalMarkdown::default();
1349 assert!(incr.full_text().is_empty());
1350 }
1351
1352 #[test]
1353 fn incr_from_complete() {
1354 let incr = IncrementalMarkdown::from_complete("hello world");
1355 assert_eq!(incr.full_text(), "hello world");
1356 }
1357
1358 #[test]
1359 fn incr_append_single_chunk() {
1360 let mut incr = IncrementalMarkdown::default();
1361 incr.append("hello");
1362 assert_eq!(incr.full_text(), "hello");
1363 }
1364
1365 #[test]
1366 fn incr_append_no_paragraph_break() {
1367 let mut incr = IncrementalMarkdown::default();
1368 incr.append("line1\nline2\nline3");
1369 assert_eq!(incr.paragraphs.len(), 0);
1370 assert_eq!(incr.tail, "line1\nline2\nline3");
1371 }
1372
1373 #[test]
1374 fn incr_append_splits_on_double_newline() {
1375 let mut incr = IncrementalMarkdown::default();
1376 incr.append("para1\n\npara2");
1377 assert_eq!(incr.paragraphs.len(), 1);
1378 assert_eq!(incr.paragraphs[0].0, "para1");
1379 assert_eq!(incr.tail, "para2");
1380 }
1381
1382 #[test]
1383 fn incr_append_multiple_paragraphs() {
1384 let mut incr = IncrementalMarkdown::default();
1385 incr.append("p1\n\np2\n\np3\n\np4");
1386 assert_eq!(incr.paragraphs.len(), 3);
1387 assert_eq!(incr.paragraphs[0].0, "p1");
1388 assert_eq!(incr.paragraphs[1].0, "p2");
1389 assert_eq!(incr.paragraphs[2].0, "p3");
1390 assert_eq!(incr.tail, "p4");
1391 }
1392
1393 #[test]
1394 fn incr_append_incremental_chunks() {
1395 let mut incr = IncrementalMarkdown::default();
1396 incr.append("hel");
1397 incr.append("lo\n");
1398 incr.append("\nworld");
1399 assert_eq!(incr.paragraphs.len(), 1);
1400 assert_eq!(incr.paragraphs[0].0, "hello");
1401 assert_eq!(incr.tail, "world");
1402 }
1403
1404 #[test]
1405 fn incr_code_fence_preserves_double_newlines() {
1406 let mut incr = IncrementalMarkdown::default();
1407 incr.append("before\n\n```\ncode\n\nmore code\n```\n\nafter");
1408 assert_eq!(incr.paragraphs.len(), 2);
1410 assert_eq!(incr.paragraphs[0].0, "before");
1411 assert_eq!(incr.paragraphs[1].0, "```\ncode\n\nmore code\n```");
1412 assert_eq!(incr.tail, "after");
1413 }
1414
1415 #[test]
1416 fn incr_code_fence_incremental() {
1417 let mut incr = IncrementalMarkdown::default();
1418 incr.append("text\n\n```\nfn main() {\n");
1419 assert_eq!(incr.paragraphs.len(), 1); assert!(incr.in_code_fence); incr.append(" println!(\"hi\");\n\n}\n```\n\nafter");
1422 assert!(!incr.in_code_fence); assert_eq!(incr.tail, "after");
1424 }
1425
1426 #[test]
1427 fn incr_full_text_reconstruction() {
1428 let mut incr = IncrementalMarkdown::default();
1429 incr.append("p1\n\np2\n\np3");
1430 assert_eq!(incr.full_text(), "p1\n\np2\n\np3");
1431 }
1432
1433 #[test]
1434 fn incr_lines_renders_all() {
1435 let mut incr = IncrementalMarkdown::default();
1436 incr.append("line1\n\nline2\n\nline3");
1437 let lines = incr.lines(&test_render);
1438 assert_eq!(lines.len(), 3);
1440 }
1441
1442 #[test]
1443 fn incr_lines_caches_paragraphs() {
1444 let mut incr = IncrementalMarkdown::default();
1445 incr.append("p1\n\np2\n\ntail");
1446 let _ = incr.lines(&test_render);
1448 assert!(!incr.paragraphs[0].1.is_empty());
1449 assert!(!incr.paragraphs[1].1.is_empty());
1450 let lines = incr.lines(&test_render);
1452 assert_eq!(lines.len(), 3);
1453 }
1454
1455 #[test]
1456 fn incr_ensure_rendered_fills_empty() {
1457 let mut incr = IncrementalMarkdown::default();
1458 incr.append("p1\n\np2\n\ntail");
1459 assert!(incr.paragraphs[0].1.is_empty());
1461 incr.ensure_rendered(&test_render);
1462 assert!(!incr.paragraphs[0].1.is_empty());
1463 assert!(!incr.paragraphs[1].1.is_empty());
1464 }
1465
1466 #[test]
1467 fn incr_invalidate_clears_renders() {
1468 let mut incr = IncrementalMarkdown::default();
1469 incr.append("p1\n\np2\n\ntail");
1470 incr.ensure_rendered(&test_render);
1471 assert!(!incr.paragraphs[0].1.is_empty());
1472 incr.invalidate_renders();
1473 assert!(incr.paragraphs[0].1.is_empty());
1474 assert!(incr.paragraphs[1].1.is_empty());
1475 }
1476
1477 #[test]
1478 fn incr_streaming_simulation() {
1479 let mut incr = IncrementalMarkdown::default();
1481 let chunks = ["Here is ", "some text.\n", "\nNext para", "graph here.\n\n", "Final."];
1482 for chunk in chunks {
1483 incr.append(chunk);
1484 }
1485 assert_eq!(incr.paragraphs.len(), 2);
1486 assert_eq!(incr.paragraphs[0].0, "Here is some text.");
1487 assert_eq!(incr.paragraphs[1].0, "Next paragraph here.");
1488 assert_eq!(incr.tail, "Final.");
1489 }
1490
1491 #[test]
1492 fn incr_empty_paragraphs() {
1493 let mut incr = IncrementalMarkdown::default();
1494 incr.append("\n\n\n\n");
1495 assert!(!incr.paragraphs.is_empty());
1497 }
1498
1499 #[test]
1502 fn viewport_new_defaults() {
1503 let vp = ChatViewport::new();
1504 assert_eq!(vp.scroll_offset, 0);
1505 assert_eq!(vp.scroll_target, 0);
1506 assert!(vp.auto_scroll);
1507 assert_eq!(vp.width, 0);
1508 assert!(vp.message_heights.is_empty());
1509 assert!(vp.height_prefix_sums.is_empty());
1510 }
1511
1512 #[test]
1513 fn viewport_on_frame_sets_width() {
1514 let mut vp = ChatViewport::new();
1515 vp.on_frame(80);
1516 assert_eq!(vp.width, 80);
1517 }
1518
1519 #[test]
1520 fn viewport_on_frame_resize_invalidates() {
1521 let mut vp = ChatViewport::new();
1522 vp.on_frame(80);
1523 vp.set_message_height(0, 10);
1524 vp.set_message_height(1, 20);
1525 vp.rebuild_prefix_sums();
1526
1527 vp.on_frame(120);
1530 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); }
1535
1536 #[test]
1537 fn viewport_on_frame_same_width_no_invalidation() {
1538 let mut vp = ChatViewport::new();
1539 vp.on_frame(80);
1540 vp.set_message_height(0, 10);
1541 vp.on_frame(80); assert_eq!(vp.message_height(0), 10); }
1544
1545 #[test]
1546 fn viewport_message_height_set_and_get() {
1547 let mut vp = ChatViewport::new();
1548 vp.on_frame(80);
1549 vp.set_message_height(0, 5);
1550 vp.set_message_height(1, 10);
1551 assert_eq!(vp.message_height(0), 5);
1552 assert_eq!(vp.message_height(1), 10);
1553 assert_eq!(vp.message_height(2), 0); }
1555
1556 #[test]
1557 fn viewport_message_height_grows_vec() {
1558 let mut vp = ChatViewport::new();
1559 vp.on_frame(80);
1560 vp.set_message_height(5, 42);
1561 assert_eq!(vp.message_heights.len(), 6);
1562 assert_eq!(vp.message_height(5), 42);
1563 assert_eq!(vp.message_height(3), 0); }
1565
1566 #[test]
1567 fn viewport_prefix_sums_basic() {
1568 let mut vp = ChatViewport::new();
1569 vp.on_frame(80);
1570 vp.set_message_height(0, 5);
1571 vp.set_message_height(1, 10);
1572 vp.set_message_height(2, 3);
1573 vp.rebuild_prefix_sums();
1574 assert_eq!(vp.total_message_height(), 18);
1575 assert_eq!(vp.cumulative_height_before(0), 0);
1576 assert_eq!(vp.cumulative_height_before(1), 5);
1577 assert_eq!(vp.cumulative_height_before(2), 15);
1578 }
1579
1580 #[test]
1581 fn viewport_prefix_sums_streaming_fast_path() {
1582 let mut vp = ChatViewport::new();
1583 vp.on_frame(80);
1584 vp.set_message_height(0, 5);
1585 vp.set_message_height(1, 10);
1586 vp.rebuild_prefix_sums();
1587 assert_eq!(vp.total_message_height(), 15);
1588
1589 vp.set_message_height(1, 20);
1591 vp.rebuild_prefix_sums(); assert_eq!(vp.total_message_height(), 25);
1593 assert_eq!(vp.cumulative_height_before(1), 5);
1594 }
1595
1596 #[test]
1597 fn viewport_find_first_visible() {
1598 let mut vp = ChatViewport::new();
1599 vp.on_frame(80);
1600 vp.set_message_height(0, 10);
1601 vp.set_message_height(1, 10);
1602 vp.set_message_height(2, 10);
1603 vp.rebuild_prefix_sums();
1604
1605 assert_eq!(vp.find_first_visible(0), 0);
1606 assert_eq!(vp.find_first_visible(10), 1);
1607 assert_eq!(vp.find_first_visible(15), 1);
1608 assert_eq!(vp.find_first_visible(20), 2);
1609 }
1610
1611 #[test]
1612 fn viewport_find_first_visible_handles_offsets_before_first_boundary() {
1613 let mut vp = ChatViewport::new();
1614 vp.on_frame(80);
1615 vp.set_message_height(0, 10);
1616 vp.set_message_height(1, 10);
1617 vp.rebuild_prefix_sums();
1618
1619 assert_eq!(vp.find_first_visible(0), 0);
1620 assert_eq!(vp.find_first_visible(5), 0);
1621 assert_eq!(vp.find_first_visible(15), 1);
1622 }
1623
1624 #[test]
1625 fn viewport_scroll_up_down() {
1626 let mut vp = ChatViewport::new();
1627 vp.scroll_target = 20;
1628 vp.auto_scroll = true;
1629
1630 vp.scroll_up(5);
1631 assert_eq!(vp.scroll_target, 15);
1632 assert!(!vp.auto_scroll); vp.scroll_down(3);
1635 assert_eq!(vp.scroll_target, 18);
1636 assert!(!vp.auto_scroll); }
1638
1639 #[test]
1640 fn viewport_scroll_up_saturates() {
1641 let mut vp = ChatViewport::new();
1642 vp.scroll_target = 2;
1643 vp.scroll_up(10);
1644 assert_eq!(vp.scroll_target, 0);
1645 }
1646
1647 #[test]
1648 fn viewport_engage_auto_scroll() {
1649 let mut vp = ChatViewport::new();
1650 vp.auto_scroll = false;
1651 vp.engage_auto_scroll();
1652 assert!(vp.auto_scroll);
1653 }
1654
1655 #[test]
1656 fn viewport_default_eq_new() {
1657 let a = ChatViewport::new();
1658 let b = ChatViewport::default();
1659 assert_eq!(a.width, b.width);
1660 assert_eq!(a.auto_scroll, b.auto_scroll);
1661 assert_eq!(a.message_heights.len(), b.message_heights.len());
1662 }
1663
1664 #[test]
1665 fn focus_owner_defaults_to_input() {
1666 let app = make_test_app();
1667 assert_eq!(app.focus_owner(), FocusOwner::Input);
1668 }
1669
1670 #[test]
1671 fn focus_owner_todo_when_panel_open_and_focused() {
1672 let mut app = make_test_app();
1673 app.todos.push(TodoItem {
1674 content: "Task".into(),
1675 status: TodoStatus::Pending,
1676 active_form: String::new(),
1677 });
1678 app.show_todo_panel = true;
1679 app.claim_focus_target(FocusTarget::TodoList);
1680 assert_eq!(app.focus_owner(), FocusOwner::TodoList);
1681 }
1682
1683 #[test]
1684 fn focus_owner_permission_overrides_todo() {
1685 let mut app = make_test_app();
1686 app.todos.push(TodoItem {
1687 content: "Task".into(),
1688 status: TodoStatus::Pending,
1689 active_form: String::new(),
1690 });
1691 app.show_todo_panel = true;
1692 app.claim_focus_target(FocusTarget::TodoList);
1693 app.pending_permission_ids.push("perm-1".into());
1694 app.claim_focus_target(FocusTarget::Permission);
1695 assert_eq!(app.focus_owner(), FocusOwner::Permission);
1696 }
1697
1698 #[test]
1699 fn focus_owner_mention_overrides_permission_and_todo() {
1700 let mut app = make_test_app();
1701 app.todos.push(TodoItem {
1702 content: "Task".into(),
1703 status: TodoStatus::Pending,
1704 active_form: String::new(),
1705 });
1706 app.show_todo_panel = true;
1707 app.claim_focus_target(FocusTarget::TodoList);
1708 app.pending_permission_ids.push("perm-1".into());
1709 app.claim_focus_target(FocusTarget::Permission);
1710 app.mention = Some(mention::MentionState {
1711 trigger_row: 0,
1712 trigger_col: 0,
1713 query: String::new(),
1714 candidates: Vec::new(),
1715 dialog: super::super::dialog::DialogState::default(),
1716 });
1717 app.claim_focus_target(FocusTarget::Mention);
1718 assert_eq!(app.focus_owner(), FocusOwner::Mention);
1719 }
1720
1721 #[test]
1722 fn focus_owner_falls_back_to_input_when_claim_is_not_available() {
1723 let mut app = make_test_app();
1724 app.claim_focus_target(FocusTarget::TodoList);
1725 assert_eq!(app.focus_owner(), FocusOwner::Input);
1726 }
1727
1728 #[test]
1729 fn claim_and_release_focus_target() {
1730 let mut app = make_test_app();
1731 app.todos.push(TodoItem {
1732 content: "Task".into(),
1733 status: TodoStatus::Pending,
1734 active_form: String::new(),
1735 });
1736 app.show_todo_panel = true;
1737 app.claim_focus_target(FocusTarget::TodoList);
1738 assert_eq!(app.focus_owner(), FocusOwner::TodoList);
1739 app.release_focus_target(FocusTarget::TodoList);
1740 assert_eq!(app.focus_owner(), FocusOwner::Input);
1741 }
1742
1743 #[test]
1744 fn latest_claim_wins_across_equal_targets() {
1745 let mut app = make_test_app();
1746 app.todos.push(TodoItem {
1747 content: "Task".into(),
1748 status: TodoStatus::Pending,
1749 active_form: String::new(),
1750 });
1751 app.show_todo_panel = true;
1752 app.mention = Some(mention::MentionState {
1753 trigger_row: 0,
1754 trigger_col: 0,
1755 query: String::new(),
1756 candidates: Vec::new(),
1757 dialog: super::super::dialog::DialogState::default(),
1758 });
1759 app.pending_permission_ids.push("perm-1".into());
1760
1761 app.claim_focus_target(FocusTarget::TodoList);
1762 assert_eq!(app.focus_owner(), FocusOwner::TodoList);
1763
1764 app.claim_focus_target(FocusTarget::Permission);
1765 assert_eq!(app.focus_owner(), FocusOwner::Permission);
1766
1767 app.claim_focus_target(FocusTarget::Mention);
1768 assert_eq!(app.focus_owner(), FocusOwner::Mention);
1769
1770 app.release_focus_target(FocusTarget::Mention);
1771 assert_eq!(app.focus_owner(), FocusOwner::Permission);
1772 }
1773}