1use std::collections::{HashMap, HashSet};
27
28use ratatui::style::{Color, Modifier, Style};
29use unicode_width::UnicodeWidthStr;
30
31#[derive(Clone, Copy, PartialEq, Eq, Debug)]
35pub struct Paint(pub &'static str);
36
37pub const PLAIN: Paint = Paint("");
38pub const LABEL_CUR: Paint = Paint("\x1b[33m");
40pub const LABEL_OTHER: Paint = Paint("\x1b[36m");
41pub const MARK_INPUT: Paint = Paint("\x1b[1;33m");
43pub const MARK_RUN: Paint = Paint("\x1b[2m");
45pub const MARK_RESTART: Paint = Paint("\x1b[36m");
49pub const PATH: Paint = Paint("\x1b[90m");
50pub const MODE_ASK: Paint = Paint("\x1b[35m");
53pub const MODE_EDIT: Paint = Paint("\x1b[95m");
54pub const MODE_AUTO: Paint = Paint("\x1b[1;95m");
55pub const VER_STALE: Paint = Paint("\x1b[33m");
58pub const VER_OK: Paint = Paint("\x1b[2;35m");
59
60impl Paint {
61 pub fn style(self) -> Style {
62 match self.0 {
63 "\x1b[33m" => Style::default().fg(Color::Yellow),
64 "\x1b[36m" => Style::default().fg(Color::Cyan),
65 "\x1b[1;33m" => Style::default()
66 .fg(Color::Yellow)
67 .add_modifier(Modifier::BOLD),
68 "\x1b[2m" => Style::default().add_modifier(Modifier::DIM),
69 "\x1b[90m" => Style::default().fg(Color::DarkGray),
70 "\x1b[35m" => Style::default().fg(Color::Magenta),
71 "\x1b[95m" => Style::default().fg(Color::LightMagenta),
72 "\x1b[1;95m" => Style::default()
73 .fg(Color::LightMagenta)
74 .add_modifier(Modifier::BOLD),
75 "\x1b[2;35m" => Style::default()
76 .fg(Color::Magenta)
77 .add_modifier(Modifier::DIM),
78 _ => Style::default(),
79 }
80 }
81}
82
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct Cell {
85 pub text: String,
86 pub paint: Paint,
87}
88
89fn cell(text: impl Into<String>, paint: Paint) -> Cell {
90 Cell {
91 text: text.into(),
92 paint,
93 }
94}
95
96#[derive(Clone, Debug)]
97pub struct Row {
98 pub cells: Vec<Cell>,
99 pub pane_id: String,
100 pub target: String,
103 pub cwd: String,
104 pub host: String,
107}
108
109impl Row {
110 pub fn to_ansi(&self) -> String {
113 let mut s = String::new();
114 for c in &self.cells {
115 if c.paint == PLAIN {
116 s.push_str(&c.text);
117 } else {
118 s.push_str(c.paint.0);
119 s.push_str(&c.text);
120 s.push_str("\x1b[0m");
121 }
122 }
123 s.push('\t');
124 s.push_str(&self.pane_id);
125 s
126 }
127
128 #[allow(dead_code)] pub fn plain(&self) -> String {
133 self.cells.iter().map(|c| c.text.as_str()).collect()
134 }
135}
136
137fn vlen(s: &str) -> usize {
142 UnicodeWidthStr::width(s)
143}
144
145fn spaces(n: usize) -> String {
146 " ".repeat(n)
147}
148
149fn pad(s: &str, w: usize) -> String {
153 let mut out = s.to_string();
154 out.push_str(&spaces(w.saturating_sub(vlen(s))));
155 out
156}
157
158pub fn summary_of(title: &str) -> &str {
169 let run: usize = title
170 .chars()
171 .take_while(|c| !(' '..='~').contains(c))
172 .map(|c| c.len_utf8())
173 .sum();
174 if run == 0 {
175 return title;
176 }
177 let rest = &title[run..];
178 match rest.strip_prefix(' ') {
179 Some(r) => r,
180 None if rest.is_empty() => rest,
181 None => title,
182 }
183}
184
185fn mode_paint(mode: &str) -> Paint {
189 match mode {
190 "acceptEdits" => MODE_EDIT,
191 "bypassPermissions" | "auto" => MODE_AUTO,
192 _ => MODE_ASK,
193 }
194}
195
196fn outdated(host: &str, agent: &str, v: &str, state: &str, newver: &str) -> bool {
208 state != "dead"
209 && host.is_empty()
210 && agent == "claude"
211 && !newver.is_empty()
212 && !v.is_empty()
213 && v != newver
214}
215
216fn version_paint(host: &str, agent: &str, v: &str, state: &str, newver: &str) -> Paint {
218 if outdated(host, agent, v, state, newver) {
219 VER_STALE
220 } else {
221 VER_OK
222 }
223}
224
225fn abbrev(names: &[String], minlen: usize) -> HashMap<String, String> {
235 let mut out = HashMap::new();
236 for s in names {
237 let chars: Vec<char> = s.chars().collect();
238 let mut n = chars.len() + 1; for k in 1..=chars.len() {
240 let head: String = chars.iter().take(k).collect();
241 let clash = names
242 .iter()
243 .any(|t| t != s && t.chars().take(k).collect::<String>() == head);
244 if !clash {
245 n = k;
246 break;
247 }
248 }
249 let n = n.max(minlen);
250 out.insert(s.clone(), chars.iter().take(n).collect());
251 }
252 out
253}
254
255fn path_display(cwd: &str, home: &str) -> String {
259 let cwd = if !home.is_empty() && cwd.starts_with(home) {
260 format!("~{}", &cwd[home.len()..])
261 } else {
262 cwd.to_string()
263 };
264 let parts: Vec<&str> = cwd.split('/').collect();
265 let disp = if parts.len() >= 2 {
266 format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
267 } else {
268 cwd.clone()
269 };
270 let n = disp.chars().count();
271 if n > 30 {
272 let tail: String = disp.chars().skip(n - 29).collect();
274 format!("…{}", tail)
275 } else {
276 disp
277 }
278}
279
280fn says_it(s: &str, terms: &[String], fold: bool) -> bool {
284 if terms.is_empty() {
285 return false;
286 }
287 let hay = if fold {
288 s.to_lowercase()
289 } else {
290 s.to_string()
291 };
292 terms
293 .iter()
294 .all(|t| t.is_empty() || hay.contains(t.as_str()))
295}
296
297fn trim_trailing(cells: &mut Vec<Cell>) {
301 while let Some(last) = cells.last_mut() {
302 if last.paint != PLAIN {
303 break;
304 }
305 let trimmed = last.text.trim_end_matches(' ');
306 if trimmed.len() == last.text.len() {
307 break; }
309 last.text.truncate(trimmed.len());
310 if !last.text.is_empty() {
311 break; }
313 cells.pop();
314 }
315}
316
317struct Item {
319 id: String,
320 target: String,
321 agent: String,
322 version: String,
323 state: String,
324 mode: String,
325 title: String,
326 host: String,
327 note: bool,
330 path: String,
332 cwd: String,
334 session: String,
335}
336
337#[derive(Default)]
338pub struct Input<'a> {
339 pub cur: &'a str,
340 pub width: usize,
342 pub home: &'a str,
343 pub newver: &'a str,
346 pub only: &'a str,
348 pub outdated: bool,
356 pub query: &'a str,
357 pub snips: HashMap<String, String>,
366 pub ptitles: HashMap<String, String>,
369 pub restarting: HashSet<String>,
377}
378
379pub fn build(lines: &str, input: &Input) -> Vec<Row> {
380 let terms: Vec<String> = input
381 .query
382 .split([' ', '\t'])
383 .filter(|t| !t.is_empty())
384 .map(|t| t.to_string())
385 .collect();
386 let fold = input.query == input.query.to_lowercase();
388
389 let mut items: Vec<Item> = Vec::new();
390 for line in lines.lines() {
391 let f: Vec<&str> = line.split('\t').collect();
392 if f.len() < 7 {
393 continue;
394 }
395 let state = f[5];
396 if !input.only.is_empty() && state != input.only {
397 continue;
398 }
399 let (id, target, cwd) = (f[0], f[1], f[2]);
400
401 let (mut host, mut note) = (String::new(), false);
406 if !id.starts_with('%') {
407 if let Some(c) = id.find(':') {
408 if c > 0 {
409 if id[c + 1..].starts_with('%') {
410 host = id[..c].to_string();
411 } else {
412 note = true;
413 }
414 }
415 }
416 }
417 if input.outdated && !outdated(&host, f[3], f[4], state, input.newver) {
420 continue;
421 }
422 let session = match target.find(':') {
423 Some(c) if c > 0 => target[..c].to_string(),
424 _ => target.to_string(),
425 };
426 items.push(Item {
427 id: id.to_string(),
428 target: target.to_string(),
429 agent: f[3].to_string(),
430 version: f[4].to_string(),
431 state: state.to_string(),
432 mode: f[6].to_string(),
433 title: f.get(7).copied().unwrap_or("").to_string(),
434 host,
435 note,
436 path: path_display(cwd, input.home),
437 cwd: cwd.to_string(),
438 session,
439 });
440 }
441
442 let compact = input.width > 0 && input.width < 100;
448 let mut names: Vec<String> = Vec::new();
449 let mut hnames: Vec<String> = Vec::new();
450 for it in &items {
451 if !it.note && !names.contains(&it.session) {
452 names.push(it.session.clone());
453 }
454 if !it.host.is_empty() && !hnames.contains(&it.host) {
455 hnames.push(it.host.clone());
456 }
457 }
458 let (short, shorth) = if compact {
459 (abbrev(&names, 1), abbrev(&hnames, 2))
460 } else {
461 (HashMap::new(), HashMap::new())
462 };
463
464 let mut labels: Vec<String> = Vec::new();
469 let mut labelw = 0;
470 for it in &items {
471 let lbl = if it.note {
472 it.target.clone()
473 } else {
474 let pfx = if it.host.is_empty() {
475 String::new()
476 } else if compact {
477 format!("{}/", shorth.get(&it.host).unwrap_or(&it.host))
478 } else {
479 format!("{}/", it.host)
480 };
481 let body = if compact {
482 let s = short.get(&it.session).cloned().unwrap_or_default();
483 format!("{}{}", s, &it.target[it.session.len()..])
484 } else {
485 it.target.clone()
486 };
487 format!("{}{}", pfx, body)
488 };
489 labelw = labelw.max(vlen(&lbl) + 2);
490 labels.push(lbl);
491 }
492 let panes = items.iter().filter(|i| !i.note).count();
503 if compact {
504 labelw = labelw.min(15);
505 } else if panes > 0 {
506 labelw = labelw.max(15);
507 }
508
509 let mut agw = 0;
514 let mut verw = 0;
515 let mut pathw = 0;
516 for it in &items {
517 agw = agw.max(vlen(&it.agent));
518 verw = verw.max(vlen(&it.version));
519 pathw = pathw.max(vlen(&it.path));
520 }
521 pathw = pathw.min(30);
522 let tailw = pathw + 1 + agw + if verw > 0 { 1 + verw } else { 0 };
523
524 let mut out = Vec::new();
525 for (i, it) in items.iter().enumerate() {
526 let is_cur = it.id == input.cur;
527 let mark = if is_cur { "● " } else { " " };
528 let plabel = pad(&format!("{}{}", mark, labels[i]), labelw);
529
530 let mut sum = summary_of(&it.title).to_string();
531 if sum.is_empty() {
535 if let Some(t) = input.ptitles.get(&it.id) {
536 sum = t.clone();
537 }
538 }
539
540 let mut cells = vec![
541 cell(plabel.clone(), if is_cur { LABEL_CUR } else { LABEL_OTHER }),
542 cell(" ", PLAIN),
543 ];
544 if input.restarting.contains(&it.id) {
550 cells.push(cell("↻", MARK_RESTART));
551 cells.push(cell(" ", PLAIN));
552 } else {
553 match it.state.as_str() {
554 "input" => {
555 cells.push(cell("✳", MARK_INPUT));
556 cells.push(cell(" ", PLAIN));
557 }
558 "run" => {
559 cells.push(cell("◐", MARK_RUN));
560 cells.push(cell(" ", PLAIN));
561 }
562 _ => cells.push(cell(" ", PLAIN)),
563 }
564 }
565 cells.push(cell(sum.clone(), PLAIN));
566
567 let snip = input.snips.get(&it.id).filter(|_| {
573 !says_it(
574 &format!("{} {} {} {} {}", plabel, sum, it.path, it.agent, it.version),
575 &terms,
576 fold,
577 )
578 });
579 if let Some(s) = snip {
580 let stail = format!("⌕ {}", s);
581 let gap = gap_of(input.width, &plabel, &sum, vlen(&stail));
582 cells.push(cell(spaces(gap), PLAIN));
583 cells.push(cell(stail, PATH));
584 } else {
585 let gap = gap_of(input.width, &plabel, &sum, tailw);
586 cells.push(cell(spaces(gap), PLAIN));
587 cells.push(cell(pad(&it.path, pathw), PATH));
588 cells.push(cell(" ", PLAIN));
589 if it.agent.is_empty() {
594 cells.push(cell(spaces(agw), PLAIN));
595 } else {
596 cells.push(cell(spaces(agw - vlen(&it.agent)), PLAIN));
597 cells.push(cell(it.agent.clone(), mode_paint(&it.mode)));
598 }
599 if verw > 0 {
600 if it.version.is_empty() {
601 cells.push(cell(format!(" {}", spaces(verw)), PLAIN));
602 } else {
603 cells.push(cell(
604 format!(" {}", spaces(verw - vlen(&it.version))),
605 PLAIN,
606 ));
607 cells.push(cell(
608 it.version.clone(),
609 version_paint(&it.host, &it.agent, &it.version, &it.state, input.newver),
610 ));
611 }
612 }
613 }
614 trim_trailing(&mut cells);
615 cells.retain(|c| !c.text.is_empty());
616 out.push(Row {
617 cells,
618 pane_id: it.id.clone(),
619 target: it.target.clone(),
620 cwd: it.cwd.clone(),
621 host: it.host.clone(),
622 });
623 }
624 out
625}
626
627fn gap_of(width: usize, plabel: &str, sum: &str, tailw: usize) -> usize {
631 let used = vlen(plabel) + 1 + 2 + vlen(sum) + tailw;
632 width.saturating_sub(used).max(2)
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 fn rows(lines: &str, cur: &str, width: usize) -> Vec<String> {
640 build(
641 lines,
642 &Input {
643 cur,
644 width,
645 home: "/home/p",
646 ..Default::default()
647 },
648 )
649 .iter()
650 .map(|r| r.to_ansi())
651 .collect()
652 }
653
654 const THREE: &str = "%10\twork:1.1\t/home/p/proj/web\tclaude\t2.1.229\trun\t-\t◐ Refactor auth\n\
655 %11\tops:2.1\t/home/p\tgemini\t0.41.2\trun\t-\t⠂ tests\n\
656 %12\tops:3.1\t/home/p/longdir/another-very-long-project-name-here\tcodex\t\trun\t-\t◐ X";
657
658 #[test]
659 fn the_pane_id_is_the_hidden_last_field() {
660 let r = rows(THREE, "%11", 100);
661 assert!(r[0].ends_with("\t%10"));
662 assert!(r[1].ends_with("\t%11"));
663 }
664
665 #[test]
666 fn only_the_current_pane_is_marked() {
667 let r = rows(THREE, "%11", 100);
668 assert!(!r[0].contains('●'));
669 assert!(r[1].contains('●'));
670 }
671
672 #[test]
675 fn the_title_marker_is_replaced_by_the_state_marker() {
676 let r = rows(THREE, "%11", 100);
677 assert!(r[0].contains("Refactor auth"));
678 assert!(!r[0].contains("◐ Refactor")); assert!(r[0].contains("\x1b[2m◐\x1b[0m ")); }
681
682 #[test]
683 fn summary_of_strips_only_a_leading_glyph_run() {
684 assert_eq!(summary_of("◐ Refactor auth"), "Refactor auth");
685 assert_eq!(summary_of("⠂ tests"), "tests");
686 assert_eq!(summary_of("plain title"), "plain title");
687 assert_eq!(summary_of("étude du code"), "étude du code");
690 assert_eq!(summary_of("✳"), "");
692 }
693
694 #[test]
695 fn paths_fold_home_and_keep_the_last_two_components() {
696 assert_eq!(path_display("/home/p/proj/web", "/home/p"), "proj/web");
697 assert_eq!(path_display("/home/p", "/home/p"), "~");
698 assert_eq!(path_display("/var/log", "/home/p"), "var/log");
699 }
700
701 #[test]
702 fn a_long_path_is_elided_from_the_left_to_thirty() {
703 let d = path_display(
704 "/home/p/longdir/another-very-long-project-name-here",
705 "/home/p",
706 );
707 assert_eq!(d.chars().count(), 30);
708 assert!(d.starts_with('…'));
709 assert!(d.ends_with("name-here"));
710 }
711
712 fn col_of(line: &str, needle: &str) -> usize {
718 let s = strip(line);
719 let b = s.find(needle).expect("needle on the row");
720 vlen(&s[..b])
721 }
722
723 #[test]
724 fn the_trailing_columns_line_up_down_the_list() {
725 let r = rows(THREE, "%11", 100);
726 let a = col_of(&r[0], "proj/web");
728 assert_eq!(col_of(&r[1], "~"), a);
729 assert_eq!(col_of(&r[2], "…"), a);
730 }
731
732 fn strip(s: &str) -> String {
733 let mut out = String::new();
734 let mut it = s.chars();
735 while let Some(c) = it.next() {
736 if c == '\x1b' {
737 for c in it.by_ref() {
738 if c == 'm' {
739 break;
740 }
741 }
742 } else {
743 out.push(c);
744 }
745 }
746 out
747 }
748
749 #[test]
752 fn a_missing_version_keeps_its_column_but_leaves_no_trailing_space() {
753 let r = rows(THREE, "%11", 100);
754 assert!(!strip(&r[2]).split('\t').next().unwrap().ends_with(' '));
755 assert!(strip(&r[2]).contains("codex"));
756 }
757
758 #[test]
759 fn abbreviates_to_the_shortest_prefix_that_still_tells_names_apart() {
760 let n: Vec<String> = ["main", "master", "ops"]
761 .iter()
762 .map(|s| s.to_string())
763 .collect();
764 let a = abbrev(&n, 1);
765 assert_eq!(a["ops"], "o");
766 assert_eq!(a["main"], "mai");
767 assert_eq!(a["master"], "mas");
768 }
769
770 #[test]
773 fn a_name_that_contains_another_is_kept_whole() {
774 let n: Vec<String> = ["main", "main2"].iter().map(|s| s.to_string()).collect();
775 let a = abbrev(&n, 1);
776 assert_eq!(a["main"], "main");
777 assert_eq!(a["main2"], "main2");
778 }
779
780 #[test]
781 fn the_host_floor_is_two_letters() {
782 let n: Vec<String> = ["laptop-two", "ha"].iter().map(|s| s.to_string()).collect();
783 let a = abbrev(&n, 2);
784 assert_eq!(a["laptop-two"], "la");
785 assert_eq!(a["ha"], "ha");
786 }
787
788 #[test]
792 fn the_label_floor_applies_to_pane_rows_and_the_cap_to_narrow_windows() {
793 let short = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
794 let wide = rows(short, "", 200);
795 let label_end = strip(&wide[0]).find("hi").unwrap();
796 assert_eq!(label_end, 15 + 1 + 2); let narrow = rows(short, "", 60);
800 assert!(strip(&narrow[0]).find("hi").unwrap() < 15);
801 }
802
803 #[test]
804 fn a_row_with_no_room_still_leaves_two_columns_of_gap() {
805 let r = rows(THREE, "%11", 0);
806 for line in &r {
807 assert!(strip(line).contains(" "));
808 }
809 }
810
811 #[test]
812 fn trims_only_a_trailing_run_of_unpainted_spaces() {
813 let mut c = vec![cell("a", PLAIN), cell("b ", PLAIN)];
814 trim_trailing(&mut c);
815 assert_eq!(c, vec![cell("a", PLAIN), cell("b", PLAIN)]);
816
817 let mut c = vec![cell("a", PLAIN), cell(" ", PLAIN), cell(" ", PLAIN)];
820 trim_trailing(&mut c);
821 assert_eq!(c, vec![cell("a", PLAIN)]);
822
823 let mut c = vec![cell("x ", PATH), cell("", PLAIN)];
825 trim_trailing(&mut c);
826 assert_eq!(c[0].text, "x ");
827 }
828
829 #[test]
830 fn the_permission_mode_rides_on_the_agent_name() {
831 let base = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t";
832 let ask = rows(&format!("{}default\thi", base), "", 100);
833 let edit = rows(&format!("{}acceptEdits\thi", base), "", 100);
834 let auto = rows(&format!("{}bypassPermissions\thi", base), "", 100);
835 assert!(ask[0].contains("\x1b[35mclaude"));
836 assert!(edit[0].contains("\x1b[95mclaude"));
837 assert!(auto[0].contains("\x1b[1;95mclaude"));
838 }
839
840 #[test]
841 fn a_stale_version_goes_yellow_only_where_ctrl_x_could_act() {
842 let line = "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi";
843 let stale = build(
844 line,
845 &Input {
846 newver: "2.0",
847 width: 100,
848 ..Default::default()
849 },
850 );
851 assert!(stale[0].to_ansi().contains("\x1b[33m1.0"));
852
853 let current = build(
855 line,
856 &Input {
857 newver: "1.0",
858 width: 100,
859 ..Default::default()
860 },
861 );
862 assert!(current[0].to_ansi().contains("\x1b[2;35m1.0"));
863
864 let remote = build(
866 "ha:%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\thi",
867 &Input {
868 newver: "2.0",
869 width: 100,
870 ..Default::default()
871 },
872 );
873 assert!(remote[0].to_ansi().contains("\x1b[2;35m1.0"));
874
875 let dead = build(
877 "%1\tw:1.1\t/home/p\tclaude\t1.0\tdead\t-\thi",
878 &Input {
879 newver: "2.0",
880 width: 100,
881 ..Default::default()
882 },
883 );
884 assert!(dead[0].to_ansi().contains("\x1b[2;35m1.0"));
885 }
886
887 #[test]
888 fn a_host_that_could_not_answer_keeps_its_name_whole() {
889 let r = build(
892 "laptop-two:unreachable\tlaptop-two: no answer\t\t\t\tnote\t\t",
893 &Input {
894 width: 60,
895 only: "note",
896 ..Default::default()
897 },
898 );
899 assert!(r[0].to_ansi().contains("laptop-two: no answer"));
900 }
901
902 #[test]
906 fn the_outdated_list_holds_exactly_the_rows_painted_yellow() {
907 let lines = "%1\ta:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tbehind\n\
908 %2\tb:1.1\t/home/p\tclaude\t2.1.243\trun\t-\tcurrent\n\
909 %3\tc:1.1\t/home/p\tgemini\t0.41.2\tinput\t-\tanother agent\n\
910 ha:%4\td:1.1\t/home/p\tclaude\t2.1.229\tidle\t-\tover there";
911 let r = build(
912 lines,
913 &Input {
914 newver: "2.1.243",
915 width: 100,
916 outdated: true,
917 ..Default::default()
918 },
919 );
920 let ids: Vec<&str> = r.iter().map(|r| r.pane_id.as_str()).collect();
921 assert_eq!(ids, ["%1"]);
922 assert!(r[0].to_ansi().contains("\x1b[33m2.1.229"));
923
924 let none = build(
927 lines,
928 &Input {
929 width: 100,
930 outdated: true,
931 ..Default::default()
932 },
933 );
934 assert!(none.is_empty());
935 }
936
937 #[test]
940 fn the_outdated_list_is_not_one_state() {
941 let lines = "%1\ta:1.1\t/home/p\tclaude\t1.0\tinput\t-\tasking\n\
942 %2\tb:1.1\t/home/p\tclaude\t1.0\trun\t-\tworking\n\
943 %3\tc:1.1\t/home/p\tclaude\t1.0\tidle\t-\tidle";
944 let r = build(
945 lines,
946 &Input {
947 newver: "2.0",
948 width: 100,
949 outdated: true,
950 ..Default::default()
951 },
952 );
953 assert_eq!(r.len(), 3);
954 }
955
956 #[test]
957 fn one_state_only_when_asked() {
958 let r = build(
959 THREE,
960 &Input {
961 only: "run",
962 width: 100,
963 ..Default::default()
964 },
965 );
966 assert_eq!(r.len(), 3);
967 let r = build(
968 THREE,
969 &Input {
970 only: "input",
971 width: 100,
972 ..Default::default()
973 },
974 );
975 assert!(r.is_empty());
976 }
977
978 #[test]
979 fn a_blank_pane_title_borrows_the_one_its_conversation_recorded() {
980 let mut ptitles = HashMap::new();
981 ptitles.insert("%1".to_string(), "what it called itself".to_string());
982 let r = build(
983 "%1\tw:1.1\t/home/p\tclaude\t1.0\tidle\t-\t",
984 &Input {
985 width: 100,
986 ptitles,
987 ..Default::default()
988 },
989 );
990 assert!(r[0].plain().contains("what it called itself"));
991 }
992
993 #[test]
997 fn a_search_snippet_replaces_the_tail_unless_the_row_already_says_it() {
998 let mut snips = HashMap::new();
999 snips.insert("%10".to_string(), "…the words it said…".to_string());
1000 snips.insert("%11".to_string(), "…other words…".to_string());
1001 let r = build(
1002 THREE,
1003 &Input {
1004 cur: "%11",
1005 width: 100,
1006 home: "/home/p",
1007 query: "refactor",
1008 snips,
1009 ..Default::default()
1010 },
1011 );
1012 assert!(r[0].plain().contains("proj/web"));
1014 assert!(!r[0].plain().contains('⌕'));
1015 assert!(r[1].plain().contains("⌕ …other words…"));
1017 }
1018
1019 #[test]
1020 fn says_it_is_smart_case_like_fzf() {
1021 let lower = vec!["refactor".to_string()];
1022 assert!(says_it("Refactor auth", &lower, true));
1023 let upper = vec!["Refactor".to_string()];
1024 assert!(!says_it("refactor auth", &upper, false));
1025 }
1026}