返回 DeepSeek-TUI-2026
tests.rs
根目录 / crates / tui / src / tui / ui / tests.rs
1 use super::*;
2 use crate::config::Config;
3 use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};
4 use crate::core::engine::mock_engine_handle;
5 use crate::tui::file_mention::{
6 apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,
7 try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,
8 };
9 use crate::tui::history::{
10 ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,
11 };
12 use crate::tui::views::{ModalView, ViewAction};
13 use crate::working_set::Workspace;
14 use std::path::PathBuf;
15 use std::process::Command;
16 use std::time::{Duration, Instant};
17 use tempfile::TempDir;
18
19 #[test]
20 fn format_resume_hint_uses_canonical_resume_command() {
21 assert_eq!(
22 format_resume_hint(Some("019dd9d6-4f44-7c83-9863-59674a12b827")),
23 Some(
24 "To continue this session, run deepseek resume 019dd9d6-4f44-7c83-9863-59674a12b827"
25 .to_string()
26 )
27 );
28 }
29
30 #[test]
31 fn format_resume_hint_omits_missing_session_id() {
32 assert_eq!(format_resume_hint(None), None);
33 assert_eq!(format_resume_hint(Some(" ")), None);
34 }
35
36 #[test]
37 fn composer_newline_shortcuts_do_not_steal_ctrl_enter() {
38 assert!(is_composer_newline_key(KeyEvent::new(
39 KeyCode::Char('j'),
40 KeyModifiers::CONTROL,
41 )));
42 assert!(is_composer_newline_key(KeyEvent::new(
43 KeyCode::Enter,
44 KeyModifiers::ALT,
45 )));
46 assert!(is_composer_newline_key(KeyEvent::new(
47 KeyCode::Enter,
48 KeyModifiers::SHIFT,
49 )));
50 assert!(!is_composer_newline_key(KeyEvent::new(
51 KeyCode::Enter,
52 KeyModifiers::NONE,
53 )));
54 assert!(!is_composer_newline_key(KeyEvent::new(
55 KeyCode::Enter,
56 KeyModifiers::CONTROL,
57 )));
58 assert!(!is_composer_newline_key(KeyEvent::new(
59 KeyCode::Enter,
60 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
61 )));
62 }
63
64 #[test]
65 fn selection_point_from_position_ignores_top_padding() {
66 let area = Rect {
67 x: 10,
68 y: 20,
69 width: 30,
70 height: 5,
71 };
72
73 // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.
74 let padding_top = 3;
75 let transcript_top = 0;
76 let transcript_total = 2;
77
78 // Click in padding area -> no selection
79 assert!(
80 selection_point_from_position(
81 area,
82 area.x + 1,
83 area.y,
84 transcript_top,
85 transcript_total,
86 padding_top,
87 )
88 .is_none()
89 );
90
91 // First transcript line is at row `padding_top`
92 let p0 = selection_point_from_position(
93 area,
94 area.x + 2,
95 area.y + u16::try_from(padding_top).expect("padding should fit"),
96 transcript_top,
97 transcript_total,
98 padding_top,
99 )
100 .expect("point");
101 assert_eq!(p0.line_index, 0);
102 assert_eq!(p0.column, 2);
103
104 // Second transcript line is one row below
105 let p1 = selection_point_from_position(
106 area,
107 area.x,
108 area.y + u16::try_from(padding_top + 1).expect("padding should fit"),
109 transcript_top,
110 transcript_total,
111 padding_top,
112 )
113 .expect("point");
114 assert_eq!(p1.line_index, 1);
115 assert_eq!(p1.column, 0);
116 }
117
118 #[test]
119 fn selection_to_text_handles_multiline_and_reversed_endpoints() {
120 let mut app = create_test_app();
121 app.history = vec![HistoryCell::Assistant {
122 content: "alpha beta\ngamma delta".to_string(),
123 streaming: false,
124 }];
125 app.resync_history_revisions();
126 app.viewport.transcript_cache.ensure(
127 &app.history,
128 &app.history_revisions,
129 80,
130 app.transcript_render_options(),
131 );
132
133 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
134 line_index: 1,
135 column: 5,
136 });
137 app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
138 line_index: 0,
139 column: 6,
140 });
141
142 assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\n▏ gam"));
143 }
144
145 #[test]
146 fn selection_to_text_copies_rendered_transcript_block() {
147 let mut app = create_test_app();
148 app.history = vec![
149 HistoryCell::System {
150 content: "copy system".to_string(),
151 },
152 HistoryCell::User {
153 content: "copy user".to_string(),
154 },
155 HistoryCell::Thinking {
156 content: "copy thinking".to_string(),
157 streaming: false,
158 duration_secs: Some(1.0),
159 },
160 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
161 name: "exec_shell".to_string(),
162 status: ToolStatus::Success,
163 input_summary: Some("cargo check".to_string()),
164 output: Some("tool output line".to_string()),
165 prompts: None,
166 spillover_path: None,
167 })),
168 HistoryCell::Assistant {
169 content: "copy assistant".to_string(),
170 streaming: false,
171 },
172 ];
173 app.resync_history_revisions();
174 app.viewport.transcript_cache.ensure(
175 &app.history,
176 &app.history_revisions,
177 80,
178 app.transcript_render_options(),
179 );
180
181 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
182 line_index: 0,
183 column: 0,
184 });
185 app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
186 line_index: app
187 .viewport
188 .transcript_cache
189 .total_lines()
190 .saturating_sub(1),
191 column: 80,
192 });
193
194 let selected = selection_to_text(&app).expect("selection text");
195 assert!(selected.contains("Note copy system"), "{selected:?}");
196 assert!(selected.contains("▎ copy user"), "{selected:?}");
197 assert!(selected.contains("copy thinking"), "{selected:?}");
198 assert!(selected.contains("tool output line"), "{selected:?}");
199 assert!(selected.contains("● copy assistant"), "{selected:?}");
200 }
201
202 #[test]
203 fn selection_has_content_rejects_zero_width_selection() {
204 let mut app = create_test_app();
205 let point = TranscriptSelectionPoint {
206 line_index: 0,
207 column: 3,
208 };
209 app.viewport.transcript_selection.anchor = Some(point);
210 app.viewport.transcript_selection.head = Some(point);
211
212 assert!(!selection_has_content(&app));
213 }
214
215 #[test]
216 fn mouse_selection_autocopies_on_release_without_ctrl_c() {
217 let mut app = create_test_app();
218 app.history = vec![HistoryCell::Assistant {
219 content: "alpha beta".to_string(),
220 streaming: false,
221 }];
222 app.resync_history_revisions();
223 app.viewport.transcript_cache.ensure(
224 &app.history,
225 &app.history_revisions,
226 80,
227 app.transcript_render_options(),
228 );
229 app.viewport.last_transcript_area = Some(Rect {
230 x: 0,
231 y: 0,
232 width: 80,
233 height: 8,
234 });
235 app.viewport.last_transcript_top = 0;
236 app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();
237 app.viewport.last_transcript_padding_top = 0;
238
239 handle_mouse_event(
240 &mut app,
241 MouseEvent {
242 kind: MouseEventKind::Down(MouseButton::Left),
243 column: 0,
244 row: 0,
245 modifiers: KeyModifiers::NONE,
246 },
247 );
248 handle_mouse_event(
249 &mut app,
250 MouseEvent {
251 kind: MouseEventKind::Drag(MouseButton::Left),
252 column: 8,
253 row: 0,
254 modifiers: KeyModifiers::NONE,
255 },
256 );
257 handle_mouse_event(
258 &mut app,
259 MouseEvent {
260 kind: MouseEventKind::Up(MouseButton::Left),
261 column: 8,
262 row: 0,
263 modifiers: KeyModifiers::NONE,
264 },
265 );
266
267 assert_eq!(app.status_message.as_deref(), Some("Selection copied"));
268 assert!(
269 app.clipboard
270 .last_written_text()
271 .is_some_and(|text| text.contains("alpha")),
272 "selection should be written to clipboard"
273 );
274 }
275
276 #[test]
277 fn right_click_opens_context_menu() {
278 let mut app = create_test_app();
279
280 let events = handle_mouse_event(
281 &mut app,
282 MouseEvent {
283 kind: MouseEventKind::Down(MouseButton::Right),
284 column: 4,
285 row: 4,
286 modifiers: KeyModifiers::NONE,
287 },
288 );
289
290 assert!(events.is_empty());
291 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));
292 }
293
294 #[test]
295 fn right_click_menu_includes_selection_and_clicked_cell_actions() {
296 let mut app = create_test_app();
297 app.history = vec![HistoryCell::Assistant {
298 content: "alpha beta".to_string(),
299 streaming: false,
300 }];
301 app.resync_history_revisions();
302 app.viewport.transcript_cache.ensure(
303 &app.history,
304 &app.history_revisions,
305 80,
306 app.transcript_render_options(),
307 );
308 app.viewport.last_transcript_area = Some(Rect {
309 x: 0,
310 y: 0,
311 width: 80,
312 height: 8,
313 });
314 app.viewport.last_transcript_top = 0;
315 app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();
316 app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {
317 line_index: 0,
318 column: 0,
319 });
320 app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {
321 line_index: 0,
322 column: 5,
323 });
324
325 let entries = build_context_menu_entries(
326 &app,
327 MouseEvent {
328 kind: MouseEventKind::Down(MouseButton::Right),
329 column: 2,
330 row: 0,
331 modifiers: KeyModifiers::NONE,
332 },
333 );
334 let labels = entries
335 .iter()
336 .map(|entry| entry.label.as_str())
337 .collect::<Vec<_>>();
338
339 assert!(labels.contains(&"Copy selection"));
340 assert!(labels.contains(&"Open selection"));
341 assert!(labels.contains(&"Open details"));
342 assert!(labels.contains(&"Paste"));
343 }
344
345 #[test]
346 fn mouse_events_do_not_mutate_transcript_behind_modal() {
347 let mut app = create_test_app();
348 app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
349
350 let events = handle_mouse_event(
351 &mut app,
352 MouseEvent {
353 kind: MouseEventKind::ScrollUp,
354 column: 4,
355 row: 4,
356 modifiers: KeyModifiers::NONE,
357 },
358 );
359
360 assert!(events.is_empty());
361 assert_eq!(app.viewport.pending_scroll_delta, 0);
362 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));
363 }
364
365 #[test]
366 fn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {
367 assert!(is_copy_shortcut(&KeyEvent::new(
368 KeyCode::Char('c'),
369 KeyModifiers::SUPER,
370 )));
371 assert!(is_copy_shortcut(&KeyEvent::new(
372 KeyCode::Char('c'),
373 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
374 )));
375 assert!(!is_copy_shortcut(&KeyEvent::new(
376 KeyCode::Char('c'),
377 KeyModifiers::CONTROL,
378 )));
379 }
380
381 #[test]
382 fn file_tree_shortcut_does_not_steal_plain_ctrl_e() {
383 assert!(!is_file_tree_toggle_shortcut(&KeyEvent::new(
384 KeyCode::Char('e'),
385 KeyModifiers::CONTROL,
386 )));
387 assert!(is_file_tree_toggle_shortcut(&KeyEvent::new(
388 KeyCode::Char('E'),
389 KeyModifiers::CONTROL,
390 )));
391 assert!(is_file_tree_toggle_shortcut(&KeyEvent::new(
392 KeyCode::Char('e'),
393 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
394 )));
395 assert!(is_file_tree_toggle_shortcut(&KeyEvent::new(
396 KeyCode::Char('E'),
397 KeyModifiers::SUPER | KeyModifiers::SHIFT,
398 )));
399 }
400
401 #[test]
402 fn parse_plan_choice_accepts_numbers() {
403 assert_eq!(parse_plan_choice("1"), Some(PlanChoice::AcceptAgent));
404 assert_eq!(parse_plan_choice("2"), Some(PlanChoice::AcceptYolo));
405 assert_eq!(parse_plan_choice("3"), Some(PlanChoice::RevisePlan));
406 assert_eq!(parse_plan_choice("4"), Some(PlanChoice::ExitPlan));
407 }
408
409 #[test]
410 fn parse_plan_choice_rejects_aliases_and_extra_text() {
411 assert_eq!(parse_plan_choice("accept"), None);
412 assert_eq!(parse_plan_choice("agent"), None);
413 assert_eq!(parse_plan_choice("yolo"), None);
414 assert_eq!(parse_plan_choice("3 revise"), None);
415 assert_eq!(parse_plan_choice("unknown"), None);
416 }
417
418 #[test]
419 fn plan_choice_from_option_maps_expected_values() {
420 assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));
421 assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));
422 assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));
423 assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));
424 assert_eq!(plan_choice_from_option(5), None);
425 }
426
427 #[test]
428 fn plan_prompt_view_escape_emits_dismiss_event() {
429 let mut view = PlanPromptView::new();
430
431 let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
432
433 assert!(matches!(
434 action,
435 ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)
436 ));
437 }
438
439 #[test]
440 fn transcript_scroll_percent_is_clamped_and_relative() {
441 assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));
442 assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));
443 assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));
444 assert_eq!(transcript_scroll_percent(0, 20, 20), None);
445 }
446
447 #[test]
448 fn parse_git_status_path_handles_simple_and_renamed_entries() {
449 assert_eq!(
450 parse_git_status_path(" M crates/tui/src/tui/ui.rs"),
451 Some("crates/tui/src/tui/ui.rs".to_string())
452 );
453 assert_eq!(
454 parse_git_status_path("R old name.rs -> crates/tui/src/tui/file_picker.rs"),
455 Some("crates/tui/src/tui/file_picker.rs".to_string())
456 );
457 }
458
459 #[test]
460 fn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {
461 let dir = TempDir::new().expect("tempdir");
462 let root = dir.path();
463 std::fs::create_dir_all(root.join("src")).unwrap();
464 let path = root.join("src/lib.rs");
465 std::fs::write(&path, "").unwrap();
466
467 let raw = format!("\"{}:42\",", path.display());
468 assert_eq!(
469 workspace_file_candidate(&raw, root),
470 Some("src/lib.rs".to_string())
471 );
472 }
473
474 #[test]
475 fn tool_path_relevance_extracts_paths_from_command_text() {
476 let dir = TempDir::new().expect("tempdir");
477 let root = dir.path();
478 std::fs::create_dir_all(root.join("src")).unwrap();
479 std::fs::write(root.join("src/alpha.rs"), "").unwrap();
480 std::fs::write(root.join("src/zeta.rs"), "").unwrap();
481
482 let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();
483 let mut seen = HashSet::new();
484 let mut budget = 16;
485 mark_tool_paths_from_text(
486 "sed -n '1,20p' src/zeta.rs",
487 root,
488 &mut seen,
489 &mut relevance,
490 &mut budget,
491 );
492
493 let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);
494 assert_eq!(view.selected_for_test(), Some("src/zeta.rs"));
495 }
496
497 fn create_test_app() -> App {
498 let options = TuiOptions {
499 model: "deepseek-v4-pro".to_string(),
500 workspace: PathBuf::from("."),
501 config_path: None,
502 config_profile: None,
503 allow_shell: false,
504 use_alt_screen: true,
505 use_mouse_capture: false,
506 use_bracketed_paste: true,
507 max_subagents: 1,
508 skills_dir: PathBuf::from("."),
509 memory_path: PathBuf::from("memory.md"),
510 notes_path: PathBuf::from("notes.txt"),
511 mcp_config_path: PathBuf::from("mcp.json"),
512 use_memory: false,
513 start_in_agent_mode: false,
514 skip_onboarding: false,
515 yolo: false,
516 resume_session_id: None,
517 initial_input: None,
518 };
519 App::new(options, &Config::default())
520 }
521
522 #[tokio::test]
523 async fn drain_web_config_events_applies_draft_without_closing_session() {
524 let mut app = create_test_app();
525 let mut config = Config::default();
526 let engine = mock_engine_handle();
527 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
528 let doc = config_ui::build_document(&app, &config).expect("document");
529 tx.send(WebConfigSessionEvent::Draft(doc))
530 .expect("send draft");
531 let mut session = Some(WebConfigSession::for_test(rx));
532
533 let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;
534
535 assert!(keep);
536 assert!(session.is_some());
537 }
538
539 #[tokio::test]
540 async fn drain_web_config_events_closes_session_after_commit() {
541 let mut app = create_test_app();
542 let mut config = Config::default();
543 let engine = mock_engine_handle();
544 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
545 let doc = config_ui::build_document(&app, &config).expect("document");
546 tx.send(WebConfigSessionEvent::Committed(doc))
547 .expect("send commit");
548 let mut session = Some(WebConfigSession::for_test(rx));
549
550 let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;
551
552 assert!(!keep);
553 }
554
555 #[test]
556 fn backtrack_prefill_rehydrates_attachment_rows() {
557 let mut app = create_test_app();
558 let user_text = "inspect this\n[Attached image: /tmp/pasted.png]";
559 app.add_message(HistoryCell::User {
560 content: user_text.to_string(),
561 });
562 app.api_messages.push(Message {
563 role: "user".to_string(),
564 content: vec![ContentBlock::Text {
565 text: user_text.to_string(),
566 cache_control: None,
567 }],
568 });
569 app.add_message(HistoryCell::Assistant {
570 content: "done".to_string(),
571 streaming: false,
572 });
573 app.api_messages.push(Message {
574 role: "assistant".to_string(),
575 content: vec![ContentBlock::Text {
576 text: "done".to_string(),
577 cache_control: None,
578 }],
579 });
580
581 apply_backtrack(&mut app, 0);
582
583 assert_eq!(app.input, user_text);
584 assert_eq!(app.composer_attachment_count(), 1);
585 }
586
587 #[test]
588 fn active_tool_status_label_summarizes_live_tool_group() {
589 let mut app = create_test_app();
590 app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));
591 let mut active = ActiveCell::new();
592 active.push_tool(
593 "exec-1",
594 HistoryCell::Tool(ToolCell::Exec(ExecCell {
595 command: "cargo test --workspace --all-features".to_string(),
596 status: ToolStatus::Running,
597 output: None,
598 started_at: app.turn_started_at,
599 duration_ms: None,
600 source: ExecSource::Assistant,
601 interaction: None,
602 })),
603 );
604 active.push_tool(
605 "tool-2",
606 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
607 name: "grep_files".to_string(),
608 status: ToolStatus::Success,
609 input_summary: Some("pattern: TODO".to_string()),
610 output: Some("done".to_string()),
611 prompts: None,
612 spillover_path: None,
613 })),
614 );
615 app.active_cell = Some(active);
616
617 let label = active_tool_status_label(&app).expect("status label");
618
619 assert!(label.contains("run cargo test"));
620 assert!(label.contains("1 active"));
621 assert!(label.contains("1 done"));
622 assert!(label.contains("Alt+V"));
623 }
624
625 #[test]
626 fn active_tool_status_label_counts_foreground_rlm_work() {
627 let mut app = create_test_app();
628 app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));
629 let mut active = ActiveCell::new();
630 active.push_tool(
631 "rlm-1",
632 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
633 name: "rlm".to_string(),
634 status: ToolStatus::Running,
635 input_summary: Some("task: compare projects".to_string()),
636 output: None,
637 prompts: None,
638 spillover_path: None,
639 })),
640 );
641 app.active_cell = Some(active);
642
643 let label = active_tool_status_label(&app).expect("status label");
644
645 assert!(label.contains("tool rlm"), "label: {label}");
646 assert!(label.contains("1 active"), "label: {label}");
647 }
648
649 #[test]
650 fn terminal_probe_timeout_defaults_to_500ms() {
651 let config = Config::default();
652
653 assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));
654 }
655
656 #[test]
657 fn terminal_probe_timeout_uses_tui_config_and_clamps() {
658 let mut config = Config {
659 tui: Some(crate::config::TuiConfig {
660 alternate_screen: None,
661 mouse_capture: None,
662 terminal_probe_timeout_ms: Some(750),
663 status_items: None,
664 osc8_links: None,
665 notification_condition: None,
666 }),
667 ..Config::default()
668 };
669
670 assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));
671
672 config
673 .tui
674 .as_mut()
675 .expect("tui config")
676 .terminal_probe_timeout_ms = Some(0);
677 assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));
678
679 config
680 .tui
681 .as_mut()
682 .expect("tui config")
683 .terminal_probe_timeout_ms = Some(60_000);
684 assert_eq!(
685 terminal_probe_timeout(&config),
686 Duration::from_millis(5_000)
687 );
688 }
689
690 #[test]
691 fn file_mentions_add_local_text_context_to_model_payload() {
692 let tmpdir = TempDir::new().expect("tempdir");
693 std::fs::write(
694 tmpdir.path().join("guide.md"),
695 "# Guide\nUse the fast path.\n",
696 )
697 .expect("write file");
698 let mut app = create_test_app();
699 app.workspace = tmpdir.path().to_path_buf();
700 let message = QueuedMessage::new("Summarize @guide.md".to_string(), None);
701
702 let content = queued_message_content_for_app(&app, &message, None);
703
704 assert!(content.starts_with("Summarize @guide.md"));
705 assert!(content.contains("Local context from @mentions:"));
706 assert!(content.contains("<file mention=\"@guide.md\""));
707 assert!(content.contains("# Guide\nUse the fast path."));
708 assert_eq!(message.display, "Summarize @guide.md");
709 }
710
711 #[test]
712 fn compact_user_context_display_hides_persisted_mention_block() {
713 let content = "Summarize @guide.md\n\n---\n\nLocal context from @mentions:\n<file>large</file>";
714
715 assert_eq!(compact_user_context_display(content), "Summarize @guide.md");
716 }
717
718 #[test]
719 fn file_mentions_do_not_trigger_inside_email_addresses() {
720 let tmpdir = TempDir::new().expect("tempdir");
721 std::fs::write(tmpdir.path().join("example.com"), "not a mention").expect("write file");
722
723 let content = user_request_with_file_mentions("email me@example.com", tmpdir.path(), None);
724
725 assert_eq!(content, "email me@example.com");
726 }
727
728 #[test]
729 fn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {
730 let tmpdir = TempDir::new().expect("tempdir");
731 std::fs::write(tmpdir.path().join("photo.png"), b"\0png").expect("write image");
732
733 let content = user_request_with_file_mentions("inspect @photo.png", tmpdir.path(), None);
734
735 assert!(content.contains("<media-file mention=\"@photo.png\""));
736 assert!(content.contains("Use /attach photo.png"));
737 assert!(!content.contains("\0png"));
738 }
739
740 #[tokio::test]
741 async fn model_change_update_syncs_engine_model_before_compaction() {
742 let mut app = create_test_app();
743 app.model = "deepseek-v4-flash".to_string();
744 let compaction = app.compaction_config();
745 let mut engine = crate::core::engine::mock_engine_handle();
746
747 apply_model_and_compaction_update(&engine.handle, compaction).await;
748
749 match engine.rx_op.recv().await.expect("set model op") {
750 crate::core::ops::Op::SetModel { model } => {
751 assert_eq!(model, "deepseek-v4-flash");
752 }
753 other => panic!("expected SetModel, got {other:?}"),
754 }
755
756 match engine.rx_op.recv().await.expect("set compaction op") {
757 crate::core::ops::Op::SetCompaction { config } => {
758 assert_eq!(config.model, "deepseek-v4-flash");
759 }
760 other => panic!("expected SetCompaction, got {other:?}"),
761 }
762 }
763
764 #[tokio::test]
765 async fn dispatch_user_message_failed_send_clears_loading_state() {
766 let mut app = create_test_app();
767 let engine = mock_engine_handle();
768 let config = Config::default();
769 drop(engine.rx_op);
770
771 let result = dispatch_user_message(
772 &mut app,
773 &config,
774 &engine.handle,
775 QueuedMessage::new("hello".to_string(), None),
776 )
777 .await;
778
779 assert!(
780 result.is_err(),
781 "dispatch should fail when engine channel is closed"
782 );
783 assert!(
784 !app.is_loading,
785 "failed dispatch must not leave the composer in a permanent busy state"
786 );
787 assert!(app.last_send_at.is_none());
788 }
789
790 fn init_git_repo() -> TempDir {
791 let dir = tempfile::tempdir().expect("tempdir");
792
793 let init = Command::new("git")
794 .arg("init")
795 .current_dir(dir.path())
796 .output()
797 .expect("git init should run");
798 assert!(
799 init.status.success(),
800 "git init failed: {}",
801 String::from_utf8_lossy(&init.stderr)
802 );
803
804 let commit = Command::new("git")
805 .args([
806 "-c",
807 "user.name=DeepSeek TUI Tests",
808 "-c",
809 "user.email=tests@example.com",
810 "commit",
811 "--allow-empty",
812 "-m",
813 "init",
814 ])
815 .current_dir(dir.path())
816 .output()
817 .expect("git commit should run");
818 assert!(
819 commit.status.success(),
820 "git commit failed: {}",
821 String::from_utf8_lossy(&commit.stderr)
822 );
823
824 dir
825 }
826
827 fn spans_text(spans: &[Span<'_>]) -> String {
828 spans
829 .iter()
830 .map(|span| span.content.as_ref())
831 .collect::<String>()
832 }
833
834 #[test]
835 fn alt_4_focuses_agents_sidebar_without_switching_modes() {
836 let mut app = create_test_app();
837 app.mode = AppMode::Agent;
838 app.sidebar_focus = SidebarFocus::Auto;
839
840 apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);
841
842 assert_eq!(app.mode, AppMode::Agent);
843 assert_eq!(app.sidebar_focus, SidebarFocus::Agents);
844 assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents"));
845 }
846
847 #[test]
848 fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {
849 let mut app = create_test_app();
850 app.mode = AppMode::Agent;
851 app.sidebar_focus = SidebarFocus::Auto;
852
853 apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);
854
855 assert_eq!(app.mode, AppMode::Agent);
856 assert_eq!(app.sidebar_focus, SidebarFocus::Agents);
857 assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents"));
858 }
859
860 fn make_subagent(
861 id: &str,
862 status: crate::tools::subagent::SubAgentStatus,
863 ) -> crate::tools::subagent::SubAgentResult {
864 crate::tools::subagent::SubAgentResult {
865 agent_id: id.to_string(),
866 agent_type: crate::tools::subagent::SubAgentType::General,
867 assignment: crate::tools::subagent::SubAgentAssignment {
868 objective: format!("objective-{id}"),
869 role: Some("worker".to_string()),
870 },
871 model: "deepseek-v4-flash".to_string(),
872 nickname: None,
873 status,
874 result: None,
875 steps_taken: 0,
876 duration_ms: 0,
877 from_prior_session: false,
878 }
879 }
880
881 #[test]
882 fn sort_subagents_orders_running_before_terminal_statuses() {
883 let mut agents = vec![
884 make_subagent("agent_c", crate::tools::subagent::SubAgentStatus::Completed),
885 make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
886 make_subagent(
887 "agent_b",
888 crate::tools::subagent::SubAgentStatus::Failed("boom".to_string()),
889 ),
890 ];
891
892 sort_subagents_in_place(&mut agents);
893
894 assert_eq!(agents[0].agent_id, "agent_a");
895 assert_eq!(agents[1].agent_id, "agent_b");
896 assert_eq!(agents[2].agent_id, "agent_c");
897 }
898
899 #[test]
900 fn running_agent_count_unions_cache_and_progress() {
901 let mut app = create_test_app();
902 app.subagent_cache = vec![
903 make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
904 make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Completed),
905 ];
906 app.agent_progress
907 .insert("agent_c".to_string(), "planning".to_string());
908
909 assert_eq!(running_agent_count(&app), 2);
910 }
911
912 #[test]
913 fn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {
914 let mut app = create_test_app();
915 app.subagent_cache = vec![
916 make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
917 make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Completed),
918 ];
919 app.agent_progress
920 .insert("agent_stale".to_string(), "old".to_string());
921
922 reconcile_subagent_activity_state(&mut app);
923 assert!(app.agent_progress.contains_key("agent_a"));
924 assert!(!app.agent_progress.contains_key("agent_stale"));
925 assert!(app.agent_activity_started_at.is_some());
926
927 app.subagent_cache.clear();
928 reconcile_subagent_activity_state(&mut app);
929 assert!(app.agent_progress.is_empty());
930 assert!(app.agent_activity_started_at.is_none());
931 }
932
933 #[test]
934 fn subagent_token_usage_updates_live_cost_counter_without_card_change() {
935 let mut app = create_test_app();
936 handle_subagent_mailbox(
937 &mut app,
938 1,
939 &crate::tools::subagent::MailboxMessage::TokenUsage {
940 agent_id: "agent-a".to_string(),
941 model: "deepseek-v4-flash".to_string(),
942 usage: crate::models::Usage {
943 input_tokens: 10_000,
944 output_tokens: 1_000,
945 ..Default::default()
946 },
947 },
948 );
949
950 assert!(app.session.subagent_cost > 0.0);
951 assert!(
952 app.history.is_empty(),
953 "usage-only mailbox messages should not allocate a sub-agent card"
954 );
955 }
956
957 #[test]
958 fn subagent_token_usage_is_deduped_by_mailbox_sequence() {
959 let mut app = create_test_app();
960 let usage = crate::tools::subagent::MailboxMessage::TokenUsage {
961 agent_id: "agent-a".to_string(),
962 model: "deepseek-v4-flash".to_string(),
963 usage: crate::models::Usage {
964 input_tokens: 10_000,
965 output_tokens: 1_000,
966 ..Default::default()
967 },
968 };
969
970 handle_subagent_mailbox(&mut app, 7, &usage);
971 let first = app.session.subagent_cost;
972 handle_subagent_mailbox(&mut app, 7, &usage);
973 assert_eq!(app.session.subagent_cost, first);
974 handle_subagent_mailbox(&mut app, 8, &usage);
975 assert!(app.session.subagent_cost > first);
976 }
977
978 #[test]
979 fn format_token_count_compact_formats_units() {
980 assert_eq!(format_token_count_compact(999), "999");
981 assert_eq!(format_token_count_compact(1_200), "1.2k");
982 assert_eq!(format_token_count_compact(1_000_000), "1.0M");
983 }
984
985 #[test]
986 fn format_context_budget_caps_overflow_display() {
987 assert_eq!(format_context_budget(5_000, 128_000), "5.0k/128.0k");
988 assert_eq!(format_context_budget(250_000, 128_000), ">128.0k/128.0k");
989 }
990
991 #[test]
992 fn footer_state_label_drops_thinking_and_prefers_compacting() {
993 // We deliberately do not surface a "thinking" label for `is_loading` —
994 // the animated water-spout strip in the footer's spacer is the visual
995 // signal. `is_loading` alone falls through to "ready"; `is_compacting`
996 // still wins because compacting is a less-common, distinct state.
997 let mut app = create_test_app();
998 assert_eq!(footer_state_label(&app).0, "ready");
999
1000 app.is_loading = true;
1001 assert_eq!(
1002 footer_state_label(&app).0,
1003 "ready",
1004 "is_loading must NOT produce a `thinking` text label — the animation handles it"
1005 );
1006
1007 app.is_compacting = true;
1008 assert!(footer_state_label(&app).0.starts_with("compacting"));
1009 }
1010
1011 #[test]
1012 fn event_poll_timeout_has_nonzero_floor() {
1013 assert_eq!(
1014 clamp_event_poll_timeout(Duration::ZERO),
1015 Duration::from_millis(1)
1016 );
1017 assert_eq!(
1018 clamp_event_poll_timeout(Duration::from_micros(250)),
1019 Duration::from_millis(1)
1020 );
1021 assert_eq!(
1022 clamp_event_poll_timeout(Duration::from_millis(24)),
1023 Duration::from_millis(24)
1024 );
1025 }
1026
1027 #[test]
1028 fn footer_status_line_spans_show_mode_and_model_idle_and_active() {
1029 let mut app = create_test_app();
1030 app.model = "deepseek-v4-flash".to_string();
1031
1032 let idle = spans_text(&footer_status_line_spans(&app, 60));
1033 assert!(idle.contains("agent"));
1034 assert!(idle.contains("deepseek-v4-flash"));
1035 assert!(idle.contains("\u{00B7}"));
1036 assert!(!idle.contains("ready"));
1037
1038 // is_loading no longer adds a "thinking" text label — the live-work
1039 // signal is the animated water-spout strip the renderer paints into
1040 // the footer's spacer. The mode + model still render unchanged.
1041 app.is_loading = true;
1042 let active = spans_text(&footer_status_line_spans(&app, 60));
1043 assert!(active.contains("agent"));
1044 assert!(active.contains("deepseek-v4-flash"));
1045 assert!(
1046 !active.contains("thinking"),
1047 "footer must not show a `thinking` text label while loading"
1048 );
1049 }
1050
1051 #[test]
1052 fn footer_status_line_spans_truncate_long_model_names() {
1053 let mut app = create_test_app();
1054 app.model = "deepseek-v4-pro-with-an-extremely-long-model-name".to_string();
1055 app.is_loading = true;
1056
1057 let line = spans_text(&footer_status_line_spans(&app, 40));
1058 assert!(line.contains("..."));
1059 assert!(UnicodeWidthStr::width(line.as_str()) <= 40);
1060 }
1061
1062 #[test]
1063 fn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {
1064 let mut app = create_test_app();
1065
1066 app.coherence_state = crate::core::coherence::CoherenceState::Healthy;
1067 assert!(
1068 footer_coherence_spans(&app).is_empty(),
1069 "healthy state should produce no footer chip"
1070 );
1071
1072 // GettingCrowded is intentionally suppressed — see the rationale in
1073 // `footer_coherence_spans`. The footer only surfaces active engine
1074 // interventions; soft pressure hints stay quiet.
1075 app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;
1076 assert!(
1077 footer_coherence_spans(&app).is_empty(),
1078 "GettingCrowded should not surface a footer chip; only active interventions do"
1079 );
1080
1081 let cases = [
1082 (
1083 crate::core::coherence::CoherenceState::RefreshingContext,
1084 "refreshing context",
1085 ),
1086 (
1087 crate::core::coherence::CoherenceState::VerifyingRecentWork,
1088 "verifying",
1089 ),
1090 (
1091 crate::core::coherence::CoherenceState::ResettingPlan,
1092 "resetting plan",
1093 ),
1094 ];
1095
1096 for (state, expected) in cases {
1097 app.coherence_state = state;
1098 assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);
1099 }
1100 }
1101
1102 #[test]
1103 fn footer_auxiliary_spans_show_cache_when_compact() {
1104 let mut app = create_test_app();
1105 app.is_loading = true;
1106 app.session.last_prompt_tokens = Some(48_000);
1107 app.session.last_prompt_cache_hit_tokens = Some(36_000);
1108 app.session.last_prompt_cache_miss_tokens = Some(12_000);
1109 app.session.session_cost = 12.34;
1110
1111 let compact = spans_text(&footer_auxiliary_spans(&app, 14));
1112 assert!(compact.contains("cache"));
1113 assert!(!compact.contains('$'));
1114 }
1115
1116 #[test]
1117 fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
1118 let mut app = create_test_app();
1119 app.session.last_prompt_tokens = Some(48_000);
1120 app.session.last_prompt_cache_hit_tokens = Some(36_000);
1121 app.session.last_prompt_cache_miss_tokens = Some(12_000);
1122 app.session.session_cost = 12.34;
1123
1124 let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
1125 assert!(roomy.contains("cache hit 75%"));
1126 assert!(roomy.contains("$12.34"));
1127 assert!(
1128 !roomy.contains("ctx"),
1129 "context % removed from footer — shown in header only"
1130 );
1131 }
1132
1133 #[test]
1134 fn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {
1135 let mut app = create_test_app();
1136 app.session.session_cost = 0.00005;
1137
1138 let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
1139 assert!(roomy.contains("<$0.0001"));
1140 }
1141
1142 #[test]
1143 fn footer_auxiliary_spans_use_configured_cost_currency() {
1144 let mut app = create_test_app();
1145 app.cost_currency = crate::pricing::CostCurrency::Cny;
1146 app.session.session_cost_cny = 2.5;
1147
1148 let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
1149 assert!(roomy.contains("¥2.50"));
1150 assert!(!roomy.contains('$'));
1151 }
1152
1153 #[test]
1154 fn footer_auxiliary_spans_show_reasoning_replay_chip() {
1155 // Issue #30: when a thinking-mode tool-calling turn replays prior
1156 // reasoning_content, the footer surfaces the approximate input-token
1157 // cost so users can see why their context filled up.
1158 let mut app = create_test_app();
1159 app.session.last_prompt_tokens = Some(48_000);
1160 app.session.last_reasoning_replay_tokens = Some(8_200);
1161
1162 let spans = footer_auxiliary_spans(&app, 64);
1163 let text = spans_text(&spans);
1164 assert!(
1165 text.contains("rsn 8.2k"),
1166 "expected replay chip, got {text:?}"
1167 );
1168 }
1169
1170 #[test]
1171 fn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {
1172 let mut app = create_test_app();
1173 app.session.last_prompt_tokens = Some(48_000);
1174 app.session.last_reasoning_replay_tokens = Some(0);
1175
1176 let spans = footer_auxiliary_spans(&app, 64);
1177 let text = spans_text(&spans);
1178 assert!(!text.contains("rsn"), "zero replay must not render chip");
1179 }
1180
1181 #[test]
1182 fn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {
1183 let mut app = create_test_app();
1184 app.session.last_prompt_tokens = Some(1_200_000);
1185 app.api_messages = vec![Message {
1186 role: "user".to_string(),
1187 content: vec![ContentBlock::Text {
1188 text: "hello".to_string(),
1189 cache_control: None,
1190 }],
1191 }];
1192
1193 let (used, max, percent) =
1194 context_usage_snapshot(&app).expect("context usage should be available");
1195 assert_eq!(max, 1_000_000);
1196 assert!(used > 0);
1197 assert!(used <= i64::from(max));
1198 assert!(percent < 100.0);
1199 }
1200
1201 #[test]
1202 fn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {
1203 let mut app = create_test_app();
1204 app.session.last_prompt_tokens = Some(980_000);
1205 app.api_messages = vec![Message {
1206 role: "user".to_string(),
1207 content: vec![ContentBlock::Text {
1208 text: "small current context".to_string(),
1209 cache_control: None,
1210 }],
1211 }];
1212
1213 let (used, max, percent) =
1214 context_usage_snapshot(&app).expect("context usage should be available");
1215 assert_eq!(max, 1_000_000);
1216 assert!(used < 10_000);
1217 assert!(percent < 2.0);
1218 }
1219
1220 /// Regression for #115. The engine sums `input_tokens` across every round
1221 /// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn
1222 /// reports a value much larger than the actual context window state, then
1223 /// the next single-round turn drops back to a single round's input_tokens.
1224 /// User-visible % was bouncing 31% → 9% because of this. The fix is to
1225 /// prefer the estimated current-context size, which is monotonic wrt
1226 /// conversation growth.
1227 #[test]
1228 fn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {
1229 let mut app = create_test_app();
1230 app.api_messages = vec![Message {
1231 role: "user".to_string(),
1232 content: vec![ContentBlock::Text {
1233 text: "context ".repeat(2_000), // ~14k tokens estimated
1234 cache_control: None,
1235 }],
1236 }];
1237
1238 // Simulate a multi-round turn that summed two rounds' input_tokens
1239 // (e.g., 200k + 210k from a long thinking + tool-call sequence).
1240 app.session.last_prompt_tokens = Some(410_000);
1241 let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect("usage available");
1242
1243 // Now the next turn is a single round on the same conversation —
1244 // reported drops to one round's worth even though the actual context
1245 // hasn't shrunk.
1246 app.session.last_prompt_tokens = Some(15_000);
1247 let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect("usage available");
1248
1249 // The displayed % should reflect the conversation size (estimated
1250 // from api_messages), NOT the wildly variable reported value.
1251 let drift = (percent_after_multi_round - percent_after_single_round).abs();
1252 assert!(
1253 drift < 1.0,
1254 "displayed % should not jump because reported tokens varied across rounds; \
1255 after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}"
1256 );
1257 }
1258
1259 #[test]
1260 fn context_usage_snapshot_prefers_live_estimate_while_loading() {
1261 let mut app = create_test_app();
1262 app.is_loading = true;
1263 app.session.last_prompt_tokens = Some(128);
1264 app.api_messages = vec![Message {
1265 role: "user".to_string(),
1266 content: vec![ContentBlock::Text {
1267 text: "context ".repeat(6_000),
1268 cache_control: None,
1269 }],
1270 }];
1271
1272 let estimated = estimated_context_tokens(&app).expect("estimated context should be available");
1273 let (used, max, percent) =
1274 context_usage_snapshot(&app).expect("context usage should be available");
1275 assert_eq!(used, estimated);
1276 assert_eq!(max, 1_000_000);
1277 assert!(used > i64::from(app.session.last_prompt_tokens.expect("reported tokens")));
1278 assert!(percent > 0.0);
1279 }
1280
1281 #[test]
1282 fn should_auto_compact_before_send_respects_threshold_and_setting() {
1283 let mut app = create_test_app();
1284 let big_buffer = vec![Message {
1285 role: "user".to_string(),
1286 content: vec![ContentBlock::Text {
1287 text: "context ".repeat(400_000),
1288 cache_control: None,
1289 }],
1290 }];
1291
1292 // High estimated context + auto_compact ON → auto-compact triggers.
1293 app.api_messages = big_buffer.clone();
1294 app.auto_compact = true;
1295 assert!(should_auto_compact_before_send(&app));
1296
1297 // Same high context but auto_compact OFF → never triggers.
1298 app.auto_compact = false;
1299 assert!(!should_auto_compact_before_send(&app));
1300
1301 // Small estimated context + auto_compact ON → does NOT trigger,
1302 // regardless of what `last_prompt_tokens` reports. This matches the
1303 // #115 fix: the estimate is the primary signal, not the engine's
1304 // turn-cumulative reported value (which used to rule the displayed
1305 // % and could spuriously trigger / suppress auto-compact).
1306 app.api_messages = vec![Message {
1307 role: "user".to_string(),
1308 content: vec![ContentBlock::Text {
1309 text: "small".to_string(),
1310 cache_control: None,
1311 }],
1312 }];
1313 app.auto_compact = true;
1314 app.session.last_prompt_tokens = Some(10_000);
1315 assert!(!should_auto_compact_before_send(&app));
1316 }
1317
1318 // ============================================================================
1319 // Streaming Cancel Behavior Tests
1320 // ============================================================================
1321
1322 #[test]
1323 fn test_esc_cancels_streaming_sets_is_loading_false() {
1324 let mut app = create_test_app();
1325 app.is_loading = true;
1326 app.mode = AppMode::Agent;
1327
1328 // Simulate what happens in ui.rs when Esc is pressed during loading:
1329 // engine_handle.cancel() is called (can't test directly - private)
1330 // Then these state changes occur:
1331 app.is_loading = false;
1332 app.status_message = Some("Request cancelled".to_string());
1333
1334 assert!(!app.is_loading);
1335 assert_eq!(app.status_message, Some("Request cancelled".to_string()));
1336 }
1337
1338 #[test]
1339 fn test_esc_with_input_clears_input_when_not_loading() {
1340 let mut app = create_test_app();
1341 app.is_loading = false;
1342 app.input = "some draft input".to_string();
1343 app.cursor_position = app.input.chars().count();
1344
1345 // Simulate Esc key press when not loading but input not empty
1346 app.clear_input();
1347
1348 assert!(app.input.is_empty());
1349 assert_eq!(app.cursor_position, 0);
1350 assert!(!app.is_loading);
1351 }
1352
1353 #[test]
1354 fn test_esc_discards_queued_draft_before_clearing_input() {
1355 let mut app = create_test_app();
1356 app.is_loading = false;
1357 app.input.clear();
1358 app.queued_draft = Some(crate::tui::app::QueuedMessage::new(
1359 "queued draft".to_string(),
1360 None,
1361 ));
1362
1363 assert_eq!(
1364 next_escape_action(&app, false),
1365 EscapeAction::DiscardQueuedDraft
1366 );
1367 }
1368
1369 #[test]
1370 fn test_esc_is_noop_when_idle() {
1371 let mut app = create_test_app();
1372 app.is_loading = false;
1373 app.input.clear();
1374 app.cursor_position = 0;
1375 app.mode = AppMode::Agent;
1376
1377 assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);
1378 assert_eq!(app.mode, AppMode::Agent);
1379 }
1380
1381 #[test]
1382 fn test_esc_closes_slash_menu_before_other_actions() {
1383 let mut app = create_test_app();
1384 app.is_loading = true;
1385 app.input = "draft".to_string();
1386 app.queued_draft = Some(crate::tui::app::QueuedMessage::new(
1387 "queued draft".to_string(),
1388 None,
1389 ));
1390
1391 assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);
1392 }
1393
1394 #[test]
1395 fn test_ctrl_c_cancels_streaming_sets_status() {
1396 let mut app = create_test_app();
1397 app.is_loading = true;
1398
1399 // Simulate Ctrl+C during loading state
1400 // engine_handle.cancel() is called (can't test directly - private)
1401 app.is_loading = false;
1402 app.status_message = Some("Request cancelled".to_string());
1403
1404 assert!(!app.is_loading);
1405 assert_eq!(app.status_message, Some("Request cancelled".to_string()));
1406 }
1407
1408 #[test]
1409 fn test_ctrl_c_exits_when_not_loading() {
1410 let mut app = create_test_app();
1411 app.is_loading = false;
1412
1413 // Ctrl+C when not loading should trigger shutdown
1414 // We can't test the actual shutdown, but verify the state is correct
1415 // for the shutdown path to be taken
1416 assert!(!app.is_loading);
1417 }
1418
1419 #[test]
1420 fn test_ctrl_d_exits_when_input_empty() {
1421 let mut app = create_test_app();
1422 app.input.clear();
1423
1424 // Ctrl+D when input empty should trigger shutdown
1425 assert!(app.input.is_empty());
1426 }
1427
1428 #[test]
1429 fn test_ctrl_d_does_nothing_when_input_not_empty() {
1430 let mut app = create_test_app();
1431 app.input = "some input".to_string();
1432
1433 // Ctrl+D when input not empty should not trigger shutdown
1434 assert!(!app.input.is_empty());
1435 }
1436
1437 #[test]
1438 fn test_esc_priority_order_matches_cancel_stack() {
1439 let mut app = create_test_app();
1440 app.is_loading = true;
1441 app.input = "draft".to_string();
1442 app.mode = AppMode::Yolo;
1443 assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
1444
1445 app.input.clear();
1446 assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
1447
1448 app.is_loading = false;
1449 app.input = "draft".to_string();
1450 assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);
1451
1452 app.input.clear();
1453 app.queued_draft = Some(crate::tui::app::QueuedMessage::new(
1454 "queued draft".to_string(),
1455 None,
1456 ));
1457 assert_eq!(
1458 next_escape_action(&app, false),
1459 EscapeAction::DiscardQueuedDraft
1460 );
1461
1462 app.queued_draft = None;
1463 assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);
1464 }
1465
1466 #[test]
1467 fn visible_slash_menu_entries_respects_hide_flag() {
1468 let mut app = create_test_app();
1469 app.input = "/mo".to_string();
1470 app.slash_menu_hidden = false;
1471
1472 let entries = visible_slash_menu_entries(&app, 6);
1473 assert!(!entries.is_empty());
1474
1475 app.slash_menu_hidden = true;
1476 let hidden_entries = visible_slash_menu_entries(&app, 6);
1477 assert!(hidden_entries.is_empty());
1478 }
1479
1480 #[test]
1481 fn visible_slash_menu_entries_excludes_removed_commands() {
1482 let mut app = create_test_app();
1483 app.input = "/".to_string();
1484
1485 let entries = visible_slash_menu_entries(&app, 128);
1486 assert!(entries.iter().any(|entry| entry.name == "/config"));
1487 assert!(entries.iter().any(|entry| entry.name == "/links"));
1488 assert!(!entries.iter().any(|entry| entry.name == "/set"));
1489 assert!(!entries.iter().any(|entry| entry.name == "/deepseek"));
1490 }
1491
1492 #[test]
1493 fn apply_slash_menu_selection_appends_space_for_arg_commands() {
1494 let mut app = create_test_app();
1495 let entries = vec![
1496 crate::tui::widgets::SlashMenuEntry {
1497 name: "/model".to_string(),
1498 description: String::new(),
1499 is_skill: false,
1500 },
1501 crate::tui::widgets::SlashMenuEntry {
1502 name: "/settings".to_string(),
1503 description: String::new(),
1504 is_skill: false,
1505 },
1506 ];
1507 app.slash_menu_selected = 0;
1508 assert!(apply_slash_menu_selection(&mut app, &entries, true));
1509 assert_eq!(app.input, "/model ");
1510 }
1511
1512 #[test]
1513 fn apply_slash_menu_selection_uses_skill_command_form() {
1514 let mut app = create_test_app();
1515 let entries = vec![crate::tui::widgets::SlashMenuEntry {
1516 name: "/skill search-files".to_string(),
1517 description: "Search files".to_string(),
1518 is_skill: true,
1519 }];
1520
1521 assert!(apply_slash_menu_selection(&mut app, &entries, true));
1522 assert_eq!(app.input, "/skill search-files");
1523 }
1524
1525 #[test]
1526 fn try_autocomplete_slash_command_completes_skill_argument() {
1527 let mut app = create_test_app();
1528 app.cached_skills = vec![
1529 ("search-files".to_string(), "Search files".to_string()),
1530 ("my-review".to_string(), "Review code".to_string()),
1531 ];
1532 app.input = "/skill my".to_string();
1533 app.cursor_position = app.input.chars().count();
1534
1535 assert!(try_autocomplete_slash_command(&mut app));
1536 assert_eq!(app.input, "/skill my-review");
1537 }
1538
1539 #[test]
1540 fn workspace_context_refresh_is_deferred_while_ui_is_busy() {
1541 let repo = init_git_repo();
1542 let mut app = create_test_app();
1543 app.workspace = repo.path().to_path_buf();
1544
1545 let now = Instant::now();
1546 refresh_workspace_context_if_needed(&mut app, now, false);
1547
1548 assert!(app.workspace_context.is_none());
1549 assert!(app.workspace_context_refreshed_at.is_none());
1550
1551 refresh_workspace_context_if_needed(&mut app, now, true);
1552
1553 let context = app
1554 .workspace_context
1555 .as_deref()
1556 .expect("idle refresh should populate workspace context");
1557 assert!(context.contains("clean"));
1558 assert_eq!(app.workspace_context_refreshed_at, Some(now));
1559 }
1560
1561 #[test]
1562 fn workspace_context_refresh_respects_ttl_before_requerying_git() {
1563 let repo = init_git_repo();
1564 let mut app = create_test_app();
1565 app.workspace = repo.path().to_path_buf();
1566
1567 let start = Instant::now();
1568 refresh_workspace_context_if_needed(&mut app, start, true);
1569 let initial = app
1570 .workspace_context
1571 .clone()
1572 .expect("initial refresh should populate context");
1573
1574 std::fs::write(repo.path().join("dirty.txt"), "dirty").expect("write dirty marker");
1575
1576 let before_ttl = start + Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS - 1);
1577 refresh_workspace_context_if_needed(&mut app, before_ttl, true);
1578 assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));
1579
1580 let after_ttl = start + Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS);
1581 refresh_workspace_context_if_needed(&mut app, after_ttl, true);
1582 let refreshed = app
1583 .workspace_context
1584 .as_deref()
1585 .expect("refresh after ttl should update context");
1586 assert!(refreshed.contains("untracked"));
1587 assert_ne!(refreshed, initial);
1588 }
1589
1590 #[tokio::test]
1591 async fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {
1592 let mut app = create_test_app();
1593 app.mode = AppMode::Plan;
1594 app.plan_prompt_pending = true;
1595 app.offline_mode = true;
1596
1597 let engine = crate::core::engine::mock_engine_handle();
1598 let config = Config::default();
1599
1600 let handled = handle_plan_choice(&mut app, &config, &engine.handle, "yolo")
1601 .await
1602 .expect("plan choice");
1603
1604 assert!(!handled);
1605 assert!(!app.plan_prompt_pending);
1606 assert_eq!(app.mode, AppMode::Plan);
1607
1608 let queued = build_queued_message(&mut app, "yolo".to_string());
1609 submit_or_steer_message(&mut app, &config, &engine.handle, queued)
1610 .await
1611 .expect("submit normal message");
1612
1613 assert_eq!(app.queued_message_count(), 1);
1614 assert_eq!(
1615 app.queued_messages
1616 .front()
1617 .map(crate::tui::app::QueuedMessage::content),
1618 Some("yolo".to_string())
1619 );
1620 assert_eq!(
1621 app.status_message.as_deref(),
1622 Some("Offline: 1 queued — ↑ to edit, /queue list")
1623 );
1624 }
1625
1626 #[tokio::test]
1627 async fn numeric_plan_choice_still_queues_follow_up_when_busy() {
1628 let mut app = create_test_app();
1629 app.mode = AppMode::Plan;
1630 app.plan_prompt_pending = true;
1631 app.is_loading = true;
1632
1633 let engine = crate::core::engine::mock_engine_handle();
1634 let config = Config::default();
1635
1636 let handled = handle_plan_choice(&mut app, &config, &engine.handle, "2")
1637 .await
1638 .expect("plan choice");
1639
1640 assert!(handled);
1641 assert!(!app.plan_prompt_pending);
1642 assert_eq!(app.mode, AppMode::Yolo);
1643 assert_eq!(app.queued_message_count(), 1);
1644 assert_eq!(
1645 app.queued_messages
1646 .front()
1647 .map(crate::tui::app::QueuedMessage::content),
1648 Some("Proceed with the accepted plan.".to_string())
1649 );
1650 }
1651
1652 #[test]
1653 fn api_key_validation_warns_without_blocking_unusual_formats() {
1654 assert!(matches!(
1655 validate_api_key_for_onboarding(""),
1656 ApiKeyValidation::Reject(_)
1657 ));
1658 assert!(matches!(
1659 validate_api_key_for_onboarding("sk short"),
1660 ApiKeyValidation::Reject(_)
1661 ));
1662 assert!(matches!(
1663 validate_api_key_for_onboarding("short-key"),
1664 ApiKeyValidation::Accept { warning: Some(_) }
1665 ));
1666 assert!(matches!(
1667 validate_api_key_for_onboarding("averylongkeywithoutdash123456"),
1668 ApiKeyValidation::Accept { warning: Some(_) }
1669 ));
1670 assert!(matches!(
1671 validate_api_key_for_onboarding("sk-valid-format-1234567890"),
1672 ApiKeyValidation::Accept { warning: None }
1673 ));
1674 }
1675
1676 #[test]
1677 fn onboarding_after_api_key_save_does_not_repeat_language_step() {
1678 let mut app = create_test_app();
1679 app.onboarding = OnboardingState::ApiKey;
1680 app.onboarding_needs_api_key = false;
1681 app.trust_mode = true;
1682 app.status_message = Some("saved".to_string());
1683
1684 advance_onboarding_after_language(&mut app);
1685
1686 assert_eq!(app.onboarding, OnboardingState::Tips);
1687 assert_eq!(app.status_message, None);
1688 }
1689
1690 #[test]
1691 fn onboarding_after_api_key_save_routes_to_trust_when_needed() {
1692 let tmpdir = TempDir::new().expect("tempdir");
1693 let mut app = create_test_app();
1694 app.workspace = tmpdir.path().to_path_buf();
1695 app.onboarding = OnboardingState::ApiKey;
1696 app.onboarding_needs_api_key = false;
1697 app.trust_mode = false;
1698
1699 advance_onboarding_after_language(&mut app);
1700
1701 assert_eq!(app.onboarding, OnboardingState::TrustDirectory);
1702 }
1703
1704 #[test]
1705 fn api_key_paste_shortcut_is_not_plain_text_input() {
1706 let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
1707 assert!(is_paste_shortcut(&ctrl_v));
1708 assert!(!is_text_input_key(&ctrl_v));
1709
1710 let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\u{16}'), KeyModifiers::NONE);
1711 assert!(is_paste_shortcut(&legacy_ctrl_v));
1712 assert!(!is_text_input_key(&legacy_ctrl_v));
1713
1714 let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
1715 assert!(is_text_input_key(&shifted));
1716 }
1717
1718 #[test]
1719 fn jump_to_adjacent_tool_cell_finds_next_and_previous() {
1720 let mut app = create_test_app();
1721 app.history = vec![
1722 HistoryCell::User {
1723 content: "hello".to_string(),
1724 },
1725 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1726 name: "file_search".to_string(),
1727 status: ToolStatus::Success,
1728 input_summary: Some("query: foo".to_string()),
1729 output: Some("done".to_string()),
1730 prompts: None,
1731 spillover_path: None,
1732 })),
1733 HistoryCell::Assistant {
1734 content: "ok".to_string(),
1735 streaming: false,
1736 },
1737 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1738 name: "run_command".to_string(),
1739 status: ToolStatus::Success,
1740 input_summary: Some("ls".to_string()),
1741 output: Some("...".to_string()),
1742 prompts: None,
1743 spillover_path: None,
1744 })),
1745 ];
1746 app.mark_history_updated();
1747 let cell_revisions = vec![app.history_version; app.history.len()];
1748 app.viewport.transcript_cache.ensure(
1749 &app.history,
1750 &cell_revisions,
1751 100,
1752 app.transcript_render_options(),
1753 );
1754
1755 app.viewport.last_transcript_top = 0;
1756 assert!(jump_to_adjacent_tool_cell(
1757 &mut app,
1758 SearchDirection::Forward
1759 ));
1760 // Forward jump pins the scroll to a non-tail line offset (the tool
1761 // cell's first line). Anything below the live tail is acceptable —
1762 // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,
1763 // which under the new flat-offset model means "not at tail."
1764 assert!(!app.viewport.transcript_scroll.is_at_tail());
1765
1766 app.viewport.last_transcript_top = app
1767 .viewport
1768 .transcript_cache
1769 .total_lines()
1770 .saturating_sub(1);
1771 assert!(jump_to_adjacent_tool_cell(
1772 &mut app,
1773 SearchDirection::Backward
1774 ));
1775 }
1776
1777 fn first_line_for_cell(app: &App, cell_index: usize) -> usize {
1778 app.viewport
1779 .transcript_cache
1780 .line_meta()
1781 .iter()
1782 .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))
1783 .expect("cell should have rendered line")
1784 }
1785
1786 #[test]
1787 fn detail_target_prefers_visible_tool_card() {
1788 let mut app = create_test_app();
1789 app.history = vec![
1790 HistoryCell::User {
1791 content: "hello".to_string(),
1792 },
1793 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1794 name: "file_search".to_string(),
1795 status: ToolStatus::Success,
1796 input_summary: Some("query: foo".to_string()),
1797 output: Some("done".to_string()),
1798 prompts: None,
1799 spillover_path: None,
1800 })),
1801 HistoryCell::Assistant {
1802 content: "ok".to_string(),
1803 streaming: false,
1804 },
1805 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1806 name: "exec_shell".to_string(),
1807 status: ToolStatus::Success,
1808 input_summary: Some("command: ls".to_string()),
1809 output: Some("...".to_string()),
1810 prompts: None,
1811 spillover_path: None,
1812 })),
1813 ];
1814 app.tool_details_by_cell.insert(
1815 1,
1816 ToolDetailRecord {
1817 tool_id: "search-1".to_string(),
1818 tool_name: "file_search".to_string(),
1819 input: serde_json::json!({"query": "foo"}),
1820 output: Some("done".to_string()),
1821 },
1822 );
1823 app.tool_details_by_cell.insert(
1824 3,
1825 ToolDetailRecord {
1826 tool_id: "exec-1".to_string(),
1827 tool_name: "exec_shell".to_string(),
1828 input: serde_json::json!({"command": "ls"}),
1829 output: Some("...".to_string()),
1830 },
1831 );
1832 app.resync_history_revisions();
1833 let revisions = app.history_revisions.clone();
1834 app.viewport.transcript_cache.ensure(
1835 &app.history,
1836 &revisions,
1837 100,
1838 app.transcript_render_options(),
1839 );
1840 app.viewport.last_transcript_top = first_line_for_cell(&app, 1);
1841 app.viewport.last_transcript_visible = 6;
1842
1843 assert_eq!(detail_target_cell_index(&app), Some(1));
1844 assert_eq!(
1845 selected_detail_footer_label(&app).as_deref(),
1846 Some("Alt+V details: file_search")
1847 );
1848 }
1849
1850 #[test]
1851 fn open_tool_details_pager_supports_active_virtual_tool_cell() {
1852 let mut app = create_test_app();
1853 handle_tool_call_started(
1854 &mut app,
1855 "active-1",
1856 "exec_shell",
1857 &serde_json::json!({"command": "echo hi"}),
1858 );
1859 let active_entries = app
1860 .active_cell
1861 .as_ref()
1862 .expect("active cell")
1863 .entries()
1864 .to_vec();
1865 app.viewport.transcript_cache.ensure_split(
1866 &[&app.history, active_entries.as_slice()],
1867 &[1],
1868 100,
1869 app.transcript_render_options(),
1870 );
1871 app.viewport.last_transcript_top = 0;
1872 app.viewport.last_transcript_visible = 4;
1873
1874 assert_eq!(detail_target_cell_index(&app), Some(0));
1875 assert!(open_tool_details_pager(&mut app));
1876 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));
1877 }
1878
1879 #[test]
1880 fn spillover_pager_section_returns_none_when_no_spillover() {
1881 let mut app = create_test_app();
1882 app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1883 name: "exec_shell".to_string(),
1884 status: ToolStatus::Success,
1885 input_summary: None,
1886 output: Some("hi".to_string()),
1887 prompts: None,
1888 spillover_path: None,
1889 }))];
1890 app.resync_history_revisions();
1891 assert!(spillover_pager_section(&app, 0).is_none());
1892 }
1893
1894 #[test]
1895 fn spillover_pager_section_loads_file_when_present() {
1896 use std::io::Write;
1897 let dir = tempfile::tempdir().unwrap();
1898 let path = dir.path().join("call-test.txt");
1899 let mut f = std::fs::File::create(&path).unwrap();
1900 writeln!(f, "FULL_OUTPUT_BYTES_HERE").unwrap();
1901
1902 let mut app = create_test_app();
1903 app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1904 name: "exec_shell".to_string(),
1905 status: ToolStatus::Success,
1906 input_summary: None,
1907 output: Some("(truncated head)".to_string()),
1908 prompts: None,
1909 spillover_path: Some(path.clone()),
1910 }))];
1911 app.resync_history_revisions();
1912
1913 let section = spillover_pager_section(&app, 0).expect("section present");
1914 assert!(section.contains("Full output (spillover)"));
1915 assert!(
1916 section.contains("FULL_OUTPUT_BYTES_HERE"),
1917 "section missing file body: {section}"
1918 );
1919 assert!(section.contains(&path.display().to_string()));
1920 }
1921
1922 #[test]
1923 fn spillover_pager_section_returns_notice_when_file_missing() {
1924 let mut app = create_test_app();
1925 let bogus = std::path::PathBuf::from("/tmp/this/path/does/not/exist-spill.txt");
1926 app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1927 name: "exec_shell".to_string(),
1928 status: ToolStatus::Success,
1929 input_summary: None,
1930 output: Some("(truncated head)".to_string()),
1931 prompts: None,
1932 spillover_path: Some(bogus),
1933 }))];
1934 app.resync_history_revisions();
1935
1936 let section = spillover_pager_section(&app, 0).expect("still emits a notice section");
1937 assert!(section.contains("could not read spillover file"));
1938 }
1939
1940 #[test]
1941 fn details_shortcut_modifiers_accept_plain_shift_and_alt_only() {
1942 assert!(details_shortcut_modifiers(KeyModifiers::NONE));
1943 assert!(details_shortcut_modifiers(KeyModifiers::SHIFT));
1944 assert!(details_shortcut_modifiers(KeyModifiers::ALT));
1945 assert!(details_shortcut_modifiers(
1946 KeyModifiers::ALT | KeyModifiers::SHIFT
1947 ));
1948 assert!(!details_shortcut_modifiers(KeyModifiers::CONTROL));
1949 assert!(!details_shortcut_modifiers(
1950 KeyModifiers::ALT | KeyModifiers::CONTROL
1951 ));
1952 }
1953
1954 #[test]
1955 fn ctrl_h_is_treated_as_terminal_backspace() {
1956 assert!(is_ctrl_h_backspace(&KeyEvent::new(
1957 KeyCode::Char('h'),
1958 KeyModifiers::CONTROL
1959 )));
1960 assert!(!is_ctrl_h_backspace(&KeyEvent::new(
1961 KeyCode::Char('h'),
1962 KeyModifiers::NONE
1963 )));
1964 assert!(!is_ctrl_h_backspace(&KeyEvent::new(
1965 KeyCode::Char('h'),
1966 KeyModifiers::CONTROL | KeyModifiers::ALT
1967 )));
1968 }
1969
1970 #[test]
1971 fn partial_file_mention_finds_token_under_cursor() {
1972 // Cursor in middle of `@docs/de` should be detected as a partial mention.
1973 let input = "look at @docs/de please";
1974 let cursor = "look at @docs/de".chars().count();
1975 let (start, partial) = partial_file_mention_at_cursor(input, cursor)
1976 .expect("cursor inside mention should yield a partial");
1977 assert_eq!(start, "look at ".len(), "byte_start of @ in input");
1978 assert_eq!(partial, "docs/de");
1979 }
1980
1981 #[test]
1982 fn partial_file_mention_returns_none_when_cursor_outside() {
1983 let input = "look at @docs/de please";
1984 // Cursor after "please" — past the whitespace following the mention.
1985 let cursor = input.chars().count();
1986 assert!(partial_file_mention_at_cursor(input, cursor).is_none());
1987
1988 // Cursor before the `@` — not inside any mention either.
1989 let early_cursor = "look".chars().count();
1990 assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());
1991 }
1992
1993 #[test]
1994 fn partial_file_mention_handles_email_addresses() {
1995 // The `@` in `user@example.com` is preceded by a non-boundary char so
1996 // it's not treated as a file-mention.
1997 let input = "ping user@example.com now";
1998 let cursor = "ping user@example.com".chars().count();
1999 assert!(partial_file_mention_at_cursor(input, cursor).is_none());
2000 }
2001
2002 #[test]
2003 fn file_mention_completion_finds_unique_match() {
2004 let tmpdir = TempDir::new().expect("tempdir");
2005 std::fs::write(tmpdir.path().join("README.md"), "readme").unwrap();
2006 std::fs::create_dir_all(tmpdir.path().join("docs")).unwrap();
2007 std::fs::write(tmpdir.path().join("docs/deepseek_v4.pdf"), b"%PDF-").unwrap();
2008
2009 let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);
2010 let matches = find_file_mention_completions(&ws, "docs/de", 16);
2011 assert_eq!(matches, vec!["docs/deepseek_v4.pdf".to_string()]);
2012 }
2013
2014 #[test]
2015 fn file_mention_completion_ranks_prefix_before_substring() {
2016 let tmpdir = TempDir::new().expect("tempdir");
2017 std::fs::write(tmpdir.path().join("README.md"), "x").unwrap();
2018 std::fs::create_dir_all(tmpdir.path().join("nested")).unwrap();
2019 std::fs::write(tmpdir.path().join("nested/README.md"), "x").unwrap();
2020
2021 let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);
2022 let matches = find_file_mention_completions(&ws, "README", 16);
2023 // Top-level README (prefix match) outranks the nested one (substring).
2024 assert_eq!(matches.first().map(String::as_str), Some("README.md"));
2025 }
2026
2027 #[test]
2028 fn try_autocomplete_file_mention_unique_replaces_partial() {
2029 let tmpdir = TempDir::new().expect("tempdir");
2030 std::fs::create_dir_all(tmpdir.path().join("docs")).unwrap();
2031 std::fs::write(tmpdir.path().join("docs/deepseek_v4.pdf"), b"%PDF-").unwrap();
2032
2033 let mut app = create_test_app();
2034 app.workspace = tmpdir.path().to_path_buf();
2035 app.input = "summarize @docs/de".to_string();
2036 app.cursor_position = app.input.chars().count();
2037
2038 assert!(try_autocomplete_file_mention(&mut app));
2039 assert_eq!(app.input, "summarize @docs/deepseek_v4.pdf");
2040 assert_eq!(app.cursor_position, app.input.chars().count());
2041 }
2042
2043 #[test]
2044 fn try_autocomplete_file_mention_extends_to_common_prefix() {
2045 let tmpdir = TempDir::new().expect("tempdir");
2046 std::fs::create_dir_all(tmpdir.path().join("crates/tui")).unwrap();
2047 std::fs::write(tmpdir.path().join("crates/tui/lib.rs"), "//").unwrap();
2048 std::fs::write(tmpdir.path().join("crates/tui/main.rs"), "//").unwrap();
2049
2050 let mut app = create_test_app();
2051 app.workspace = tmpdir.path().to_path_buf();
2052 app.input = "@crates/tui/".to_string();
2053 app.cursor_position = app.input.chars().count();
2054
2055 assert!(try_autocomplete_file_mention(&mut app));
2056 // Both files share the `crates/tui/` prefix and one more letter is
2057 // not unique (`l` vs `m`), so the partial extends to the common prefix
2058 // unchanged here, with the status surfacing both candidates.
2059 assert!(app.input.starts_with("@crates/tui/"));
2060 let preview = app
2061 .status_message
2062 .as_deref()
2063 .expect("status message should describe candidates");
2064 assert!(preview.contains("@crates/tui/lib.rs"));
2065 assert!(preview.contains("@crates/tui/main.rs"));
2066 }
2067
2068 #[test]
2069 fn try_autocomplete_file_mention_no_match_reports_status() {
2070 let tmpdir = TempDir::new().expect("tempdir");
2071 std::fs::write(tmpdir.path().join("README.md"), "x").unwrap();
2072
2073 let mut app = create_test_app();
2074 app.workspace = tmpdir.path().to_path_buf();
2075 app.input = "@nonexistent_xyz".to_string();
2076 app.cursor_position = app.input.chars().count();
2077
2078 assert!(try_autocomplete_file_mention(&mut app));
2079 assert_eq!(app.input, "@nonexistent_xyz");
2080 assert_eq!(
2081 app.status_message.as_deref(),
2082 Some("No files match @nonexistent_xyz")
2083 );
2084 }
2085
2086 #[test]
2087 fn try_autocomplete_file_mention_returns_false_outside_mention() {
2088 let mut app = create_test_app();
2089 app.input = "no mention here".to_string();
2090 app.cursor_position = app.input.chars().count();
2091 assert!(!try_autocomplete_file_mention(&mut app));
2092 }
2093
2094 // ---- P2.1: @-mention popup helpers ----
2095 //
2096 // `visible_mention_menu_entries` is the entries source the composer widget
2097 // renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the
2098 // popup is open. The popup widget itself piggybacks the slash-menu render
2099 // path (see `ComposerWidget::active_menu_entries`).
2100
2101 #[test]
2102 fn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {
2103 let mut app = create_test_app();
2104 app.input = "no mention here".to_string();
2105 app.cursor_position = app.input.chars().count();
2106 assert!(visible_mention_menu_entries(&mut app, 6).is_empty());
2107 }
2108
2109 #[test]
2110 fn mention_popup_lists_workspace_matches_for_cursor_partial() {
2111 let tmpdir = TempDir::new().expect("tempdir");
2112 std::fs::create_dir_all(tmpdir.path().join("docs")).unwrap();
2113 std::fs::write(tmpdir.path().join("docs/deepseek_v4.pdf"), b"%PDF-").unwrap();
2114 std::fs::write(tmpdir.path().join("docs/MCP.md"), "x").unwrap();
2115 std::fs::write(tmpdir.path().join("README.md"), "x").unwrap();
2116
2117 let mut app = create_test_app();
2118 app.workspace = tmpdir.path().to_path_buf();
2119 app.input = "look at @docs/".to_string();
2120 app.cursor_position = app.input.chars().count();
2121
2122 let entries = visible_mention_menu_entries(&mut app, 6);
2123 assert!(!entries.is_empty(), "popup should surface docs/ entries");
2124 assert!(entries.iter().any(|e| e.starts_with("docs/")));
2125 // README.md doesn't match `docs/` — confirm we didn't dump every file.
2126 assert!(!entries.iter().any(|e| e == "README.md"));
2127 }
2128
2129 #[test]
2130 fn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {
2131 let tmpdir = TempDir::new().expect("tempdir");
2132 std::fs::create_dir_all(tmpdir.path().join("docs")).unwrap();
2133 std::fs::write(tmpdir.path().join("docs/alpha.md"), "x").unwrap();
2134
2135 let mut app = create_test_app();
2136 app.workspace = tmpdir.path().to_path_buf();
2137 app.input = "look at @docs/".to_string();
2138 app.cursor_position = app.input.chars().count();
2139
2140 let entries = visible_mention_menu_entries(&mut app, 6);
2141 assert!(entries.iter().any(|e| e == "docs/alpha.md"));
2142
2143 std::fs::write(tmpdir.path().join("docs/beta.md"), "x").unwrap();
2144 app.cursor_position = "look at @do".chars().count();
2145
2146 let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);
2147 assert_eq!(
2148 entries_after_cursor_move, entries,
2149 "cursor movement inside one @mention token should not re-walk the workspace",
2150 );
2151
2152 app.input = "look at @docs/b".to_string();
2153 app.cursor_position = app.input.chars().count();
2154
2155 let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);
2156 assert!(
2157 entries_after_partial_change
2158 .iter()
2159 .any(|e| e == "docs/beta.md"),
2160 "changing the partial should invalidate the completion cache",
2161 );
2162 }
2163
2164 #[test]
2165 fn mention_popup_respects_hidden_flag() {
2166 let tmpdir = TempDir::new().expect("tempdir");
2167 std::fs::write(tmpdir.path().join("README.md"), "x").unwrap();
2168
2169 let mut app = create_test_app();
2170 app.workspace = tmpdir.path().to_path_buf();
2171 app.input = "@READ".to_string();
2172 app.cursor_position = app.input.chars().count();
2173 app.mention_menu_hidden = true;
2174
2175 assert!(
2176 visible_mention_menu_entries(&mut app, 6).is_empty(),
2177 "Esc-hidden popup must not surface entries until next input edit",
2178 );
2179 }
2180
2181 #[test]
2182 fn apply_mention_menu_selection_splices_selected_entry() {
2183 let tmpdir = TempDir::new().expect("tempdir");
2184 std::fs::create_dir_all(tmpdir.path().join("crates/tui")).unwrap();
2185 std::fs::write(tmpdir.path().join("crates/tui/lib.rs"), "//").unwrap();
2186 std::fs::write(tmpdir.path().join("crates/tui/main.rs"), "//").unwrap();
2187
2188 let mut app = create_test_app();
2189 app.workspace = tmpdir.path().to_path_buf();
2190 app.input = "open @crates/tui/m".to_string();
2191 app.cursor_position = app.input.chars().count();
2192
2193 let entries = visible_mention_menu_entries(&mut app, 6);
2194 assert!(!entries.is_empty(), "expected entries for @crates/tui/m");
2195 // Pick whichever entry appears at index 0; it's deterministic given the
2196 // workspace setup. Apply it.
2197 app.mention_menu_selected = 0;
2198 let applied = apply_mention_menu_selection(&mut app, &entries);
2199 assert!(
2200 applied,
2201 "apply_mention_menu_selection should report success"
2202 );
2203 assert!(
2204 app.input.starts_with("open @"),
2205 "input should still start with `open @`, got: {input}",
2206 input = app.input,
2207 );
2208 // Cursor should land at the end of the spliced token.
2209 assert_eq!(app.cursor_position, app.input.chars().count());
2210 }
2211
2212 #[test]
2213 fn apply_mention_menu_selection_is_noop_outside_a_mention() {
2214 let mut app = create_test_app();
2215 app.input = "no @ here".to_string();
2216 app.cursor_position = 1; // before the @ token
2217 let applied = apply_mention_menu_selection(&mut app, &["whatever".to_string()]);
2218 assert!(!applied);
2219 assert_eq!(app.input, "no @ here");
2220 }
2221
2222 #[test]
2223 fn apply_mention_menu_selection_with_no_entries_is_noop() {
2224 let mut app = create_test_app();
2225 app.input = "@partial".to_string();
2226 app.cursor_position = app.input.chars().count();
2227 let applied = apply_mention_menu_selection(&mut app, &[]);
2228 assert!(!applied);
2229 }
2230
2231 // === CX#7 — single active cell mutated in place for parallel tool calls ===
2232
2233 /// Build a minimal successful ToolResult with the given content.
2234 fn ok_result(
2235 content: &str,
2236 ) -> Result<crate::tools::spec::ToolResult, crate::tools::spec::ToolError> {
2237 Ok(crate::tools::spec::ToolResult::success(content))
2238 }
2239
2240 #[test]
2241 fn tool_child_usage_metadata_updates_live_cost_counter() {
2242 let mut app = create_test_app();
2243 let result = Ok(crate::tools::spec::ToolResult::success("ok").with_metadata(
2244 serde_json::json!({
2245 "child_model": "deepseek-v4-flash",
2246 "child_input_tokens": 10_000,
2247 "child_output_tokens": 1_000,
2248 "child_prompt_cache_hit_tokens": 7_000,
2249 "child_prompt_cache_miss_tokens": 3_000,
2250 }),
2251 ));
2252
2253 handle_tool_call_complete(&mut app, "review-usage", "review", &result);
2254
2255 assert!(app.session.subagent_cost > 0.0);
2256 }
2257
2258 #[test]
2259 fn parallel_exploring_tool_starts_share_one_active_entry() {
2260 // Three exploring tools start in any order; they must collapse into one
2261 // entry inside the active cell rather than three separate cells. This is
2262 // the central CX#7 contract for the most common parallel case.
2263 let mut app = create_test_app();
2264
2265 handle_tool_call_started(
2266 &mut app,
2267 "t-a",
2268 "read_file",
2269 &serde_json::json!({"path": "alpha.rs"}),
2270 );
2271 handle_tool_call_started(
2272 &mut app,
2273 "t-b",
2274 "read_file",
2275 &serde_json::json!({"path": "beta.rs"}),
2276 );
2277 handle_tool_call_started(
2278 &mut app,
2279 "t-c",
2280 "grep_files",
2281 &serde_json::json!({"pattern": "TODO"}),
2282 );
2283
2284 // History must remain empty: nothing flushes until the turn ends.
2285 assert_eq!(app.history.len(), 0, "no history cells written mid-turn");
2286 let active = app.active_cell.as_ref().expect("active cell created");
2287 assert_eq!(
2288 active.entry_count(),
2289 1,
2290 "all exploring starts share one entry"
2291 );
2292 let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {
2293 panic!("expected exploring cell")
2294 };
2295 assert_eq!(explore.entries.len(), 3);
2296 for entry in &explore.entries {
2297 assert_eq!(entry.status, ToolStatus::Running);
2298 }
2299 }
2300
2301 #[test]
2302 fn out_of_order_completes_finalize_one_history_cell_per_turn() {
2303 // Three parallel tools complete in reverse order; we then signal turn
2304 // complete and assert exactly one tool history cell exists (the
2305 // finalized active group). This proves the active cell didn't bounce
2306 // mid-turn and that the flush path correctly migrates entries.
2307 let mut app = create_test_app();
2308
2309 handle_tool_call_started(
2310 &mut app,
2311 "t-1",
2312 "read_file",
2313 &serde_json::json!({"path": "a.rs"}),
2314 );
2315 handle_tool_call_started(
2316 &mut app,
2317 "t-2",
2318 "read_file",
2319 &serde_json::json!({"path": "b.rs"}),
2320 );
2321 handle_tool_call_started(
2322 &mut app,
2323 "t-3",
2324 "grep_files",
2325 &serde_json::json!({"pattern": "x"}),
2326 );
2327
2328 // Out-of-order completion: t-3, then t-1, then t-2.
2329 handle_tool_call_complete(&mut app, "t-3", "grep_files", &ok_result("two hits"));
2330 handle_tool_call_complete(&mut app, "t-1", "read_file", &ok_result("contents A"));
2331 handle_tool_call_complete(&mut app, "t-2", "read_file", &ok_result("contents B"));
2332
2333 // Still nothing in history: the active cell holds everything.
2334 assert_eq!(app.history.len(), 0);
2335 let active = app.active_cell.as_ref().expect("active cell still present");
2336 let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {
2337 panic!("expected exploring cell")
2338 };
2339 assert!(
2340 explore
2341 .entries
2342 .iter()
2343 .all(|e| e.status == ToolStatus::Success),
2344 "all exploring entries should be Success after their tools complete"
2345 );
2346
2347 // Flush via the explicit helper (mirrors what TurnComplete does).
2348 app.flush_active_cell();
2349
2350 assert!(app.active_cell.is_none(), "active cell cleared after flush");
2351 // The flushed group is exactly one history cell — the merged exploring
2352 // aggregate. This is the heart of CX#7: parallel work renders as ONE
2353 // finalized cell, regardless of completion order.
2354 let tool_cells = app
2355 .history
2356 .iter()
2357 .filter(|c| matches!(c, HistoryCell::Tool(_)))
2358 .count();
2359 assert_eq!(
2360 tool_cells, 1,
2361 "exactly one tool history cell after parallel turn"
2362 );
2363 }
2364
2365 #[test]
2366 fn mixed_parallel_tools_render_in_single_active_cell() {
2367 // Tools of different shapes — exploring + exec + generic — all in flight
2368 // at once. The active cell must hold them all without bouncing.
2369 let mut app = create_test_app();
2370
2371 handle_tool_call_started(
2372 &mut app,
2373 "ex-1",
2374 "read_file",
2375 &serde_json::json!({"path": "x.rs"}),
2376 );
2377 handle_tool_call_started(
2378 &mut app,
2379 "shell-1",
2380 "exec_shell",
2381 &serde_json::json!({"command": "ls"}),
2382 );
2383 handle_tool_call_started(
2384 &mut app,
2385 "gen-1",
2386 "todo_write",
2387 &serde_json::json!({"items": []}),
2388 );
2389
2390 assert_eq!(app.history.len(), 0);
2391 let active = app.active_cell.as_ref().expect("active cell present");
2392 // 3 entries: exploring aggregate (1) + exec + generic.
2393 assert_eq!(active.entry_count(), 3);
2394
2395 handle_tool_call_complete(&mut app, "shell-1", "exec_shell", &ok_result("ok"));
2396 handle_tool_call_complete(&mut app, "gen-1", "todo_write", &ok_result("done"));
2397 handle_tool_call_complete(&mut app, "ex-1", "read_file", &ok_result("file body"));
2398
2399 // After all complete, still in active until flush.
2400 assert_eq!(app.history.len(), 0);
2401 app.flush_active_cell();
2402 let tool_cells: Vec<_> = app
2403 .history
2404 .iter()
2405 .filter(|c| matches!(c, HistoryCell::Tool(_)))
2406 .collect();
2407 assert_eq!(
2408 tool_cells.len(),
2409 3,
2410 "three distinct tool shapes finalize as three cells in stable insertion order"
2411 );
2412 }
2413
2414 #[test]
2415 fn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {
2416 // A ToolCallComplete with no matching ToolCallStarted — the orphan path.
2417 // Per the design we render it as a finalized standalone cell so the user
2418 // still sees the output, but we must NOT flush or contaminate any active
2419 // cell that's currently in flight.
2420 let mut app = create_test_app();
2421
2422 handle_tool_call_started(
2423 &mut app,
2424 "live-1",
2425 "read_file",
2426 &serde_json::json!({"path": "live.rs"}),
2427 );
2428
2429 // Orphan completion arrives.
2430 handle_tool_call_complete(&mut app, "ghost-id", "mystery_tool", &ok_result("oops"));
2431
2432 // Active cell is intact.
2433 let active = app
2434 .active_cell
2435 .as_ref()
2436 .expect("active cell preserved after orphan");
2437 assert_eq!(active.entry_count(), 1);
2438
2439 // The orphan rendered as a separate finalized cell pushed to history.
2440 assert_eq!(app.history.len(), 1, "orphan added one finalized cell");
2441 let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {
2442 panic!("orphan should render as a Generic tool cell")
2443 };
2444 assert_eq!(generic.name, "mystery_tool");
2445 assert_eq!(generic.status, ToolStatus::Success);
2446 }
2447
2448 #[test]
2449 fn turn_complete_flushes_active_cell_into_history() {
2450 // The full path through the public flush helper. Verifies that a
2451 // mid-turn snapshot (exec running, exploring complete) becomes a stable
2452 // history slice on flush.
2453 let mut app = create_test_app();
2454 handle_tool_call_started(
2455 &mut app,
2456 "ex-1",
2457 "read_file",
2458 &serde_json::json!({"path": "a.rs"}),
2459 );
2460 handle_tool_call_complete(&mut app, "ex-1", "read_file", &ok_result("body"));
2461 handle_tool_call_started(
2462 &mut app,
2463 "shell-1",
2464 "exec_shell",
2465 &serde_json::json!({"command": "ls"}),
2466 );
2467 // Don't complete shell-1 — simulate cancellation mid-shell.
2468 app.finalize_active_cell_as_interrupted();
2469
2470 assert!(app.active_cell.is_none(), "active cell cleared on flush");
2471 let exec_cells: Vec<_> = app
2472 .history
2473 .iter()
2474 .filter_map(|c| match c {
2475 HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),
2476 _ => None,
2477 })
2478 .collect();
2479 assert_eq!(exec_cells.len(), 1);
2480 assert_eq!(
2481 exec_cells[0].status,
2482 ToolStatus::Failed,
2483 "interrupted shell entry marked Failed (closest available terminal status)"
2484 );
2485 }
2486
2487 #[test]
2488 fn orphan_during_active_keeps_subsequent_completion_routed_correctly() {
2489 // Regression cover for the index-shift trap: when an orphan arrives
2490 // mid-active, it pushes a real history cell that bumps virtual indices
2491 // by one. A subsequent legitimate completion must still find its entry.
2492 let mut app = create_test_app();
2493 handle_tool_call_started(
2494 &mut app,
2495 "live",
2496 "exec_shell",
2497 &serde_json::json!({"command": "ls"}),
2498 );
2499 // Orphan completion arrives FIRST (before live's completion).
2500 handle_tool_call_complete(&mut app, "ghost", "weird_tool", &ok_result("ghost-out"));
2501 // Now complete the live tool — it should still mutate the active entry,
2502 // not silently drop or hit a stale index.
2503 handle_tool_call_complete(&mut app, "live", "exec_shell", &ok_result("hello"));
2504
2505 // Active cell still present (turn hasn't completed).
2506 let active = app.active_cell.as_ref().expect("active cell present");
2507 let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {
2508 panic!("expected exec cell")
2509 };
2510 assert_eq!(exec.status, ToolStatus::Success);
2511
2512 // History contains exactly the orphan.
2513 assert_eq!(app.history.len(), 1);
2514 let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {
2515 panic!("expected orphan generic cell")
2516 };
2517 assert_eq!(generic.name, "weird_tool");
2518
2519 // Flush settles the active exec into history below the orphan.
2520 app.flush_active_cell();
2521 assert_eq!(app.history.len(), 2);
2522 }
2523
2524 #[test]
2525 fn tool_details_survive_active_cell_flush() {
2526 // The pager / Ctrl+O resolves tool details by cell index. Flushing the
2527 // active cell must move detail records into `tool_details_by_cell` so
2528 // the pager keeps working after the turn settles.
2529 let mut app = create_test_app();
2530 handle_tool_call_started(
2531 &mut app,
2532 "tid",
2533 "exec_shell",
2534 &serde_json::json!({"command": "echo hi"}),
2535 );
2536 handle_tool_call_complete(&mut app, "tid", "exec_shell", &ok_result("hi"));
2537 app.flush_active_cell();
2538
2539 // The exec cell is now at index 0 in history.
2540 assert_eq!(app.history.len(), 1);
2541 let detail = app
2542 .tool_details_by_cell
2543 .get(&0)
2544 .expect("detail record migrated to flushed cell index");
2545 assert_eq!(detail.tool_id, "tid");
2546 assert_eq!(detail.tool_name, "exec_shell");
2547 }
2548
2549 // ---- exploring labels: codex-style progressive verbs ----
2550 //
2551 // Bare names like "Read foo.rs" / "Search pattern" read as past tense, which
2552 // is wrong while the tool is still running. Progressive forms ("Reading…",
2553 // "Searching for…") match what the user actually sees: a live in-flight
2554 // action.
2555
2556 #[test]
2557 fn exploring_label_uses_progressive_for_read_file() {
2558 let label = exploring_label("read_file", &serde_json::json!({"path": "src/foo.rs"}));
2559 assert_eq!(label, "Reading src/foo.rs");
2560 }
2561
2562 #[test]
2563 fn exploring_label_uses_progressive_for_list_dir() {
2564 let label = exploring_label("list_dir", &serde_json::json!({"path": "crates/tui/src/"}));
2565 assert_eq!(label, "Listing crates/tui/src/");
2566 }
2567
2568 #[test]
2569 fn exploring_label_uses_progressive_for_list_dir_no_path() {
2570 let label = exploring_label("list_dir", &serde_json::json!({}));
2571 assert_eq!(label, "Listing directory");
2572 }
2573
2574 #[test]
2575 fn exploring_label_for_grep_quotes_pattern_with_searching_for() {
2576 let label = exploring_label(
2577 "grep_files",
2578 &serde_json::json!({"pattern": "TranscriptScroll"}),
2579 );
2580 assert_eq!(label, "Searching for `TranscriptScroll`");
2581 }
2582
2583 #[test]
2584 fn exploring_label_for_list_files_uses_progressive() {
2585 let label = exploring_label("list_files", &serde_json::json!({}));
2586 assert_eq!(label, "Listing files");
2587 }
2588
2589 // `running_status_label_with_elapsed` lives in `crate::tui::history` next to
2590 // the other tool-header helpers — its tests live there too.
2591
2592 // ---- P2.4: auto-scroll churn regressions ----
2593 //
2594 // The contract: once the user scrolls away from the live tail mid-turn
2595 // (`user_scrolled_during_stream = true`), no path should yank them back to
2596 // the bottom until either (a) they explicitly scroll to tail, (b) the turn
2597 // ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers
2598 // only call `mark_history_updated`, which does NOT scroll. `add_message`
2599 // gates on the flag.
2600
2601 #[test]
2602 fn add_message_does_not_scroll_when_user_scrolled_away() {
2603 use crate::tui::scrolling::TranscriptScroll;
2604
2605 let mut app = create_test_app();
2606 // Pre-condition: user was following the tail, then scrolled up.
2607 app.viewport.transcript_scroll = TranscriptScroll::at_line(7);
2608 app.user_scrolled_during_stream = true;
2609
2610 app.add_message(HistoryCell::User {
2611 content: "fresh user message".to_string(),
2612 });
2613
2614 assert!(
2615 !app.viewport.transcript_scroll.is_at_tail(),
2616 "add_message must respect user_scrolled_during_stream",
2617 );
2618 }
2619
2620 #[test]
2621 fn add_message_pins_to_tail_when_user_was_following() {
2622 use crate::tui::scrolling::TranscriptScroll;
2623
2624 let mut app = create_test_app();
2625 app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
2626 app.user_scrolled_during_stream = false;
2627
2628 app.add_message(HistoryCell::User {
2629 content: "fresh user message".to_string(),
2630 });
2631
2632 assert!(
2633 app.viewport.transcript_scroll.is_at_tail(),
2634 "auto-pin should still work when the user hasn't opted out",
2635 );
2636 }
2637
2638 #[test]
2639 fn tool_call_started_does_not_scroll_when_user_scrolled_away() {
2640 // Tool-cell handlers must not sneak in a scroll_to_bottom — they go
2641 // through `mark_history_updated` which only bumps `history_version`.
2642 use crate::tui::scrolling::TranscriptScroll;
2643
2644 let mut app = create_test_app();
2645 app.viewport.transcript_scroll = TranscriptScroll::at_line(7);
2646 app.user_scrolled_during_stream = true;
2647
2648 handle_tool_call_started(
2649 &mut app,
2650 "tid",
2651 "exec_shell",
2652 &serde_json::json!({"command": "ls"}),
2653 );
2654
2655 assert!(
2656 !app.viewport.transcript_scroll.is_at_tail(),
2657 "tool-cell start must not yank scroll position to bottom",
2658 );
2659 }
2660
2661 #[test]
2662 fn tool_call_complete_does_not_scroll_when_user_scrolled_away() {
2663 use crate::tui::scrolling::TranscriptScroll;
2664
2665 let mut app = create_test_app();
2666 handle_tool_call_started(
2667 &mut app,
2668 "tid",
2669 "exec_shell",
2670 &serde_json::json!({"command": "ls"}),
2671 );
2672
2673 // After start, user scrolls up.
2674 app.viewport.transcript_scroll = TranscriptScroll::at_line(7);
2675 app.user_scrolled_during_stream = true;
2676
2677 handle_tool_call_complete(&mut app, "tid", "exec_shell", &ok_result("output"));
2678
2679 assert!(
2680 !app.viewport.transcript_scroll.is_at_tail(),
2681 "tool-cell complete must not yank scroll position to bottom",
2682 );
2683 }
2684
2685 #[test]
2686 fn mark_history_updated_does_not_call_scroll_to_bottom() {
2687 // Behavior pin: future contributors must not add a scroll_to_bottom
2688 // here. The scroll-following logic lives only in `add_message` and
2689 // `flush_active_cell`, both gated on `user_scrolled_during_stream`.
2690 use crate::tui::scrolling::TranscriptScroll;
2691
2692 let mut app = create_test_app();
2693 app.viewport.transcript_scroll = TranscriptScroll::at_line(3);
2694 app.user_scrolled_during_stream = true;
2695
2696 app.mark_history_updated();
2697
2698 assert!(
2699 !app.viewport.transcript_scroll.is_at_tail(),
2700 "mark_history_updated must not scroll",
2701 );
2702 }
2703
2704 // ---- P2.3: thinking + tool calls render as one grouped block ----
2705
2706 #[test]
2707 fn thinking_then_tools_share_active_cell_until_text_flushes() {
2708 // Contract: a turn that emits Thinking → Tool → Tool keeps everything
2709 // inside `active_cell` (one logical "Working…" group) until the next
2710 // assistant prose chunk fires, at which point the group flushes into
2711 // history in original order.
2712 let mut app = create_test_app();
2713
2714 // 1. Thinking starts and streams a delta.
2715 let thinking_idx = ensure_streaming_thinking_active_entry(&mut app);
2716 append_streaming_thinking(&mut app, thinking_idx, "planning the read");
2717 assert!(
2718 app.history.is_empty(),
2719 "thinking must not write into history mid-turn"
2720 );
2721 assert_eq!(thinking_idx, 0);
2722
2723 // 2. Two tool calls land in the same active cell.
2724 handle_tool_call_started(
2725 &mut app,
2726 "t-1",
2727 "exec_shell",
2728 &serde_json::json!({"command": "ls"}),
2729 );
2730 handle_tool_call_started(
2731 &mut app,
2732 "t-2",
2733 "exec_shell",
2734 &serde_json::json!({"command": "pwd"}),
2735 );
2736
2737 let active = app
2738 .active_cell
2739 .as_ref()
2740 .expect("active cell present mid-turn");
2741 assert_eq!(
2742 active.entry_count(),
2743 3,
2744 "thinking + two exec entries share one active cell"
2745 );
2746 assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));
2747 assert!(matches!(
2748 active.entries()[1],
2749 HistoryCell::Tool(ToolCell::Exec(_))
2750 ));
2751 assert!(matches!(
2752 active.entries()[2],
2753 HistoryCell::Tool(ToolCell::Exec(_))
2754 ));
2755
2756 // 3. Thinking finalizes — entry stays in active cell, just stops streaming.
2757 let finalized = finalize_streaming_thinking_active_entry(&mut app, Some(1.5), "");
2758 assert!(finalized, "finalizer reports it touched the active cell");
2759 let HistoryCell::Thinking {
2760 streaming,
2761 duration_secs,
2762 content,
2763 ..
2764 } = &app
2765 .active_cell
2766 .as_ref()
2767 .expect("active cell still present after thinking complete")
2768 .entries()[0]
2769 else {
2770 panic!("expected thinking entry")
2771 };
2772 assert!(!streaming, "thinking spinner stops after finalize");
2773 assert_eq!(*duration_secs, Some(1.5));
2774 assert_eq!(content, "planning the read");
2775 assert!(
2776 app.streaming_thinking_active_entry.is_none(),
2777 "stream pointer cleared after finalize"
2778 );
2779
2780 // 4. Assistant prose arriving (simulated by flush) drains the group into
2781 // history in original order: Thinking → Tool → Tool.
2782 app.flush_active_cell();
2783 assert!(app.active_cell.is_none(), "active cell cleared after flush");
2784 assert_eq!(
2785 app.history.len(),
2786 3,
2787 "thinking + both tool entries land in history together"
2788 );
2789 assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));
2790 assert!(matches!(
2791 app.history[1],
2792 HistoryCell::Tool(ToolCell::Exec(_))
2793 ));
2794 assert!(matches!(
2795 app.history[2],
2796 HistoryCell::Tool(ToolCell::Exec(_))
2797 ));
2798 }
2799
2800 #[test]
2801 fn flush_active_cell_finalizes_unclosed_thinking_block() {
2802 // Defensive: if the engine fails to emit ThinkingComplete before the
2803 // assistant text arrives, `flush_active_cell` must still stop the
2804 // spinner so the migrated history cell isn't perpetually streaming.
2805 let mut app = create_test_app();
2806 let _ = ensure_streaming_thinking_active_entry(&mut app);
2807 append_streaming_thinking(&mut app, 0, "incomplete");
2808
2809 app.flush_active_cell();
2810
2811 assert_eq!(app.history.len(), 1);
2812 let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {
2813 panic!("expected thinking history cell")
2814 };
2815 assert!(
2816 !*streaming,
2817 "flush must stop the spinner even without ThinkingComplete"
2818 );
2819 assert!(
2820 app.streaming_thinking_active_entry.is_none(),
2821 "stream pointer cleared by flush"
2822 );
2823 }
2824
2825 #[test]
2826 fn second_thinking_block_appends_new_entry_in_same_active_cell() {
2827 // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any
2828 // prose; the second thinking block should land as a fresh entry inside
2829 // the SAME active cell rather than flush the first group prematurely.
2830 let mut app = create_test_app();
2831
2832 let _ = ensure_streaming_thinking_active_entry(&mut app);
2833 append_streaming_thinking(&mut app, 0, "first plan");
2834 let _ = finalize_streaming_thinking_active_entry(&mut app, Some(0.5), "");
2835
2836 handle_tool_call_started(
2837 &mut app,
2838 "t-1",
2839 "exec_shell",
2840 &serde_json::json!({"command": "ls"}),
2841 );
2842
2843 // Second Thinking block.
2844 let second_idx = ensure_streaming_thinking_active_entry(&mut app);
2845 assert_eq!(
2846 second_idx, 2,
2847 "second thinking entry follows the tool entry"
2848 );
2849 append_streaming_thinking(&mut app, second_idx, "second plan");
2850
2851 let active = app.active_cell.as_ref().expect("active cell present");
2852 assert_eq!(active.entry_count(), 3);
2853 assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));
2854 assert!(matches!(
2855 active.entries()[1],
2856 HistoryCell::Tool(ToolCell::Exec(_))
2857 ));
2858 assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));
2859 assert!(
2860 app.history.is_empty(),
2861 "the group still hasn't flushed — no prose yet"
2862 );
2863 }
2864
2865 // ---- per-child prompt wiring ----
2866 //
2867 // Generic tool cells default to `prompts: None`. Reserved for any future
2868 // fan-out tool that wants to surface per-child prompts.
2869
2870 #[test]
2871 fn non_fanout_tool_does_not_populate_prompts() {
2872 // Ordinary tools must use the standard `args:` summary rendering path.
2873 let mut app = create_test_app();
2874
2875 handle_tool_call_started(
2876 &mut app,
2877 "fs-1",
2878 "file_search",
2879 &serde_json::json!({ "query": "client.rs" }),
2880 );
2881
2882 let active = app.active_cell.as_ref().expect("active cell present");
2883 let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {
2884 panic!("expected GenericToolCell for file_search");
2885 };
2886
2887 assert!(
2888 generic.prompts.is_none(),
2889 "non-fan-out tool must not populate prompts"
2890 );
2891 }
2892 #[test]
2893 fn noisy_subagent_progress_keeps_existing_objective_summary() {
2894 let mut app = create_test_app();
2895 app.agent_progress.insert(
2896 "agent_live".to_string(),
2897 "starting: inspect release state".to_string(),
2898 );
2899
2900 let display =
2901 friendly_subagent_progress(&app, "agent_live", "step 1/8: requesting model response");
2902
2903 assert_eq!(display, "starting: inspect release state");
2904 }
2905
2906 /// Regression for issue #65: `truncate_line_to_width` with a tiny budget
2907 /// must respect display widths, not codepoint counts. The old branch counted
2908 /// chars and overran the budget for any double-width grapheme, which
2909 /// contributed to mid-character sidebar artifacts on resize.
2910 #[test]
2911 fn truncate_line_to_width_respects_display_width_for_tiny_budgets() {
2912 use unicode_width::UnicodeWidthStr;
2913
2914 let trimmed = truncate_line_to_width("Agents", 3);
2915 assert_eq!(trimmed, "Age");
2916 assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);
2917
2918 let trimmed_cjk = truncate_line_to_width("中文测试", 3);
2919 assert!(
2920 UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,
2921 "trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})",
2922 UnicodeWidthStr::width(trimmed_cjk.as_str()),
2923 );
2924
2925 assert_eq!(truncate_line_to_width("anything", 0), "");
2926 assert_eq!(truncate_line_to_width("hi", 10), "hi");
2927
2928 let trimmed_long = truncate_line_to_width("a long sidebar label", 10);
2929 assert!(trimmed_long.ends_with("..."));
2930 assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);
2931 }
2932
2933 /// Regression for #86. A recoverable engine error (stream stall, transient
2934 /// disconnect, retryable server hiccup) must NOT flip the session into
2935 /// offline mode. Until this fix the UI matched on `EngineEvent::Error {
2936 /// message, .. }` and unconditionally set `app.offline_mode = true`, so a
2937 /// long V4 thinking turn whose chunked stream got closed mid-flight ended
2938 /// the session in offline mode with the next typed message queued.
2939 #[test]
2940 fn recoverable_engine_error_does_not_enter_offline_mode() {
2941 use crate::error_taxonomy::{ErrorEnvelope, StreamError};
2942 let mut app = create_test_app();
2943 assert!(!app.offline_mode);
2944
2945 let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();
2946 apply_engine_error_to_app(&mut app, envelope);
2947
2948 assert!(
2949 !app.offline_mode,
2950 "recoverable error must keep the session online so the user can retry"
2951 );
2952 assert!(!app.is_loading);
2953 let status = app
2954 .status_message
2955 .as_deref()
2956 .expect("recoverable errors must set a status message");
2957 assert!(
2958 status.starts_with("Connection interrupted"),
2959 "expected interrupt-style status, got {status:?}"
2960 );
2961
2962 // Sanity: the rendered cell is the categorized Error variant, not a plain System note.
2963 let last = app
2964 .history
2965 .last()
2966 .expect("recoverable engine error should push a history cell");
2967 assert!(
2968 matches!(last, crate::tui::history::HistoryCell::Error { .. }),
2969 "expected HistoryCell::Error, got {last:?}"
2970 );
2971 let _ = ErrorEnvelope::transient("");
2972 }
2973
2974 /// Hard failures (auth, billing, malformed request) DO need to flip offline
2975 /// mode so subsequent typed messages get queued instead of silently lost
2976 /// against a broken upstream.
2977 #[test]
2978 fn non_recoverable_engine_error_enters_offline_mode() {
2979 use crate::error_taxonomy::ErrorEnvelope;
2980 let mut app = create_test_app();
2981 assert!(!app.offline_mode);
2982
2983 apply_engine_error_to_app(
2984 &mut app,
2985 ErrorEnvelope::fatal_auth("Authentication failed: invalid API key"),
2986 );
2987
2988 assert!(
2989 app.offline_mode,
2990 "non-recoverable error must enter offline mode"
2991 );
2992 assert!(!app.is_loading);
2993 let status = app
2994 .status_message
2995 .as_deref()
2996 .expect("non-recoverable errors must set a status message");
2997 assert!(
2998 status.starts_with("Engine error"),
2999 "expected engine-error status, got {status:?}"
3000 );
3001 }
3002
3003 #[test]
3004 fn env_only_auth_failure_reopens_api_key_onboarding() {
3005 use crate::error_taxonomy::ErrorEnvelope;
3006 let mut app = create_test_app();
3007 app.api_key_env_only = true;
3008 app.onboarding = crate::tui::app::OnboardingState::None;
3009 app.onboarding_needs_api_key = false;
3010
3011 apply_engine_error_to_app(
3012 &mut app,
3013 ErrorEnvelope::fatal_auth("Authentication failed: invalid API key"),
3014 );
3015
3016 assert!(app.offline_mode);
3017 assert_eq!(
3018 app.onboarding,
3019 crate::tui::app::OnboardingState::ApiKey,
3020 "env-only auth failures should prompt for a saved config key"
3021 );
3022 assert!(app.onboarding_needs_api_key);
3023 let status = app
3024 .status_message
3025 .as_deref()
3026 .expect("auth recovery should explain the env key source");
3027 assert!(
3028 status.contains("DEEPSEEK_API_KEY"),
3029 "expected env-specific recovery hint, got {status:?}"
3030 );
3031 }
3032
3033 // ---- Issue #208: in-flight input routing ----
3034
3035 #[test]
3036 fn next_escape_action_cancels_when_loading_with_empty_input() {
3037 let mut app = create_test_app();
3038 app.is_loading = true;
3039 app.input.clear();
3040 assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
3041 }
3042
3043 #[test]
3044 fn next_escape_action_cancels_when_loading_with_input() {
3045 let mut app = create_test_app();
3046 app.is_loading = true;
3047 app.input = "hold on, look at this instead".to_string();
3048 assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
3049 }
3050
3051 #[test]
3052 fn next_escape_action_treats_whitespace_only_as_empty() {
3053 let mut app = create_test_app();
3054 app.is_loading = true;
3055 app.input = " \n\t".to_string();
3056 assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);
3057 }
3058
3059 #[test]
3060 fn next_escape_action_idle_with_input_clears() {
3061 let mut app = create_test_app();
3062 app.is_loading = false;
3063 app.input = "draft".to_string();
3064 assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);
3065 }
3066
3067 #[test]
3068 fn next_escape_action_idle_empty_is_noop() {
3069 let mut app = create_test_app();
3070 app.is_loading = false;
3071 app.input.clear();
3072 assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);
3073 }
3074
3075 #[test]
3076 fn next_escape_action_slash_menu_takes_priority() {
3077 let mut app = create_test_app();
3078 app.is_loading = true;
3079 app.input = "anything".to_string();
3080 assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);
3081 }
3082
3083 #[test]
3084 fn tab_queues_running_turn_draft_for_next_turn() {
3085 let mut app = create_test_app();
3086 app.is_loading = true;
3087 app.input = "follow up next".to_string();
3088 app.cursor_position = app.input.chars().count();
3089
3090 assert!(queue_current_draft_for_next_turn(&mut app));
3091
3092 assert!(app.input.is_empty());
3093 assert_eq!(app.queued_message_count(), 1);
3094 assert_eq!(
3095 app.queued_messages.front().map(|msg| msg.display.as_str()),
3096 Some("follow up next")
3097 );
3098 assert!(
3099 app.status_message
3100 .as_deref()
3101 .is_some_and(|msg| msg.contains("queued — ↑"))
3102 );
3103 }
3104
3105 #[test]
3106 fn tab_queue_preserves_queued_draft_skill_instruction() {
3107 let mut app = create_test_app();
3108 app.is_loading = true;
3109 app.input = "edited queued follow-up".to_string();
3110 app.cursor_position = app.input.chars().count();
3111 app.queued_draft = Some(QueuedMessage::new(
3112 "original".to_string(),
3113 Some("skill body".to_string()),
3114 ));
3115
3116 assert!(queue_current_draft_for_next_turn(&mut app));
3117
3118 let queued = app.queued_messages.front().expect("queued message");
3119 assert_eq!(queued.display, "edited queued follow-up");
3120 assert_eq!(queued.skill_instruction.as_deref(), Some("skill body"));
3121 assert!(app.queued_draft.is_none());
3122 }
3123
3124 #[test]
3125 fn merge_pending_steers_returns_none_when_empty() {
3126 let mut app = create_test_app();
3127 assert!(merge_pending_steers(&mut app).is_none());
3128 assert!(!app.submit_pending_steers_after_interrupt);
3129 }
3130
3131 #[test]
3132 fn merge_pending_steers_passes_through_single_message() {
3133 let mut app = create_test_app();
3134 app.push_pending_steer(QueuedMessage::new(
3135 "lone steer".to_string(),
3136 Some("skill body".to_string()),
3137 ));
3138 let merged = merge_pending_steers(&mut app).expect("merge yields a message");
3139 assert_eq!(merged.display, "lone steer");
3140 assert_eq!(merged.skill_instruction.as_deref(), Some("skill body"));
3141 assert!(app.pending_steers.is_empty());
3142 assert!(!app.submit_pending_steers_after_interrupt);
3143 }
3144
3145 #[test]
3146 fn merge_pending_steers_concatenates_multiple_with_blank_line() {
3147 let mut app = create_test_app();
3148 app.push_pending_steer(QueuedMessage::new("first".to_string(), None));
3149 app.push_pending_steer(QueuedMessage::new("second".to_string(), None));
3150 app.push_pending_steer(QueuedMessage::new("third".to_string(), None));
3151
3152 let merged = merge_pending_steers(&mut app).expect("merge yields a message");
3153 assert_eq!(merged.display, "first\n\nsecond\n\nthird");
3154 assert!(app.pending_steers.is_empty());
3155 }
3156
3157 #[test]
3158 fn merge_pending_steers_keeps_first_skill_instruction_only() {
3159 let mut app = create_test_app();
3160 app.push_pending_steer(QueuedMessage::new(
3161 "a".to_string(),
3162 Some("first skill".to_string()),
3163 ));
3164 app.push_pending_steer(QueuedMessage::new(
3165 "b".to_string(),
3166 Some("second skill".to_string()),
3167 ));
3168 let merged = merge_pending_steers(&mut app).expect("merge yields a message");
3169 assert_eq!(merged.skill_instruction.as_deref(), Some("first skill"));
3170 assert_eq!(merged.display, "a\n\nb");
3171 }
3172
3173 #[test]
3174 fn build_pending_input_preview_populates_all_three_buckets() {
3175 let mut app = create_test_app();
3176 app.push_pending_steer(QueuedMessage::new("steer-msg".to_string(), None));
3177 app.rejected_steers.push_back("rejected-msg".to_string());
3178 app.queue_message(QueuedMessage::new("queued-msg".to_string(), None));
3179
3180 let preview = build_pending_input_preview(&app);
3181 assert_eq!(preview.pending_steers, vec!["steer-msg".to_string()]);
3182 assert_eq!(preview.rejected_steers, vec!["rejected-msg".to_string()]);
3183 assert_eq!(preview.queued_messages, vec!["queued-msg".to_string()]);
3184 }
3185
3186 #[test]
3187 fn build_pending_input_preview_includes_current_context_chips() {
3188 let tmpdir = TempDir::new().expect("tempdir");
3189 std::fs::write(tmpdir.path().join("guide.md"), "hello").expect("write");
3190 let mut app = create_test_app();
3191 app.workspace = tmpdir.path().to_path_buf();
3192 app.input = "Read @guide.md and @missing.md".to_string();
3193 app.cursor_position = app.input.chars().count();
3194
3195 let preview = build_pending_input_preview(&app);
3196
3197 assert!(
3198 preview
3199 .context_items
3200 .iter()
3201 .any(|item| item.kind == "file" && item.label == "guide.md" && item.included),
3202 "file mention preview missing: {:?}",
3203 preview.context_items
3204 );
3205 assert!(
3206 preview
3207 .context_items
3208 .iter()
3209 .any(|item| item.kind == "missing" && item.label == "missing.md" && !item.included),
3210 "missing mention preview missing: {:?}",
3211 preview.context_items
3212 );
3213 }
3214
3215 #[test]
3216 fn render_footer_from_with_default_items_renders_mode_and_model() {
3217 // Default footer composition should show the mode chip and model
3218 // identifier — whatever the configured default model is.
3219 let mut app = create_test_app();
3220 app.session.session_cost = 0.00005;
3221 let items = crate::config::StatusItem::default_footer();
3222 let props = render_footer_from(&app, &items, None);
3223 assert_eq!(props.mode_label, "agent");
3224 assert!(!props.model.is_empty(), "footer should show a model name");
3225 // Tiny but real costs should render instead of disappearing as "$0.00".
3226 assert!(!props.cost.is_empty());
3227 assert_eq!(spans_text(&props.cost), "<$0.0001");
3228 }
3229
3230 #[test]
3231 fn render_footer_from_with_empty_items_blanks_every_segment() {
3232 // A user who toggles every chip OFF should get a bare footer (no model
3233 // text, no cost, no auxiliary chips). This is the explicit-empty case.
3234 let mut app = create_test_app();
3235 app.session.session_cost = 1.5;
3236 let props = render_footer_from(&app, &[], None);
3237 assert_eq!(props.mode_label, "");
3238 assert!(props.model.is_empty());
3239 assert!(props.cost.is_empty());
3240 assert!(props.coherence.is_empty());
3241 assert!(props.agents.is_empty());
3242 assert!(props.cache.is_empty());
3243 }
3244
3245 #[test]
3246 fn render_footer_from_drops_only_unselected_clusters() {
3247 // Toggling Cost off but keeping the rest should hide cost only.
3248 let mut app = create_test_app();
3249 app.session.session_cost = 0.42;
3250 let items: Vec<crate::config::StatusItem> = crate::config::StatusItem::default_footer()
3251 .into_iter()
3252 .filter(|item| *item != crate::config::StatusItem::Cost)
3253 .collect();
3254 let props = render_footer_from(&app, &items, None);
3255 assert_eq!(props.mode_label, "agent");
3256 assert!(!props.model.is_empty(), "footer should show a model name");
3257 assert!(
3258 props.cost.is_empty(),
3259 "cost cluster should be empty when Cost is disabled"
3260 );
3261 }
3262
3263 /// Regression for issue #244: visible session spend must not decrease.
3264 /// Sub-agent token usage events arrive out of order and may be reconciled
3265 /// later (cache adjustments, provisional → final swap). The displayed total
3266 /// is anchored to a high-water mark so users never see a number go down
3267 /// during a single session.
3268 #[test]
3269 fn displayed_session_cost_is_monotonic_under_negative_reconciliation() {
3270 let mut app = create_test_app();
3271 app.accrue_subagent_cost(0.50);
3272 let after_first = app.displayed_session_cost();
3273 assert!((after_first - 0.50).abs() < 1e-6);
3274
3275 // Simulate reconciliation that lowers the underlying counter (e.g. a
3276 // cache discount applied after the fact). The underlying value drops,
3277 // but the displayed cost must not.
3278 app.session.subagent_cost = 0.20;
3279 let after_recon = app.displayed_session_cost();
3280 assert!(
3281 after_recon >= after_first,
3282 "displayed cost regressed: {after_recon} < {after_first}"
3283 );
3284
3285 // Adding more cost should still bump above the high-water.
3286 app.accrue_session_cost(0.10);
3287 let after_add = app.displayed_session_cost();
3288 assert!(after_add >= after_first);
3289 }
3290
3291 /// Regression for issue #244: deduplicated mailbox events must not
3292 /// decrement displayed cost — they should leave it untouched and the
3293 /// next genuine event must extend it monotonically.
3294 #[test]
3295 fn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {
3296 let mut app = create_test_app();
3297 let usage = crate::tools::subagent::MailboxMessage::TokenUsage {
3298 agent_id: "agent-x".to_string(),
3299 model: "deepseek-v4-flash".to_string(),
3300 usage: crate::models::Usage {
3301 input_tokens: 10_000,
3302 output_tokens: 1_000,
3303 ..Default::default()
3304 },
3305 };
3306 handle_subagent_mailbox(&mut app, 11, &usage);
3307 let baseline = app.displayed_session_cost();
3308 assert!(baseline > 0.0);
3309
3310 // Re-emit the same seq — must be deduped, displayed cost unchanged.
3311 handle_subagent_mailbox(&mut app, 11, &usage);
3312 assert!(
3313 (app.displayed_session_cost() - baseline).abs() < 1e-9,
3314 "duplicate mailbox seq must not move displayed cost"
3315 );
3316
3317 // A fresh seq must extend the displayed cost upward.
3318 handle_subagent_mailbox(&mut app, 12, &usage);
3319 assert!(app.displayed_session_cost() > baseline);
3320 }
3321 #[test]
3322 fn checklist_write_renders_dedicated_card() {
3323 let cell = GenericToolCell {
3324 name: "checklist_write".to_string(),
3325 status: ToolStatus::Success,
3326 input_summary: None,
3327 output: Some(
3328 "Todo list updated (3 items, 33% complete)\n{\"items\":[{\"id\":1,\"content\":\"Plan it out\",\"status\":\"completed\"},{\"id\":2,\"content\":\"Wire the thing\",\"status\":\"in_progress\"},{\"id\":3,\"content\":\"Run gates\",\"status\":\"pending\"}],\"completion_pct\":33,\"in_progress_id\":2}"
3329 .to_string(),
3330 ),
3331 prompts: None,
3332 spillover_path: None,
3333 };
3334 let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);
3335 let text: Vec<String> = lines
3336 .iter()
3337 .map(|line| {
3338 line.spans
3339 .iter()
3340 .map(|span| span.content.as_ref())
3341 .collect::<String>()
3342 })
3343 .collect();
3344 let joined = text.join("\n");
3345
3346 assert!(
3347 joined.contains("1/3"),
3348 "header must include completed/total: {joined}"
3349 );
3350 assert!(
3351 joined.contains("33%"),
3352 "header must include percent: {joined}"
3353 );
3354 assert!(
3355 joined.contains("Plan it out"),
3356 "items must render content: {joined}"
3357 );
3358 assert!(
3359 !joined.contains("\"items\""),
3360 "raw JSON must NOT appear: {joined}"
3361 );
3362 }
3363
3364 // ---- scroll_with_arrows ----
3365
3366 #[test]
3367 fn scroll_with_arrows_returns_true_when_input_empty() {
3368 let app = create_test_app();
3369 assert!(
3370 super::should_scroll_with_arrows(&app),
3371 "empty composer: Up/Down should scroll transcript"
3372 );
3373 }
3374
3375 #[test]
3376 fn scroll_with_arrows_returns_true_when_input_only_whitespace() {
3377 let mut app = create_test_app();
3378 app.input = " ".to_string();
3379 assert!(
3380 super::should_scroll_with_arrows(&app),
3381 "whitespace-only composer: Up/Down should scroll transcript"
3382 );
3383 }
3384
3385 #[test]
3386 fn scroll_with_arrows_returns_false_when_input_has_text() {
3387 let mut app = create_test_app();
3388 app.input = "hello".to_string();
3389 assert!(
3390 !super::should_scroll_with_arrows(&app),
3391 "text in composer: Up/Down should navigate history"
3392 );
3393 }
3394
3395 #[test]
3396 fn notification_settings_tui_always_keeps_configured_method_no_threshold() {
3397 let config = Config {
3398 tui: Some(crate::config::TuiConfig {
3399 notification_condition: Some(crate::config::NotificationCondition::Always),
3400 ..Default::default()
3401 }),
3402 notifications: Some(crate::config::NotificationsConfig {
3403 method: crate::config::NotificationMethod::Bel,
3404 threshold_secs: 120,
3405 include_summary: true,
3406 }),
3407 ..Config::default()
3408 };
3409
3410 let (method, threshold, include_summary) =
3411 super::notification_settings(&config).expect("notification should be enabled");
3412 assert_eq!(method, crate::tui::notifications::Method::Bel);
3413 assert_eq!(threshold, Duration::ZERO);
3414 assert!(include_summary);
3415 }
3416
3417 #[test]
3418 fn notification_settings_tui_never_disables_notifications() {
3419 let config = Config {
3420 tui: Some(crate::config::TuiConfig {
3421 notification_condition: Some(crate::config::NotificationCondition::Never),
3422 ..Default::default()
3423 }),
3424 ..Config::default()
3425 };
3426
3427 assert!(super::notification_settings(&config).is_none());
3428 }
3429
3430 #[test]
3431 fn notification_settings_no_tui_override_uses_notifications_block() {
3432 let config = Config {
3433 notifications: Some(crate::config::NotificationsConfig {
3434 method: crate::config::NotificationMethod::Osc9,
3435 threshold_secs: 45,
3436 include_summary: false,
3437 }),
3438 ..Config::default()
3439 };
3440
3441 let (method, threshold, include_summary) =
3442 super::notification_settings(&config).expect("notification should be enabled");
3443 assert_eq!(method, crate::tui::notifications::Method::Osc9);
3444 assert_eq!(threshold, Duration::from_secs(45));
3445 assert!(!include_summary);
3446 }
3447
3448 #[test]
3449 fn completed_turn_notification_uses_streaming_text() {
3450 let app = create_test_app();
3451 let msg = super::completed_turn_notification_message(
3452 &app,
3453 "Hello there.\n\nWhat's next?",
3454 false,
3455 Duration::from_secs(12),
3456 None,
3457 );
3458 assert_eq!(msg, "Hello there.\nWhat's next?");
3459 }
3460
3461 #[test]
3462 fn completed_turn_notification_falls_back_to_latest_assistant_message() {
3463 let mut app = create_test_app();
3464 app.api_messages.push(crate::models::Message {
3465 role: "assistant".to_string(),
3466 content: vec![crate::models::ContentBlock::Text {
3467 text: "Earlier turn".to_string(),
3468 cache_control: None,
3469 }],
3470 });
3471 app.api_messages.push(crate::models::Message {
3472 role: "user".to_string(),
3473 content: vec![crate::models::ContentBlock::Text {
3474 text: "next".to_string(),
3475 cache_control: None,
3476 }],
3477 });
3478 app.api_messages.push(crate::models::Message {
3479 role: "assistant".to_string(),
3480 content: vec![crate::models::ContentBlock::Text {
3481 text: "Latest reply".to_string(),
3482 cache_control: None,
3483 }],
3484 });
3485
3486 let msg =
3487 super::completed_turn_notification_message(&app, "", false, Duration::from_secs(75), None);
3488 assert_eq!(msg, "Latest reply");
3489 }
3490
3491 #[test]
3492 fn completed_turn_notification_falls_back_to_default_when_empty() {
3493 let app = create_test_app();
3494 let msg =
3495 super::completed_turn_notification_message(&app, "", false, Duration::from_secs(5), None);
3496 assert_eq!(msg, "deepseek: turn complete");
3497 }
3498
3499 #[test]
3500 fn completed_turn_notification_truncates_long_text() {
3501 let app = create_test_app();
3502 let long = "a".repeat(500);
3503 let msg = super::completed_turn_notification_message(
3504 &app,
3505 &long,
3506 false,
3507 Duration::from_secs(5),
3508 None,
3509 );
3510 assert!(msg.ends_with("..."));
3511 // 360-char body + 3-char ellipsis
3512 assert_eq!(msg.chars().count(), 363);
3513 }
3514
3514 lines RUST