1use super::clip::{self, SaveError};
9use super::exec::{self, Invocation, Plan, Route};
10use super::fit;
11use super::journal::{Event, Journal};
12use super::repeat_view::{self, Ended, RepeatView};
13use super::vocab::Vocab;
14use crate::cli::interactive::SessionContext;
15use crate::cli::output::{Captured, Output, SeriesHook};
16use crate::cli::textfit::{ELLIPSIS, cols, cut_with_ellipsis, draw_note_alone};
17use crate::cli::theme::{ACCENT, ERR, INFO, KEY, MATCH, OK, PaneState, SECONDARY, SELECTED, WARN};
18use ansi_to_tui::IntoText;
19use chrono::Utc;
20use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
21use ratatui::Frame;
22use ratatui::layout::{Constraint, Layout, Position};
23use ratatui::style::{Modifier, Style};
24use ratatui::text::{Line, Span, Text};
25use ratatui::widgets::{Block, Borders, Paragraph};
26use scope::config::Config;
27use scope::display::theme::color_enabled;
28use std::cell::Cell;
29use std::time::Instant;
30use unicode_width::UnicodeWidthStr;
31
32pub const SCROLLBACK_LIMIT: usize = 5000;
34
35pub const MIN_W: u16 = 40;
37pub const MIN_H: u16 = 10;
39
40fn too_small_note() -> String {
43 format!("Terminal too small: need at least {MIN_W}×{MIN_H}")
44}
45
46pub const PASTE_LIMIT: usize = 10_000;
48
49const BIDI_CONTROLS: [std::ops::RangeInclusive<char>; 2] =
51 ['\u{202A}'..='\u{202E}', '\u{2066}'..='\u{2069}'];
52
53pub const BUSY_NOTE: &str = "A command is running; Esc or Ctrl-C cancels it first.";
55
56const MENU_ROWS: usize = 8;
58
59pub enum Overlay {
61 Menu(Menu),
63 Palette(super::palette::PaletteState),
65 Search(SearchState),
67 HistorySearch(HistorySearchState),
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73enum EscAction {
74 ClosePalette,
75 CloseMenu,
76 CloseSearch,
77 CloseHistorySearch,
78 Cancel,
80 ClearLine,
82}
83
84impl EscAction {
85 fn hint(self) -> Option<&'static str> {
88 match self {
89 Self::ClosePalette => Some("Esc closes palette"),
90 Self::CloseMenu => Some("Esc closes menu"),
91 Self::CloseSearch => Some("Esc closes search"),
92 Self::CloseHistorySearch => Some("Esc closes history search"),
93 Self::Cancel => Some("Esc cancels"),
94 Self::ClearLine => None,
95 }
96 }
97}
98
99const NO_MATCH: &str = "no match";
101
102const NOTE_GLYPH: &str = "ℹ";
104
105const MATCH_MARK: &str = "▶ ";
107
108const MATCH_BLANK: &str = " ";
110
111struct SearchView {
113 matches: Vec<usize>,
114 current: Option<usize>,
115 text: String,
116}
117
118pub struct SearchState {
120 pub query: String,
122 editing: bool,
124 current: Option<usize>,
126 saved_scroll: usize,
128}
129
130pub struct HistorySearchState {
132 pub query: String,
134 current: Option<usize>,
136 draft: String,
138}
139
140pub struct Menu {
142 pub start: usize,
144 pub candidates: Vec<String>,
146 pub selected: usize,
148}
149
150fn line_feed_as_enter(key: KeyEvent) -> KeyEvent {
154 let ctrl_j = key.code == KeyCode::Char('j') && key.modifiers.contains(KeyModifiers::CONTROL);
155 if ctrl_j || key.code == KeyCode::Char('\n') {
156 KeyEvent {
157 code: KeyCode::Enter,
158 modifiers: KeyModifiers::NONE,
159 ..key
160 }
161 } else {
162 key
163 }
164}
165
166fn common_prefix(items: &[String]) -> String {
167 let first = &items[0];
168 let mut end = first.len();
169 for s in &items[1..] {
170 end = end.min(
171 first
172 .bytes()
173 .zip(s.bytes())
174 .take_while(|(a, b)| a == b)
175 .count(),
176 );
177 }
178 while !first.is_char_boundary(end) {
179 end -= 1;
180 }
181 first[..end].to_string()
182}
183
184#[derive(Debug, PartialEq, Eq)]
186pub enum Action {
187 None,
189 Submit(String),
191 Cancel,
193 Exit,
195 Suspend,
197 Copy(String),
200}
201
202fn alt_key(key: &KeyEvent) -> bool {
204 key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::CONTROL)
205}
206
207const PALETTE_MARKER: &str = "> ";
209
210const PALETTE_LEGEND: &str = "↑↓ select · Enter puts it on the line (Enter again runs) · Esc close";
213
214pub struct Running {
216 pub input: String,
218 pub argv: Vec<String>,
220 pub started: Instant,
222 pub started_at: String,
224 pub captured: Captured,
226 out: Output,
228 series: Option<SeriesHook>,
230 _diag: scope::diag::RedirectGuard,
232}
233
234pub enum Ending {
236 Finished(scope::error::Result<()>),
238 Cancelled,
240}
241
242impl Ending {
243 fn outcome(&self) -> (&'static str, Option<String>) {
246 match self {
247 Ending::Finished(Ok(())) => ("ok", None),
248 Ending::Finished(Err(e)) => ("error", Some(e.to_string())),
249 Ending::Cancelled => ("cancelled", None),
250 }
251 }
252}
253
254pub struct TuiState {
256 pub input: String,
258 cursor: usize,
260 pub history: Vec<String>,
262 hist_pos: Option<usize>,
264 draft: String,
266 scrollback: Vec<Line<'static>>,
268 scroll: Cell<usize>,
270 too_small: Cell<bool>,
272 last_max_top: Cell<usize>,
274 last_pane_height: Cell<usize>,
277 pub ctx: SessionContext,
279 pub running: Option<Running>,
281 failed: bool,
284 scroll_x: Cell<usize>,
287 pane_width: Cell<u16>,
290 busy_hint: bool,
292 dropped_lines: usize,
294 cleared: Option<String>,
297 last_output: Option<String>,
300 repeat_view: Option<RepeatView>,
302 last_series: Option<SeriesHook>,
304 journal: Journal,
306 vocab: Vocab,
308 pub overlay: Option<Overlay>,
310 reveal: Cell<bool>,
313 search_width: Cell<usize>,
316}
317
318impl TuiState {
319 pub fn new(ctx: SessionContext, journal: Journal, vocab: Vocab) -> Self {
321 let history = journal.load_history();
322 Self {
323 input: String::new(),
324 cursor: 0,
325 history,
326 hist_pos: None,
327 draft: String::new(),
328 scrollback: Vec::new(),
329 scroll: Cell::new(0),
330 too_small: Cell::new(false),
331 last_max_top: Cell::new(0),
332 last_pane_height: Cell::new(0),
333 ctx,
334 running: None,
335 failed: false,
336 scroll_x: Cell::new(0),
337 pane_width: Cell::new(0),
338 busy_hint: false,
339 last_output: None,
340 dropped_lines: 0,
341 cleared: None,
342 repeat_view: None,
343 last_series: None,
344 journal,
345 vocab,
346 overlay: None,
347 reveal: Cell::new(false),
348 search_width: Cell::new(0),
349 }
350 }
351
352 pub fn journal(&self) -> &Journal {
354 &self.journal
355 }
356
357 pub fn handle_key(&mut self, key: KeyEvent) -> Action {
361 let key = line_feed_as_enter(key);
362 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
363 self.busy_hint = false;
364 if ctrl && key.code == KeyCode::Char('z') {
367 return Action::Suspend;
368 }
369 if self.running.is_none()
372 && let Some(view) = self.repeat_view.take()
373 {
374 self.leave_repeat_view(view);
375 }
376 if ctrl
380 && key.code == KeyCode::Char('c')
381 && matches!(
382 self.overlay,
383 Some(Overlay::Palette(_) | Overlay::Search(_) | Overlay::HistorySearch(_))
384 )
385 {
386 self.close_overlay();
387 }
388 if self.too_small.get() && self.overlay.is_some() {
391 self.close_overlay();
392 return Action::None;
393 }
394 if key.code == KeyCode::Esc {
395 return self.press_esc();
396 }
397 match self.overlay.take() {
398 Some(Overlay::Search(st)) => {
399 self.search_key(st, key);
400 return Action::None;
401 }
402 Some(Overlay::HistorySearch(st)) => {
403 self.history_search_key(st, key);
404 return Action::None;
405 }
406 other => self.overlay = other,
407 }
408 if let Some(Overlay::Palette(p)) = &mut self.overlay {
409 let count = p.matches().len();
410 match key.code {
411 KeyCode::Enter => {
412 let fill = p
413 .matches()
414 .get(p.selected)
415 .map(|&i| p.items[i].fill.clone());
416 self.overlay = None;
417 if let Some(fill) = fill {
418 self.set_input(fill);
419 }
420 }
421 KeyCode::Down | KeyCode::Tab if count > 0 => p.selected = (p.selected + 1) % count,
422 KeyCode::Up | KeyCode::BackTab if count > 0 => {
423 p.selected = (p.selected + count - 1) % count
424 }
425 KeyCode::Backspace => {
426 p.query.pop();
427 p.selected = 0;
428 }
429 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
430 p.query.push(c);
431 p.selected = 0;
432 }
433 _ => {}
434 }
435 return Action::None;
436 }
437 if let Some(Overlay::Menu(menu)) = &mut self.overlay {
438 match key.code {
439 KeyCode::Tab | KeyCode::Down => {
440 menu.selected = (menu.selected + 1) % menu.candidates.len();
441 return Action::None;
442 }
443 KeyCode::BackTab | KeyCode::Up => {
444 menu.selected =
445 (menu.selected + menu.candidates.len() - 1) % menu.candidates.len();
446 return Action::None;
447 }
448 KeyCode::Enter => {
449 let (start, pick) = (menu.start, menu.candidates[menu.selected].clone());
450 self.overlay = None;
451 self.replace_word(start, &pick, true);
452 return Action::None;
453 }
454 _ => self.overlay = None,
455 }
456 }
457 match key.code {
458 KeyCode::Char('c') if ctrl => {
459 if self.running.is_some() {
460 return Action::Cancel;
461 }
462 if self.input.is_empty() {
463 self.push_note("Type `exit` or press Ctrl-D to leave.");
464 } else {
465 self.set_input(String::new());
466 }
467 }
468 KeyCode::Char('y') if ctrl => return self.copy_key(),
469 KeyCode::Char('d') if ctrl => {
470 if self.running.is_some() {
471 self.busy_hint = true;
472 self.push_busy_note();
473 } else if self.input.is_empty() {
474 return Action::Exit;
475 }
476 }
477 KeyCode::Enter => {
478 if self.too_small.get() {
480 return Action::None;
481 }
482 if self.running.is_some() {
484 if !self.input.trim().is_empty() {
485 self.busy_hint = true;
486 if self.input_is_exit() {
487 self.push_busy_note();
488 }
489 }
490 return Action::None;
491 }
492 let line = std::mem::take(&mut self.input);
493 self.cursor = 0;
494 self.hist_pos = None;
495 self.scroll.set(0);
496 if !line.trim().is_empty() && self.history.last() != Some(&line) {
497 self.history.push(line.clone());
498 }
499 return Action::Submit(line);
500 }
501 KeyCode::Tab if !self.too_small.get() => self.tab_complete(),
504 KeyCode::Up if self.input.is_empty() && self.cleared.is_some() => {
505 if let Some(line) = self.cleared.take() {
506 self.set_input(line);
507 }
508 }
509 KeyCode::Up => self.history_prev(),
510 KeyCode::Down => self.history_next(),
511 KeyCode::PageUp => {
512 let max = self.last_max_top.get();
513 self.scroll.set(
514 self.scroll
515 .get()
516 .min(max)
517 .saturating_add(self.page())
518 .min(max),
519 );
520 }
521 KeyCode::PageDown => {
522 self.scroll.set(
523 self.scroll
524 .get()
525 .min(self.last_max_top.get())
526 .saturating_sub(self.page()),
527 );
528 }
529 KeyCode::Home if ctrl => self.scroll.set(self.last_max_top.get()),
530 KeyCode::End if ctrl => self.scroll.set(0),
531 KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
532 KeyCode::Right => self.cursor = (self.cursor + 1).min(self.char_len()),
533 KeyCode::Home => self.cursor = 0,
534 KeyCode::End if self.input.is_empty() => self.scroll.set(0),
537 KeyCode::End => self.cursor = self.char_len(),
538 KeyCode::Char('f') if ctrl && !self.too_small.get() => self.open_search(),
539 KeyCode::Char('/')
540 if !ctrl
541 && !key.modifiers.contains(KeyModifiers::ALT)
542 && self.input.is_empty()
543 && !self.too_small.get() =>
544 {
545 self.open_search()
546 }
547 KeyCode::Char('r') if ctrl && !self.too_small.get() => self.open_history_search(),
548 KeyCode::Char('k') if ctrl && !self.too_small.get() => {
549 self.overlay = Some(Overlay::Palette(super::palette::PaletteState::new(
550 super::palette::items(&self.history),
551 )));
552 }
553 KeyCode::Char('a') if ctrl => self.cursor = 0,
554 KeyCode::Char('b') if ctrl => self.cursor = self.cursor.saturating_sub(1),
558 KeyCode::Char('b') if alt_key(&key) => self.cursor = self.word_left(),
559 KeyCode::Char('f') if alt_key(&key) => self.cursor = self.word_right(),
560 KeyCode::Char('k') if alt_key(&key) => {
561 let kept: String = self.input.chars().take(self.cursor).collect();
562 let cut: String = self.input.chars().skip(self.cursor).collect();
563 self.keep_cleared(cut);
564 self.input = kept;
565 }
566 KeyCode::Char('e') if ctrl => self.cursor = self.char_len(),
567 KeyCode::Char('u') if ctrl => {
568 let cut: String = self.input.chars().take(self.cursor).collect();
569 let rest: String = self.input.chars().skip(self.cursor).collect();
570 self.keep_cleared(cut);
571 self.input = rest;
572 self.cursor = 0;
573 }
574 KeyCode::Char('w') if ctrl => self.delete_word(),
575 KeyCode::Backspace => {
576 self.scroll.set(0);
577 if self.cursor > 0 {
578 self.cursor -= 1;
579 self.remove_char(self.cursor);
580 }
581 }
582 KeyCode::Delete => {
583 self.scroll.set(0);
584 if self.cursor < self.char_len() {
585 self.remove_char(self.cursor);
586 }
587 }
588 KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => {
589 self.scroll.set(0);
590 let at = self.byte_index(self.cursor);
591 self.input.insert(at, c);
592 self.cursor += 1;
593 }
594 _ => {}
595 }
596 Action::None
597 }
598
599 pub fn paste(&mut self, text: &str) {
604 let unified = text.replace("\r\n", "\n").replace('\r', "\n");
605 let body = unified.strip_suffix('\n').unwrap_or(&unified);
606 let lines = body.split('\n').count();
607 let mut clean: String = body
608 .replace('\n', " ")
609 .chars()
610 .map(|c| if c == '\t' { ' ' } else { c })
611 .filter(|c| !c.is_control() && !BIDI_CONTROLS.iter().any(|r| r.contains(c)))
612 .collect();
613 if clean.is_empty() {
614 return;
615 }
616 if !matches!(
618 self.overlay,
619 Some(Overlay::Search(_) | Overlay::HistorySearch(_))
620 ) {
621 self.scroll.set(0);
622 }
623 let truncated = clean.chars().count() > PASTE_LIMIT;
624 if truncated {
625 clean = clean.chars().take(PASTE_LIMIT).collect();
626 }
627 match self.overlay.take() {
628 Some(Overlay::Search(st)) => self.search_type(st, &clean),
629 Some(Overlay::HistorySearch(st)) => self.history_search_type(st, &clean),
630 Some(Overlay::Palette(mut p)) => {
632 p.query.push_str(&clean);
633 p.selected = 0;
634 self.overlay = Some(Overlay::Palette(p));
635 }
636 _ => {
637 let at = self.byte_index(self.cursor);
638 self.input.insert_str(at, &clean);
639 self.cursor += clean.chars().count();
640 }
641 }
642 if lines > 1 {
643 self.push_note(&format!("pasted {} lines as one line", lines));
644 }
645 if truncated {
646 self.push_note(&format!("paste truncated to {} characters", PASTE_LIMIT));
647 }
648 }
649
650 fn close_overlay(&mut self) {
655 match self.overlay.take() {
656 Some(Overlay::Search(st)) => self.scroll.set(st.saved_scroll),
657 Some(Overlay::HistorySearch(st)) => self.set_input(st.draft),
658 _ => {}
659 }
660 }
661
662 fn open_search(&mut self) {
663 self.overlay = Some(Overlay::Search(SearchState {
664 query: String::new(),
665 editing: true,
666 current: None,
667 saved_scroll: self.scroll.get().min(self.last_max_top.get()),
668 }));
669 }
670
671 fn open_history_search(&mut self) {
672 self.hist_pos = None;
673 self.overlay = Some(Overlay::HistorySearch(HistorySearchState {
674 query: String::new(),
675 current: None,
676 draft: self.input.clone(),
677 }));
678 }
679
680 fn live_output(&self) -> Vec<Line<'static>> {
683 self.running
684 .as_ref()
685 .and_then(|run| run.captured.out().into_text().ok())
686 .map(|t| t.lines)
687 .unwrap_or_default()
688 }
689
690 fn search_matches(&self, query: &str) -> Vec<usize> {
693 self.matches_in(query, &self.live_output())
694 }
695
696 fn matches_in(&self, query: &str, live: &[Line<'static>]) -> Vec<usize> {
697 if query.is_empty() {
698 return Vec::new();
699 }
700 let needle = query.to_lowercase();
701 self.scrollback
702 .iter()
703 .chain(live)
704 .enumerate()
705 .filter(|(_, line)| line.to_string().to_lowercase().contains(&needle))
706 .map(|(i, _)| i)
707 .collect()
708 }
709
710 fn history_matches(&self, query: &str) -> Vec<usize> {
712 if query.is_empty() {
713 return Vec::new();
714 }
715 let needle = query.to_lowercase();
716 self.history
717 .iter()
718 .enumerate()
719 .filter(|(_, line)| line.to_lowercase().contains(&needle))
720 .map(|(i, _)| i)
721 .collect()
722 }
723
724 fn rank(matches: &[usize], current: Option<usize>) -> Option<(usize, usize)> {
727 let at = matches.binary_search(¤t?).ok()?;
728 Some((matches.len() - at, matches.len()))
729 }
730
731 fn step(matches: &[usize], current: Option<usize>, older: bool) -> Option<usize> {
733 let last = matches.len().checked_sub(1)?;
734 let at = current.and_then(|c| matches.binary_search(&c).ok());
735 let next = match (at, older) {
736 (None, _) => last,
737 (Some(0), true) => last,
738 (Some(a), true) => a - 1,
739 (Some(a), false) if a == last => 0,
740 (Some(a), false) => a + 1,
741 };
742 Some(matches[next])
743 }
744
745 fn search_type(&mut self, mut st: SearchState, text: &str) {
747 st.editing = true;
748 st.query.push_str(text);
749 self.search_refind(&mut st);
750 self.overlay = Some(Overlay::Search(st));
751 }
752
753 fn search_refind(&self, st: &mut SearchState) {
754 st.current = self.search_matches(&st.query).last().copied();
755 self.reveal.set(st.current.is_some());
756 }
757
758 fn search_key(&mut self, mut st: SearchState, key: KeyEvent) {
759 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
760 let alt = key.modifiers.contains(KeyModifiers::ALT);
761 let walk = |s: &mut Self, st: &mut SearchState, older: bool| {
762 let found = Self::step(&s.search_matches(&st.query), st.current, older);
763 if found.is_some() {
764 st.current = found;
765 s.reveal.set(true);
766 }
767 };
768 match key.code {
769 KeyCode::Char('f') if ctrl => st.editing = !st.editing,
770 KeyCode::Enter if st.editing => st.editing = false,
771 KeyCode::Enter => walk(self, &mut st, true),
773 KeyCode::Backspace if st.editing => {
774 st.query.pop();
775 self.search_refind(&mut st);
776 }
777 KeyCode::Backspace => st.editing = true,
778 KeyCode::Char(c) if !ctrl && !alt && st.editing => {
779 let mut buf = [0u8; 4];
780 return self.search_type(st, c.encode_utf8(&mut buf));
781 }
782 KeyCode::Char('n') if !ctrl && !alt => walk(self, &mut st, true),
783 KeyCode::Char('N') if !ctrl && !alt => walk(self, &mut st, false),
784 _ => {}
785 }
786 self.overlay = Some(Overlay::Search(st));
787 }
788
789 fn history_search_type(&mut self, mut st: HistorySearchState, text: &str) {
791 st.query.push_str(text);
792 self.history_refind(&mut st);
793 self.overlay = Some(Overlay::HistorySearch(st));
794 }
795
796 fn history_refind(&mut self, st: &mut HistorySearchState) {
798 st.current = self.history_matches(&st.query).last().copied();
799 self.show_history_match(st);
800 }
801
802 fn show_history_match(&mut self, st: &HistorySearchState) {
803 let line = match st.current {
804 Some(i) => self.history[i].clone(),
805 None => st.draft.clone(),
806 };
807 self.set_input(line);
808 }
809
810 fn history_search_key(&mut self, mut st: HistorySearchState, key: KeyEvent) {
811 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
812 let alt = key.modifiers.contains(KeyModifiers::ALT);
813 match key.code {
814 KeyCode::Enter => return,
816 KeyCode::Char('r') if ctrl => {
817 let found = Self::step(&self.history_matches(&st.query), st.current, true);
818 if found.is_some() {
819 st.current = found;
820 self.show_history_match(&st);
821 }
822 }
823 KeyCode::Backspace => {
824 st.query.pop();
825 self.history_refind(&mut st);
826 }
827 KeyCode::Char(c) if !ctrl && !alt => {
828 let mut buf = [0u8; 4];
829 return self.history_search_type(st, c.encode_utf8(&mut buf));
830 }
831 _ => {}
832 }
833 self.overlay = Some(Overlay::HistorySearch(st));
834 }
835
836 fn search_view(&self, live: &[Line<'static>]) -> Option<SearchView> {
839 match &self.overlay {
840 Some(Overlay::Search(st)) => {
841 let matches = self.matches_in(&st.query, live);
842 let current = st
844 .current
845 .filter(|c| matches.binary_search(c).is_ok())
846 .or(matches.last().copied());
847 let text = Self::search_text("search", &st.query, &matches, current);
848 Some(SearchView {
849 matches,
850 current,
851 text,
852 })
853 }
854 Some(Overlay::HistorySearch(st)) => {
855 let matches = self.history_matches(&st.query);
856 let text = Self::search_text("history search", &st.query, &matches, st.current);
857 Some(SearchView {
858 matches: Vec::new(),
859 current: None,
860 text,
861 })
862 }
863 _ => None,
864 }
865 }
866
867 fn search_text(label: &str, query: &str, matches: &[usize], cur: Option<usize>) -> String {
868 if query.is_empty() {
869 return format!("{label}: type to search");
870 }
871 match Self::rank(matches, cur) {
872 Some((n, total)) => format!("{label}: {query} {n}/{total}"),
873 None => format!("{label}: {query} {NO_MATCH}"),
874 }
875 }
876
877 fn page(&self) -> usize {
880 self.last_pane_height.get().saturating_sub(1).max(1)
881 }
882
883 fn input_scroll(&self, width: usize) -> usize {
888 let mut bounds = vec![0usize];
889 let mut col = 0;
890 for c in self.input.chars() {
891 col += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
892 bounds.push(col);
893 }
894 let total = col;
895 let cursor_col = self.cursor_column();
896 let cursor_w = self
897 .input
898 .chars()
899 .nth(self.cursor)
900 .map_or(1, |c| {
901 unicode_width::UnicodeWidthChar::width(c)
902 .unwrap_or(0)
903 .max(1)
904 })
905 .min(width);
906 let down = |x: usize| bounds.iter().copied().rfind(|&b| b <= x).unwrap_or(0);
907 let up = |x: usize| bounds.iter().copied().find(|&b| b >= x).unwrap_or(total);
908 let mut scroll = down(self.scroll_x.get().min((total + 1).saturating_sub(width)));
909 if cursor_col < scroll {
910 scroll = cursor_col;
911 }
912 if cursor_col + cursor_w > scroll + width {
913 scroll = up(cursor_col + cursor_w - width);
914 }
915 self.scroll_x.set(scroll);
916 scroll
917 }
918
919 fn cursor_column(&self) -> usize {
921 UnicodeWidthStr::width(&self.input[..self.byte_index(self.cursor)])
922 }
923
924 fn input_is_exit(&self) -> bool {
926 self.input
927 .split_whitespace()
928 .next()
929 .is_some_and(|w| exec::session_command_name(w) == "exit")
930 }
931
932 fn push_busy_note(&mut self) {
934 let last = self.scrollback.last().map(|l| l.to_string());
935 if last.as_deref() != Some(format!("{NOTE_GLYPH} {BUSY_NOTE}").as_str()) {
936 self.push_note(BUSY_NOTE);
937 }
938 }
939
940 fn tab_complete(&mut self) {
941 if self.cursor != self.char_len() {
942 return;
943 }
944 let c = super::complete::complete(&self.input, &self.vocab);
945 match c.candidates.len() {
946 0 => {}
947 1 => self.replace_word(c.start, &c.candidates[0], true),
948 _ => {
949 let prefix = common_prefix(&c.candidates);
950 self.replace_word(c.start, &prefix, false);
951 self.overlay = Some(Overlay::Menu(Menu {
952 start: c.start,
953 candidates: c.candidates,
954 selected: 0,
955 }));
956 }
957 }
958 }
959
960 fn replace_word(&mut self, start: usize, word: &str, space: bool) {
962 self.input.truncate(start);
963 self.input.push_str(word);
964 if space {
965 self.input.push(' ');
966 }
967 self.cursor = self.char_len();
968 }
969
970 fn char_len(&self) -> usize {
971 self.input.chars().count()
972 }
973
974 fn byte_index(&self, char_idx: usize) -> usize {
975 self.input
976 .char_indices()
977 .nth(char_idx)
978 .map_or(self.input.len(), |(i, _)| i)
979 }
980
981 fn remove_char(&mut self, char_idx: usize) {
982 let at = self.byte_index(char_idx);
983 self.input.remove(at);
984 }
985
986 fn keep_cleared(&mut self, text: String) {
988 if !text.trim().is_empty() {
989 self.cleared = Some(text);
990 }
991 }
992
993 fn word_left(&self) -> usize {
995 let chars: Vec<char> = self.input.chars().collect();
996 let mut at = self.cursor;
997 while at > 0 && chars[at - 1] == ' ' {
998 at -= 1;
999 }
1000 while at > 0 && chars[at - 1] != ' ' {
1001 at -= 1;
1002 }
1003 at
1004 }
1005
1006 fn word_right(&self) -> usize {
1008 let chars: Vec<char> = self.input.chars().collect();
1009 let mut at = self.cursor;
1010 while at < chars.len() && chars[at] == ' ' {
1011 at += 1;
1012 }
1013 while at < chars.len() && chars[at] != ' ' {
1014 at += 1;
1015 }
1016 at
1017 }
1018
1019 fn delete_word(&mut self) {
1020 let chars: Vec<char> = self.input.chars().collect();
1021 let mut start = self.cursor;
1022 while start > 0 && chars[start - 1] == ' ' {
1023 start -= 1;
1024 }
1025 while start > 0 && chars[start - 1] != ' ' {
1026 start -= 1;
1027 }
1028 self.input = chars[..start].iter().chain(&chars[self.cursor..]).collect();
1029 self.cursor = start;
1030 }
1031
1032 fn set_input(&mut self, s: String) {
1033 self.cursor = s.chars().count();
1034 self.input = s;
1035 }
1036
1037 fn history_prev(&mut self) {
1038 if self.history.is_empty() {
1039 return;
1040 }
1041 let pos = match self.hist_pos {
1042 None => {
1043 self.draft = self.input.clone();
1044 self.history.len() - 1
1045 }
1046 Some(0) => 0,
1047 Some(p) => p - 1,
1048 };
1049 self.hist_pos = Some(pos);
1050 self.set_input(self.history[pos].clone());
1051 }
1052
1053 fn history_next(&mut self) {
1054 match self.hist_pos {
1055 None => {}
1056 Some(p) if p + 1 < self.history.len() => {
1057 self.hist_pos = Some(p + 1);
1058 self.set_input(self.history[p + 1].clone());
1059 }
1060 Some(_) => {
1061 self.hist_pos = None;
1062 let draft = std::mem::take(&mut self.draft);
1063 self.set_input(draft);
1064 }
1065 }
1066 }
1067
1068 pub fn push_ansi(&mut self, text: &str) {
1072 let text = text.strip_suffix('\n').unwrap_or(text);
1073 if text.is_empty() {
1074 return;
1075 }
1076 let lines = match text.into_text() {
1077 Ok(t) => t.lines,
1078 Err(_) => text.lines().map(|l| Line::raw(l.to_string())).collect(),
1080 };
1081 self.push_lines(lines);
1082 }
1083
1084 pub fn push_note(&mut self, note: &str) {
1086 self.push_lines(vec![Line::from(vec![
1087 Span::styled(NOTE_GLYPH, INFO),
1088 Span::styled(format!(" {note}"), SECONDARY),
1089 ])]);
1090 }
1091
1092 fn push_styled_note(&mut self, note: &str, style: Style) {
1094 self.push_lines(vec![Line::styled(note.to_string(), style)]);
1095 }
1096
1097 fn push_lines(&mut self, lines: Vec<Line<'static>>) {
1098 self.scrollback.extend(lines);
1099 let excess = self.scrollback.len().saturating_sub(SCROLLBACK_LIMIT);
1100 self.scrollback.drain(..excess);
1101 self.dropped_lines += excess;
1102 if let Some(Overlay::Search(st)) = &mut self.overlay {
1105 st.current = st.current.and_then(|c| c.checked_sub(excess));
1106 }
1107 }
1108
1109 fn push_echo(&mut self, line: &str) {
1110 self.push_lines(vec![Line::from(vec![
1111 Span::styled("scope", ACCENT),
1112 Span::styled(format!(":{}", self.ctx.chain), KEY),
1113 Span::raw("> "),
1114 Span::raw(line.to_string()),
1115 ])]);
1116 }
1117
1118 fn prompt(&self) -> Line<'static> {
1120 Line::from(vec![
1121 Span::styled("scope", ACCENT),
1122 Span::styled(format!(":{}", self.ctx.chain), KEY),
1123 Span::raw("> "),
1124 ])
1125 }
1126
1127 fn pane_state(&self) -> PaneState {
1129 if self.running.is_some() {
1130 PaneState::Running
1131 } else if self.failed {
1132 PaneState::Failed
1133 } else {
1134 PaneState::Idle
1135 }
1136 }
1137
1138 pub fn scrollback_text(&self) -> String {
1140 let mut s = String::new();
1141 for line in &self.scrollback {
1142 for span in &line.spans {
1143 s.push_str(&span.content);
1144 }
1145 s.push('\n');
1146 }
1147 s
1148 }
1149
1150 pub fn submit(&mut self, line: &str, config: &Config) -> Plan {
1155 self.push_echo(line);
1156 if !line.trim().is_empty() {
1160 self.failed = false;
1161 }
1162 if line
1165 .split_whitespace()
1166 .next()
1167 .map(exec::session_command_name)
1168 .as_deref()
1169 == Some("clear")
1170 {
1171 self.scrollback.clear();
1172 self.dropped_lines = 0;
1173 self.scroll.set(0);
1174 return Plan::Done;
1175 }
1176 if line
1177 .split_whitespace()
1178 .next()
1179 .is_some_and(|w| w.eq_ignore_ascii_case("save"))
1180 {
1181 let words = shlex::split(line);
1182 self.save(line, words.as_ref().map(|w| &w[1..]));
1183 return Plan::Done;
1184 }
1185 let (out, captured) = Output::capture_merged();
1186 let started = Instant::now();
1187 let started_at = Utc::now().to_rfc3339();
1188 let plan = match exec::plan(line, &mut self.ctx, config, &out) {
1189 Ok(plan) => plan,
1190 Err(e) => {
1192 self.push_note(&format!("Output error: {}", e));
1193 Plan::Done
1194 }
1195 };
1196 self.push_ansi(&captured.out());
1197 if !line.trim().is_empty() && !matches!(plan, Plan::Run(_)) {
1198 self.log(Event {
1199 started: started_at,
1200 duration_ms: started.elapsed().as_millis(),
1201 input: line,
1202 argv: &[],
1203 route: "session",
1204 outcome: "session".into(),
1205 error: None,
1206 });
1207 }
1208 plan
1209 }
1210
1211 fn log_action(
1214 &mut self,
1215 input: &str,
1216 started: Instant,
1217 outcome: &'static str,
1218 error: Option<String>,
1219 ) {
1220 self.log(Event {
1221 started: Utc::now().to_rfc3339(),
1222 duration_ms: started.elapsed().as_millis(),
1223 input,
1224 argv: &[],
1225 route: "session",
1226 outcome: outcome.into(),
1227 error,
1228 });
1229 }
1230
1231 fn save(&mut self, line: &str, words: Option<&[String]>) {
1234 let started = Instant::now();
1235 let result = match words {
1236 None => Err(SaveError::Refused(format!(
1237 "Unbalanced quotes. {}",
1238 clip::SAVE_USAGE
1239 ))),
1240 Some(args) => {
1241 let args: Vec<&str> = args.iter().map(String::as_str).collect();
1242 let home = scope::paths::home_dir();
1243 match std::env::current_dir() {
1244 Ok(cwd) => {
1245 let csv = args
1247 .iter()
1248 .any(|a| a.to_ascii_lowercase().ends_with(".csv"))
1249 .then(|| self.last_series.as_ref().and_then(repeat_view::series_csv))
1250 .flatten();
1251 let text = csv.as_deref().or(self.last_output.as_deref());
1252 clip::save(&args, text, &cwd, home.as_deref())
1253 }
1254 Err(e) => Err(SaveError::failed(e, "Could not read the current directory")),
1255 }
1256 }
1257 };
1258 match result {
1259 Ok(note) => {
1260 self.push_note(¬e);
1261 self.log_action(line, started, "session", None);
1262 }
1263 Err(e) => {
1264 self.push_note(&e.message());
1265 let outcome = match e {
1266 SaveError::Refused(_) => "refused",
1267 SaveError::Failed(_) => "error",
1268 };
1269 self.log_action(line, started, outcome, Some(e.message()));
1270 }
1271 }
1272 }
1273
1274 fn copy_key(&mut self) -> Action {
1277 let started = Instant::now();
1278 let refusal = match self.last_output.clone() {
1279 None => "nothing to copy yet".to_string(),
1280 Some(text) if clip::fits_osc52(&text) => return Action::Copy(text),
1281 Some(text) => format!(
1282 "too large to copy ({} lines); use save <file>",
1283 text.lines().count()
1284 ),
1285 };
1286 self.push_note(&refusal);
1287 self.log_action("Ctrl-Y", started, "refused", Some(refusal));
1288 Action::None
1289 }
1290
1291 pub fn copied(&mut self, lines: usize, result: std::io::Result<()>) {
1293 let started = Instant::now();
1294 match result {
1295 Ok(()) => {
1296 self.push_note(&format!("copied {} lines", lines));
1297 self.log_action("Ctrl-Y", started, "session", None);
1298 }
1299 Err(e) => {
1300 let note = format!("Could not copy: {}", e);
1301 self.push_note(¬e);
1302 self.log_action("Ctrl-Y", started, "error", Some(note));
1303 }
1304 }
1305 }
1306
1307 pub fn start(&mut self, input: &str, inv: &Invocation) -> Output {
1309 let (out, captured) = Output::capture_merged();
1310 let diag = scope::diag::redirect(captured.out_sink());
1311 self.failed = false;
1312 self.last_series = None;
1313 self.running = Some(Running {
1314 input: input.to_string(),
1315 argv: inv.argv.clone(),
1316 started: Instant::now(),
1317 started_at: Utc::now().to_rfc3339(),
1318 captured,
1319 series: out.series_hook(),
1320 out: out.clone(),
1321 _diag: diag,
1322 });
1323 out
1324 }
1325
1326 pub fn finish(&mut self, ending: Ending) {
1329 self.finish_with(ending);
1330 }
1331
1332 fn finish_with(&mut self, ending: Ending) {
1335 let Some(run) = self.running.take() else {
1336 return;
1337 };
1338 let (outcome, error) = ending.outcome();
1339 if let Ending::Finished(Err(e)) = &ending {
1340 if let Err(write_err) = crate::cli::errors::display_error(e, &run.out) {
1342 self.push_note(&format!("Output error: {}", write_err));
1343 }
1344 }
1345 let secs = run.started.elapsed().as_secs_f64();
1346 let (footer, style) = match &ending {
1347 Ending::Cancelled => (format!("Cancelled after {:.1}s.", secs), WARN),
1348 Ending::Finished(Err(_)) => {
1349 self.failed = true;
1350 (
1351 format!("✗ Failed in {:.1}s.", secs),
1352 ERR.add_modifier(Modifier::BOLD),
1353 )
1354 }
1355 Ending::Finished(Ok(())) => (format!("✓ Done in {:.1}s.", secs), OK),
1356 };
1357 match run.series.filter(repeat_view::has_runs) {
1358 Some(series) => {
1361 self.last_output =
1362 repeat_view::final_snapshot(&series).map(|t| crate::cli::ansi::strip(&t));
1363 self.last_series = Some(series.clone());
1364 self.repeat_view = Some(RepeatView {
1365 series,
1366 ended: Some(Ended {
1367 text: footer,
1368 style,
1369 }),
1370 pending: run.captured.out(),
1371 });
1372 }
1373 None => {
1374 self.keep_copy(&run.captured, &ending);
1375 self.push_ansi(&run.captured.out());
1376 self.push_styled_note(&footer, style);
1377 }
1378 }
1379 self.log(Event {
1380 started: run.started_at,
1381 duration_ms: run.started.elapsed().as_millis(),
1382 input: &run.input,
1383 argv: &run.argv,
1384 route: "pane",
1385 outcome: outcome.into(),
1386 error,
1387 });
1388 }
1389
1390 fn leave_repeat_view(&mut self, view: RepeatView) {
1394 if let Some(snapshot) = repeat_view::final_snapshot(&view.series) {
1395 self.push_ansi(&snapshot);
1396 }
1397 self.push_ansi(&view.pending);
1398 self.push_note(&repeat_view::summary(&view.series));
1399 if let Some(ended) = view.ended {
1400 self.push_styled_note(&ended.text, ended.style);
1401 }
1402 }
1403
1404 fn active_view(&self) -> Option<RepeatView> {
1407 if let Some(run) = &self.running {
1408 let series = run.series.as_ref().filter(|s| repeat_view::has_runs(s))?;
1409 return Some(RepeatView {
1410 series: series.clone(),
1411 ended: None,
1412 pending: String::new(),
1413 });
1414 }
1415 self.repeat_view.clone()
1416 }
1417
1418 fn keep_copy(&mut self, captured: &Captured, ending: &Ending) {
1423 if matches!(ending, Ending::Finished(Ok(()))) {
1424 let data = crate::cli::ansi::strip(&captured.data());
1425 self.last_output = (!captured.trimmed() && !data.trim().is_empty()).then_some(data);
1426 }
1427 }
1428
1429 pub fn show_outside(&mut self, copy: &Captured, ending: &Ending) {
1434 if copy.trimmed() {
1435 self.push_note(
1436 "Its output was long: only the end is kept here, and Ctrl-Y and save do not have it.",
1437 );
1438 }
1439 self.keep_copy(copy, ending);
1440 self.push_ansi(©.out());
1441 }
1442
1443 pub fn record_suspended(
1446 &mut self,
1447 input: &str,
1448 inv_argv: &[String],
1449 duration: std::time::Duration,
1450 started_at: String,
1451 ending: &Ending,
1452 ) {
1453 let (outcome, error) = ending.outcome();
1454 self.failed = matches!(ending, Ending::Finished(Err(_)));
1457 let word = if outcome == "error" {
1459 "failed"
1460 } else {
1461 outcome
1462 };
1463 self.push_note(&format!(
1464 "Ran outside the TUI: {} ({}, {:.1}s).",
1465 input,
1466 word,
1467 duration.as_secs_f64()
1468 ));
1469 self.log(Event {
1470 started: started_at,
1471 duration_ms: duration.as_millis(),
1472 input,
1473 argv: inv_argv,
1474 route: "suspend",
1475 outcome: outcome.into(),
1476 error,
1477 });
1478 }
1479
1480 pub fn record_signal(&mut self, name: &str, route: &'static str, input: &str, argv: &[String]) {
1484 self.log(Event {
1485 started: Utc::now().to_rfc3339(),
1486 duration_ms: 0,
1487 input,
1488 argv,
1489 route,
1490 outcome: format!("signal:{}", name).into(),
1491 error: None,
1492 });
1493 }
1494
1495 fn log(&mut self, event: Event<'_>) {
1496 if let Err(e) = self.journal.record(&event) {
1497 self.push_note(&format!("Could not write the TUI event log: {}", e));
1498 }
1499 }
1500
1501 pub fn pane_width(&self) -> Option<usize> {
1506 match self.pane_width.get() {
1507 0 => None,
1508 w => Some(w as usize),
1509 }
1510 }
1511
1512 pub fn render(&self, f: &mut Frame) {
1514 let area = f.area();
1515 let too_small = area.width < MIN_W || area.height < MIN_H;
1516 self.too_small.set(too_small);
1517 if too_small {
1518 self.pane_width.set(0);
1522 self.last_pane_height.set(0);
1523 draw_note_alone(f, &too_small_note());
1524 return;
1525 }
1526 let [pane, input, hint_row, status] = Layout::vertical([
1527 Constraint::Min(3),
1528 Constraint::Length(3),
1529 Constraint::Length(1),
1530 Constraint::Length(1),
1531 ])
1532 .areas(f.area());
1533
1534 let live_out = self.live_output();
1536 let liveness = match &self.running {
1539 Some(run) => self.liveness_lines(run, color_enabled()),
1540 None => Vec::new(),
1541 };
1542 let state = self.pane_state();
1544 let block = Block::new()
1545 .borders(Borders::TOP | Borders::BOTTOM)
1546 .border_style(state.style())
1547 .title(Line::styled(
1548 format!(" scope v{} ", scope::VERSION),
1549 state.style().add_modifier(Modifier::BOLD),
1550 ))
1551 .title(Line::styled(format!(" {} ", state.word()), state.style()).right_aligned());
1552 let inner = block.inner(pane);
1553 self.pane_width.set(inner.width);
1554 let view = self.search_view(&live_out);
1556 if let Some(repeating) = self.active_view() {
1557 let status = if self.running.is_some() {
1559 liveness
1560 } else {
1561 Vec::new()
1562 };
1563 f.render_widget(block, pane);
1564 repeat_view::draw(f, inner, &repeating, status);
1565 self.last_max_top.set(0);
1566 self.last_pane_height.set(inner.height as usize);
1567 } else {
1568 let width = inner.width as usize;
1572 let in_pane_search = matches!(self.overlay, Some(Overlay::Search(_)));
1575 let (rows, starts) = match (&view, in_pane_search) {
1576 (Some(v), true) => {
1577 let entries: Vec<_> =
1578 self.scrollback.iter().chain(&live_out).cloned().collect();
1579 marked_rows(&entries, liveness.clone(), &v.matches, v.current, width)
1580 }
1581 _ => {
1582 let mut lines = Vec::new();
1583 if self.dropped_lines > 0 {
1584 lines.push(Line::styled(
1586 format!(
1587 "… {} older lines dropped (the pane keeps the last {SCROLLBACK_LIMIT})",
1588 self.dropped_lines
1589 ),
1590 SECONDARY,
1591 ));
1592 }
1593 lines.extend(self.scrollback.iter().cloned());
1594 lines.extend(live_out.iter().cloned());
1595 lines.extend(liveness.iter().cloned());
1596 (fit::fit(lines, width), Vec::new())
1597 }
1598 };
1599 let para = Paragraph::new(Text::from(rows));
1600 let total = para.line_count(inner.width);
1601 let max_top = total.saturating_sub(inner.height as usize);
1602 self.last_max_top.set(max_top);
1603 self.last_pane_height.set(inner.height as usize);
1604 if let (Some(v), true) = (&view, in_pane_search) {
1605 let before = self.search_width.replace(width);
1606 let height = inner.height as usize;
1607 if self.reveal.replace(false) {
1608 self.reveal_match(&starts, v.current, height, max_top);
1609 } else if before == 0 && self.scroll.get() > 0 {
1610 let lines: Vec<_> = self.scrollback.iter().chain(&live_out).cloned().collect();
1613 let old = entry_starts(&lines, width);
1614 let total =
1615 old.last().copied().unwrap_or(0) + fit::fit(liveness.clone(), width).len();
1616 let old_max = total.saturating_sub(height);
1617 let old_top = old_max - self.scroll.get().min(old_max);
1618 let entry = old.partition_point(|&s| s <= old_top).saturating_sub(1);
1619 let top = starts.get(entry).copied().unwrap_or(max_top).min(max_top);
1620 self.scroll.set(max_top - top);
1621 } else if before != width {
1622 self.reveal_match(&starts, v.current, height, max_top);
1623 }
1624 } else {
1625 self.search_width.set(0);
1626 }
1627 let top = max_top.saturating_sub(self.scroll.get().min(max_top));
1628 f.render_widget(para.block(block).scroll((top as u16, 0)), pane);
1629 }
1630
1631 let block = Block::bordered().title(self.prompt());
1633 let inner_width = input.width.saturating_sub(2).max(1) as usize;
1635 let col = self.cursor_column();
1636 let scroll_x = self.input_scroll(inner_width);
1637 f.render_widget(
1638 Paragraph::new(self.input.as_str())
1639 .block(block)
1640 .scroll((0, scroll_x.min(u16::MAX as usize) as u16)),
1641 input,
1642 );
1643 let cursor_x = input.x + 1 + (col - scroll_x).min(u16::MAX as usize) as u16;
1644 f.set_cursor_position(Position::new(
1645 cursor_x.min(input.right().saturating_sub(2)),
1646 input.y + 1,
1647 ));
1648
1649 if let Some(Overlay::Menu(m)) = &self.overlay {
1650 let rows = m.candidates.len().min(MENU_ROWS);
1651 let first = m.selected.saturating_sub(rows - 1);
1652 let height = (rows as u16 + 2).min(input.y);
1653 let width = m
1654 .candidates
1655 .iter()
1656 .map(|c| UnicodeWidthStr::width(c.as_str()))
1657 .max()
1658 .unwrap_or(0) as u16
1659 + 4;
1660 let area = ratatui::layout::Rect {
1661 x: input.x,
1662 y: input.y.saturating_sub(height),
1663 width: width.min(input.width),
1664 height,
1665 };
1666 let lines: Vec<Line> = m.candidates[first..first + rows]
1667 .iter()
1668 .enumerate()
1669 .map(|(i, c)| {
1670 let style = if first + i == m.selected {
1671 SELECTED
1672 } else {
1673 Style::new()
1674 };
1675 Line::styled(c.clone(), style)
1676 })
1677 .collect();
1678 f.render_widget(ratatui::widgets::Clear, area);
1679 f.render_widget(Paragraph::new(lines).block(Block::bordered()), area);
1680 }
1681
1682 if self.busy_hint {
1683 f.render_widget(
1684 Paragraph::new(Line::styled(format!(" {}", BUSY_NOTE), WARN)),
1685 hint_row,
1686 );
1687 } else if let Some(h) = super::hint::hint(&self.input) {
1688 let mut spans = vec![Span::styled(format!(" {}", h.text), SECONDARY)];
1689 if let Some(next) = h.next {
1690 spans.push(Span::styled(" next: ", SECONDARY));
1691 spans.push(Span::styled(next, KEY));
1692 }
1693 f.render_widget(Paragraph::new(Line::from(spans)), hint_row);
1694 }
1695 if let Some(Overlay::Palette(p)) = &self.overlay {
1696 let area = f.area();
1697 let width = area.width.saturating_sub(4).min(80);
1698 let height = area.height.saturating_sub(4).min(16);
1699 let rect = ratatui::layout::Rect {
1700 x: area.x + (area.width.saturating_sub(width)) / 2,
1701 y: area.y + 1,
1702 width,
1703 height,
1704 };
1705 let rows = height.saturating_sub(4) as usize;
1707 let matches = p.matches();
1708 let first = p.selected.saturating_sub(rows.saturating_sub(1));
1709 let mut lines = vec![Line::from(format!("> {}", p.query))];
1710 for (n, &i) in matches.iter().enumerate().skip(first).take(rows) {
1711 let it = &p.items[i];
1712 let (marker, style) = if n == p.selected {
1714 (PALETTE_MARKER, SELECTED)
1715 } else {
1716 (" ", ACCENT)
1717 };
1718 lines.push(Line::from(vec![
1719 Span::styled(marker, KEY),
1720 Span::styled(format!("{:<28}", it.label), style),
1721 Span::styled(format!(" {}", it.description), SECONDARY),
1722 ]));
1723 }
1724 lines.push(Line::styled(PALETTE_LEGEND, SECONDARY));
1725 f.render_widget(ratatui::widgets::Clear, rect);
1726 f.render_widget(
1727 Paragraph::new(lines).block(Block::bordered().title(" Commands (Ctrl-K) ")),
1728 rect,
1729 );
1730 }
1731 f.render_widget(
1732 Paragraph::new(fit_status(
1733 &self.status_parts_with(view.map(|v| v.text)),
1734 status.width as usize,
1735 )),
1736 status,
1737 );
1738 }
1739
1740 fn reveal_match(
1744 &self,
1745 starts: &[usize],
1746 current: Option<usize>,
1747 height: usize,
1748 max_top: usize,
1749 ) {
1750 let Some(at) = current.filter(|&c| c + 1 < starts.len()) else {
1751 return;
1752 };
1753 let (first, rows) = (starts[at], starts[at + 1] - starts[at]);
1754 let top = first
1755 .saturating_sub(height.saturating_sub(rows) / 2)
1756 .min(max_top);
1757 self.scroll.set(max_top - top);
1758 }
1759
1760 fn liveness_lines(&self, run: &Running, color: bool) -> Vec<Line<'static>> {
1762 let dim = SECONDARY;
1763 let elapsed = run.started.elapsed();
1764 let mut lines = vec![Line::from(vec![
1765 Span::styled(spinner_glyph(elapsed, color), WARN),
1766 Span::styled(
1767 format!(" running {}… {}s", run.input, elapsed.as_secs()),
1768 dim,
1769 ),
1770 ])];
1771 if let Some(hook) = run.out.progress_hook() {
1772 let p = hook.lock().unwrap_or_else(|e| e.into_inner()).clone();
1774 if p.run > 0 {
1775 let mut text = match p.total {
1776 Some(total) => format!("run {} of {}", p.run, total),
1777 None => format!("run {}", p.run),
1778 };
1779 if let Some(at) = p.next_at {
1780 let wait = at.saturating_duration_since(Instant::now());
1781 text.push_str(&format!(" · next in {}s", wait.as_secs_f64().ceil() as u64));
1782 }
1783 lines.push(Line::styled(text, dim));
1784 }
1785 }
1786 lines
1787 }
1788
1789 fn esc_action(&self) -> EscAction {
1793 match (&self.overlay, &self.running) {
1794 (Some(Overlay::Palette(_)), _) => EscAction::ClosePalette,
1795 (Some(Overlay::Menu(_)), _) => EscAction::CloseMenu,
1796 (Some(Overlay::Search(_)), _) => EscAction::CloseSearch,
1797 (Some(Overlay::HistorySearch(_)), _) => EscAction::CloseHistorySearch,
1798 (None, Some(_)) => EscAction::Cancel,
1799 (None, None) => EscAction::ClearLine,
1800 }
1801 }
1802
1803 fn press_esc(&mut self) -> Action {
1807 match self.esc_action() {
1808 EscAction::ClosePalette
1809 | EscAction::CloseMenu
1810 | EscAction::CloseSearch
1811 | EscAction::CloseHistorySearch => self.close_overlay(),
1812 EscAction::Cancel => return Action::Cancel,
1813 EscAction::ClearLine => {
1814 let line = std::mem::take(&mut self.input);
1815 self.keep_cleared(line);
1816 self.cursor = 0;
1817 }
1818 }
1819 Action::None
1820 }
1821
1822 #[cfg(test)]
1825 fn status_parts(&self) -> StatusParts {
1826 let search = self.search_view(&self.live_output()).map(|v| v.text);
1827 self.status_parts_with(search)
1828 }
1829
1830 fn status_parts_with(&self, search: Option<String>) -> StatusParts {
1832 let ctx = &self.ctx;
1833 let mut limit = labeled("limit", ctx.limit.to_string());
1834 for (on, flag) in [(ctx.include_tokens, "+tokens"), (ctx.include_txs, "+txs")] {
1835 if on {
1836 limit.extend([sep(), Span::styled(flag, ACCENT)]);
1837 }
1838 }
1839 let context = vec![
1841 labeled("chain", ctx.chain.clone()),
1842 labeled("format", format!("{:?}", ctx.format)),
1843 limit,
1844 ];
1845 let scrolled = self.scroll.get().min(self.last_max_top.get());
1847 let scrolled = (scrolled > 0).then(|| {
1848 let mut part = vec![Span::styled(
1849 format!("scrolled up {} lines", scrolled),
1850 ACCENT,
1851 )];
1852 if search.is_none() {
1855 part.extend([sep(), Span::styled("PgDn", ACCENT)]);
1856 }
1857 part
1858 });
1859 let esc = self
1860 .esc_action()
1861 .hint()
1862 .map(|hint| Span::styled(hint, Style::new().add_modifier(Modifier::BOLD)));
1863 let (state, scrolled, command) = match (&self.running, esc) {
1864 (Some(run), esc) => {
1865 let mut state = vec![Span::styled(
1866 format!("running {:.0}s", run.started.elapsed().as_secs_f64()),
1867 WARN,
1868 )];
1869 state.extend(esc.into_iter().flat_map(|esc| [sep(), esc]));
1870 (state, scrolled, Some(run.input.clone()))
1871 }
1872 (None, Some(esc)) => (vec![esc], scrolled, None),
1874 (None, None) => (
1876 scrolled.unwrap_or_else(|| {
1877 vec![
1878 Span::styled("help", ACCENT),
1879 sep(),
1880 Span::styled("exit", ACCENT),
1881 ]
1882 }),
1883 None,
1884 None,
1885 ),
1886 };
1887 StatusParts {
1888 context,
1889 state,
1890 scrolled,
1891 search: search.map(|text| vec![Span::styled(text, ACCENT)]),
1892 command,
1893 }
1894 }
1895}
1896
1897type Spans = Vec<Span<'static>>;
1899
1900struct StatusParts {
1903 context: Vec<Spans>,
1906 state: Spans,
1909 scrolled: Option<Spans>,
1912 search: Option<Spans>,
1915 command: Option<String>,
1918}
1919
1920const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1922
1923const SPINNER_ASCII: [&str; 4] = ["-", "\\", "|", "/"];
1925
1926fn spinner_glyph(elapsed: std::time::Duration, color: bool) -> &'static str {
1928 let step = (elapsed.as_millis() / super::LIVE_REDRAW.as_millis()) as usize;
1929 if color {
1930 SPINNER_FRAMES[step % SPINNER_FRAMES.len()]
1931 } else {
1932 SPINNER_ASCII[step % SPINNER_ASCII.len()]
1933 }
1934}
1935
1936const COMMAND_FRAME_COLS: usize = 5;
1939
1940const MIN_CUT_COMMAND_COLS: usize = 2;
1942
1943const STATUS_SEP: &str = " · ";
1945
1946fn sep() -> Span<'static> {
1948 Span::styled(STATUS_SEP, SECONDARY)
1949}
1950
1951fn labeled(label: &str, value: String) -> Spans {
1953 vec![
1954 Span::styled(format!("{label} "), SECONDARY),
1955 Span::styled(value, ACCENT),
1956 ]
1957}
1958
1959fn spans_cols(spans: &[Span<'static>]) -> usize {
1961 spans.iter().map(|sp| cols(&sp.content)).sum()
1962}
1963
1964fn joined<'a>(parts: impl IntoIterator<Item = &'a Spans>) -> Spans {
1967 let mut out = Spans::new();
1968 for (i, part) in parts.into_iter().enumerate() {
1969 if i > 0 {
1970 out.push(sep());
1971 }
1972 out.extend(part.iter().cloned());
1973 }
1974 out
1975}
1976
1977fn cut_spans(spans: Spans, max: usize) -> Spans {
1979 let full: String = spans.iter().map(|sp| sp.content.as_ref()).collect();
1980 let cut = cut_with_ellipsis(&full, max);
1981 if cut == full {
1982 return spans;
1983 }
1984 let ellipsis = cut.ends_with(ELLIPSIS);
1985 let mut keep = cut.len() - if ellipsis { ELLIPSIS.len() } else { 0 };
1987 let mut out = Spans::new();
1988 for sp in spans {
1989 if keep == 0 {
1990 break;
1991 }
1992 let n = keep.min(sp.content.len());
1993 out.push(Span::styled(sp.content[..n].to_string(), sp.style));
1994 keep -= n;
1995 }
1996 if ellipsis {
1997 out.push(Span::styled(ELLIPSIS, SECONDARY));
1998 }
1999 out
2000}
2001
2002fn fit_status(parts: &StatusParts, width: usize) -> Line<'static> {
2006 if width == 0 {
2007 return Line::default();
2008 }
2009 let lead = || Span::raw(" ");
2010 let more = || Span::styled(format!(" {ELLIPSIS}"), SECONDARY);
2011 let command = |c: &str| vec![sep(), Span::styled(format!("`{c}`"), ACCENT)];
2012 let note = |part: &Option<Spans>| {
2013 part.as_ref()
2014 .map(|p| joined([&Spans::new(), p]))
2015 .unwrap_or_default()
2016 };
2017 let scroll_note = note(&parts.scrolled);
2018 let search_note = note(&parts.search);
2019 let fits = |line: &Spans| spans_cols(line) <= width;
2020 let total = parts.context.len();
2021 for kept in (0..=total).rev() {
2022 let mut line = vec![lead()];
2023 if kept > 0 {
2024 line.extend(joined(&parts.context[..kept]));
2025 line.push(Span::raw(" "));
2026 }
2027 line.extend(parts.state.iter().cloned());
2028 line.extend(scroll_note.iter().cloned());
2029 line.extend(search_note.iter().cloned());
2030 if let Some(c) = &parts.command {
2031 line.extend(command(c));
2032 }
2033 if kept < total {
2034 line.push(more());
2035 }
2036 if fits(&line) {
2037 return Line::from(line);
2038 }
2039 }
2040 let both = [scroll_note.clone(), search_note.clone()].concat();
2043 for note in [both, search_note.clone()] {
2044 let used = 1 + spans_cols(&parts.state) + spans_cols(¬e) + COMMAND_FRAME_COLS;
2045 if let Some(c) = &parts.command
2046 && width >= used + MIN_CUT_COMMAND_COLS
2047 {
2048 let cut = cut_with_ellipsis(c, width - used);
2049 let line = [vec![lead()], parts.state.clone(), note, command(&cut)].concat();
2050 return Line::from(line);
2051 }
2052 }
2053 let head = [vec![lead()], parts.state.clone()].concat();
2055 if parts.search.is_some() {
2056 let line = [head.clone(), search_note, vec![more()]].concat();
2057 if fits(&line) {
2058 return Line::from(line);
2059 }
2060 }
2061 let line = [head.clone(), vec![more()]].concat();
2062 if fits(&line) {
2063 return Line::from(line);
2064 }
2065 Line::from(cut_spans(head, width))
2066}
2067
2068fn entry_starts(lines: &[Line<'static>], width: usize) -> Vec<usize> {
2071 let mut rows = 0;
2072 let mut starts = Vec::with_capacity(lines.len() + 1);
2073 for line in lines {
2074 starts.push(rows);
2075 rows += fit::fit(vec![line.clone()], width).len();
2076 }
2077 starts.push(rows);
2078 starts
2079}
2080
2081fn marked_rows(
2087 scrollback: &[Line<'static>],
2088 extra: Vec<Line<'static>>,
2089 matches: &[usize],
2090 current: Option<usize>,
2091 width: usize,
2092) -> (Vec<Line<'static>>, Vec<usize>) {
2093 let text_width = width.saturating_sub(cols(MATCH_MARK)).max(1);
2094 let gutter = |mut row: Line<'static>, hit: bool, now: bool| {
2095 let mark = if hit { MATCH_MARK } else { MATCH_BLANK };
2096 row.spans.insert(0, Span::raw(mark));
2097 if hit {
2098 let mut style = MATCH;
2099 if now {
2100 style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
2101 }
2102 row = row.patch_style(style);
2103 }
2104 row
2105 };
2106 let mut rows = Vec::new();
2107 let mut starts = Vec::with_capacity(scrollback.len() + 1);
2108 for (i, line) in scrollback.iter().enumerate() {
2109 starts.push(rows.len());
2110 let hit = matches.binary_search(&i).is_ok();
2111 for row in fit::fit(vec![line.clone()], text_width) {
2112 rows.push(gutter(row, hit, current == Some(i)));
2113 }
2114 }
2115 starts.push(rows.len());
2116 for row in fit::fit(extra, text_width) {
2117 rows.push(gutter(row, false, false));
2118 }
2119 (rows, starts)
2120}
2121
2122pub async fn run_invocation(
2125 inv: Invocation,
2126 config: &Config,
2127 clients: &dyn scope::chains::ChainClientFactory,
2128 out: &Output,
2129) -> scope::error::Result<()> {
2130 debug_assert!(!matches!(inv.route, Route::Refuse(_)));
2131 if inv.ai {
2132 let mut config = config.clone();
2133 config.output.format = scope::config::OutputFormat::Markdown;
2134 crate::cli::dispatch::dispatch_with(inv.command, &config, clients, out).await
2135 } else {
2136 crate::cli::dispatch::dispatch_with(inv.command, config, clients, out).await
2137 }
2138}
2139
2140#[cfg(test)]
2145mod tests {
2146 use super::*;
2147 use ratatui::Terminal;
2148 use ratatui::backend::TestBackend;
2149 use ratatui::style::Color;
2150
2151 fn key(code: KeyCode) -> KeyEvent {
2152 KeyEvent::new(code, KeyModifiers::NONE)
2153 }
2154
2155 fn ctrl(c: char) -> KeyEvent {
2156 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
2157 }
2158
2159 fn venues_list() -> crate::cli::Commands {
2160 use clap::Parser;
2161 crate::cli::Cli::try_parse_from(["scope", "venues", "list"])
2162 .unwrap()
2163 .command
2164 }
2165
2166 fn state() -> TuiState {
2167 TuiState::new(
2168 SessionContext::default(),
2169 Journal::default(),
2170 Vocab::default(),
2171 )
2172 }
2173
2174 fn type_str(s: &mut TuiState, text: &str) {
2175 for c in text.chars() {
2176 s.handle_key(key(KeyCode::Char(c)));
2177 }
2178 }
2179
2180 fn screen(s: &TuiState, w: u16, h: u16) -> String {
2181 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
2182 term.draw(|f| s.render(f)).unwrap();
2183 let buf = term.backend().buffer().clone();
2184 (0..h)
2185 .map(|y| {
2186 (0..w)
2187 .map(|x| buf[(x, y)].symbol().to_string())
2188 .collect::<String>()
2189 })
2190 .collect::<Vec<_>>()
2191 .join("\n")
2192 }
2193
2194 fn cursor_pos(s: &TuiState, w: u16, h: u16) -> Position {
2195 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
2196 term.draw(|f| s.render(f)).unwrap();
2197 term.get_cursor_position().unwrap()
2198 }
2199
2200 fn vocab_state() -> TuiState {
2201 TuiState::new(
2202 SessionContext::default(),
2203 Journal::default(),
2204 Vocab {
2205 venues: vec!["binance".into(), "bitget".into(), "okx".into()],
2206 ..Vocab::default()
2207 },
2208 )
2209 }
2210
2211 #[test]
2212 fn test_tab_single_candidate_completes_and_adds_space() {
2213 let mut s = vocab_state();
2214 type_str(&mut s, "market su");
2215 s.handle_key(key(KeyCode::Tab));
2216 assert_eq!(s.input, "market summary ");
2217 assert!(s.overlay.is_none());
2218 }
2219
2220 #[test]
2221 fn test_tab_many_candidates_fills_prefix_and_opens_menu() {
2222 let mut s = vocab_state();
2223 type_str(&mut s, "market summary USDC --venue b");
2224 s.handle_key(key(KeyCode::Tab));
2225 assert_eq!(s.input, "market summary USDC --venue bi");
2226 let Some(Overlay::Menu(m)) = &s.overlay else {
2227 panic!("menu expected")
2228 };
2229 assert_eq!(m.candidates, vec!["binance", "bitget"]);
2230 s.handle_key(key(KeyCode::Tab));
2231 s.handle_key(key(KeyCode::Enter));
2232 assert_eq!(s.input, "market summary USDC --venue bitget ");
2233 assert!(
2234 s.overlay.is_none(),
2235 "Enter in the menu accepts; it does not submit"
2236 );
2237 }
2238
2239 #[test]
2240 fn test_tab_mid_line_does_nothing() {
2241 let mut s = vocab_state();
2243 type_str(&mut s, "market su");
2244 s.handle_key(key(KeyCode::Left));
2245 s.handle_key(key(KeyCode::Tab));
2246 assert_eq!(s.input, "market su");
2247 assert!(s.overlay.is_none());
2248 }
2249
2250 #[test]
2251 fn test_menu_escape_and_other_keys_close_it() {
2252 let mut s = vocab_state();
2253 type_str(&mut s, "ad");
2254 s.handle_key(key(KeyCode::Tab));
2255 assert!(matches!(s.overlay, Some(Overlay::Menu(_))));
2256 s.handle_key(key(KeyCode::Esc));
2257 assert!(s.overlay.is_none());
2258 assert_eq!(s.input, "address", "Esc closes the menu, not the line");
2259 s.handle_key(key(KeyCode::Tab));
2260 type_str(&mut s, "-");
2261 assert!(s.overlay.is_none());
2262 assert_eq!(s.input, "address-");
2263 }
2264
2265 #[test]
2266 fn test_tab_works_while_a_command_runs() {
2267 let _lock = super::super::DIAG_LOCK.blocking_lock();
2269 let mut s = vocab_state();
2270 let inv = Invocation {
2271 command: venues_list(),
2272 route: Route::Pane,
2273 ai: false,
2274 argv: vec![],
2275 };
2276 s.start("venues list", &inv);
2277 type_str(&mut s, "market su");
2278 s.handle_key(key(KeyCode::Tab));
2279 assert_eq!(s.input, "market summary ");
2280 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
2281 s.finish(Ending::Cancelled);
2282 }
2283
2284 #[test]
2285 fn test_menu_renders_bounded_and_tiny_terminal_is_safe() {
2286 let mut s = TuiState::new(
2288 SessionContext::default(),
2289 Journal::default(),
2290 Vocab {
2291 venues: (0..40).map(|i| format!("v{:02}", i)).collect(),
2292 ..Vocab::default()
2293 },
2294 );
2295 type_str(&mut s, "market summary X --venue v");
2296 s.handle_key(key(KeyCode::Tab));
2297 for _ in 0..20 {
2298 s.handle_key(key(KeyCode::Tab));
2299 }
2300 let text = screen(&s, 60, 20);
2301 assert!(
2302 text.contains("v20"),
2303 "the selection stays visible: {}",
2304 text
2305 );
2306 assert!(!text.contains("v00"), "at most 8 rows: {}", text);
2307 screen(&s, 10, 5);
2308 }
2309
2310 fn ctrl_k() -> KeyEvent {
2311 KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
2312 }
2313
2314 #[test]
2315 fn test_ctrl_k_opens_palette_and_enter_fills_input_without_running() {
2316 let mut s = state();
2317 s.handle_key(ctrl_k());
2318 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
2319 type_str(&mut s, "msum");
2320 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
2321 assert_eq!(s.input, "market summary ");
2322 assert!(s.overlay.is_none());
2323 }
2324
2325 fn repeating(s: &mut TuiState, n: u64, fail: u64) {
2328 let inv = Invocation {
2329 command: venues_list(),
2330 route: Route::Pane,
2331 ai: false,
2332 argv: vec![],
2333 };
2334 let out = s.start("market summary USDC --every 1s", &inv);
2335 let metrics =
2336 scope::market::series::summary_metrics(&scope::market::HealthThresholds::default());
2337 for run in 1..=n {
2338 out.record_run(
2339 &metrics,
2340 crate::cli::output::RunRecord {
2341 run,
2342 at: Utc::now(),
2343 sample: scope::market::series::Sample {
2344 values: vec![Some(1.0); metrics.len()],
2345 healthy: run != fail,
2346 failed_checks: vec![format!("spread {run}%")],
2347 },
2348 snapshot: format!("SNAPSHOT {run}\n"),
2349 },
2350 );
2351 }
2352 }
2353
2354 #[test]
2355 fn test_a_repeat_run_draws_the_view_not_one_report_per_run() {
2356 let _lock = super::super::DIAG_LOCK.blocking_lock();
2357 let mut s = state();
2358 repeating(&mut s, 3, 2);
2359 let text = screen(&s, 120, 36);
2360 assert!(text.contains("SNAPSHOT 3"), "{text}");
2361 assert!(!text.contains("SNAPSHOT 1"), "{text}");
2362 assert!(text.contains("Trends · run 3"), "{text}");
2363 assert!(text.contains("run 2: spread 2%"), "{text}");
2364 s.finish(Ending::Cancelled);
2365 }
2366
2367 #[test]
2368 fn test_the_view_stays_after_esc_and_the_next_key_leaves_it_once() {
2369 let _lock = super::super::DIAG_LOCK.blocking_lock();
2370 let mut s = state();
2371 repeating(&mut s, 3, 0);
2372 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
2373 s.finish(Ending::Cancelled);
2374 let text = screen(&s, 120, 36);
2375 assert!(text.contains("Cancelled after"), "{text}");
2376 assert!(text.contains(repeat_view::LEAVE_HINT), "{text}");
2377 assert!(!s.scrollback_text().contains("SNAPSHOT"), "not yet pushed");
2378 assert_eq!(s.handle_key(key(KeyCode::Char('x'))), Action::None);
2380 assert_eq!(s.input, "x");
2381 let back = s.scrollback_text();
2382 assert_eq!(back.matches("SNAPSHOT 3").count(), 1, "{back}");
2383 assert!(!back.contains("SNAPSHOT 1"), "{back}");
2384 assert!(back.contains("3 runs · none failed"), "{back}");
2385 assert!(back.contains("Cancelled after"), "{back}");
2386 assert!(!screen(&s, 120, 36).contains("Trends"), "the view is gone");
2387 }
2388
2389 #[test]
2390 fn test_copy_and_save_take_the_final_snapshot_and_the_series() {
2391 let _lock = super::super::DIAG_LOCK.blocking_lock();
2392 let mut s = state();
2393 repeating(&mut s, 2, 0);
2394 s.finish(Ending::Finished(Ok(())));
2395 assert_eq!(s.last_output.as_deref(), Some("SNAPSHOT 2\n"));
2396 let csv = s
2397 .last_series
2398 .as_ref()
2399 .and_then(repeat_view::series_csv)
2400 .unwrap();
2401 assert_eq!(csv.lines().count(), 3, "header and two runs: {csv}");
2402 }
2403
2404 fn alt(c: char) -> KeyEvent {
2405 KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)
2406 }
2407
2408 #[test]
2409 fn test_readline_word_moves_and_kill_to_end() {
2410 let mut s = state();
2411 type_str(&mut s, "market summary USDC");
2412 s.handle_key(alt('b'));
2413 assert_eq!(s.cursor, 15, "Alt-B: start of USDC");
2414 s.handle_key(alt('b'));
2415 assert_eq!(s.cursor, 7, "Alt-B: start of summary");
2416 s.handle_key(alt('f'));
2417 assert_eq!(s.cursor, 14, "Alt-F: end of summary");
2418 s.handle_key(ctrl('b'));
2419 assert_eq!(s.cursor, 13, "Ctrl-B: one left");
2420 s.handle_key(alt('k'));
2421 assert_eq!(s.input, "market summar", "Alt-K: deletes to the end");
2422 }
2423
2424 #[test]
2425 fn test_up_brings_back_a_line_that_esc_cleared() {
2426 let mut s = state();
2427 type_str(&mut s, "market summary USDC --venue binance --every 30s");
2428 s.handle_key(key(KeyCode::Esc));
2429 assert_eq!(s.input, "");
2430 s.handle_key(key(KeyCode::Up));
2431 assert_eq!(s.input, "market summary USDC --venue binance --every 30s");
2432 }
2433
2434 #[test]
2435 fn test_clear_empties_the_pane_and_reset_resets_the_context() {
2436 let mut s = state();
2437 s.push_note("old output");
2438 s.ctx.limit = 7;
2439 s.submit("clear", &Config::default());
2440 assert!(!s.scrollback_text().contains("old output"));
2441 assert_eq!(s.ctx.limit, 7, "clear leaves the context");
2442 s.submit("reset", &Config::default());
2443 assert_eq!(s.ctx.limit, SessionContext::default().limit);
2444 }
2445
2446 #[test]
2447 fn test_the_pane_says_when_it_dropped_old_lines() {
2448 let mut s = state();
2449 let big: String = (0..SCROLLBACK_LIMIT + 7)
2450 .map(|i| format!("l{i}\n"))
2451 .collect();
2452 s.push_ansi(&big);
2453 assert!(s.dropped_lines > 0);
2454 let rows = 200;
2455 s.scroll.set(usize::MAX / 2);
2456 let text = screen(&s, 100, rows);
2457 assert!(text.contains("older lines dropped"), "{text}");
2458 }
2459
2460 #[test]
2461 fn test_ctrl_c_in_palette_still_cancels_a_running_command() {
2462 let _lock = super::super::DIAG_LOCK.blocking_lock();
2464 let mut s = state();
2465 let inv = Invocation {
2466 command: venues_list(),
2467 route: Route::Pane,
2468 ai: false,
2469 argv: vec![],
2470 };
2471 s.start("venues list", &inv);
2472 s.handle_key(ctrl_k());
2473 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
2474 assert!(s.overlay.is_none(), "Ctrl-C also closes the palette");
2475 s.finish(Ending::Cancelled);
2476 }
2477
2478 #[test]
2479 fn test_palette_escape_keeps_the_input() {
2480 let mut s = state();
2481 type_str(&mut s, "draft");
2482 s.handle_key(ctrl_k());
2483 type_str(&mut s, "zzz");
2484 s.handle_key(key(KeyCode::Esc));
2485 assert!(s.overlay.is_none());
2486 assert_eq!(s.input, "draft");
2487 }
2488
2489 #[test]
2490 fn test_palette_with_long_history_renders_bounded() {
2491 let mut s = state();
2493 for i in 0..60 {
2494 type_str(&mut s, &format!("venues list {}", i));
2495 s.handle_key(key(KeyCode::Enter));
2496 }
2497 s.handle_key(ctrl_k());
2498 for _ in 0..30 {
2499 s.handle_key(key(KeyCode::Down));
2500 }
2501 screen(&s, 80, 20);
2502 screen(&s, 10, 5);
2503 }
2504
2505 #[test]
2506 fn test_editing_keys() {
2507 let mut s = state();
2508 type_str(&mut s, "adress");
2509 s.handle_key(key(KeyCode::Left));
2510 s.handle_key(key(KeyCode::Left));
2511 s.handle_key(key(KeyCode::Left));
2512 s.handle_key(key(KeyCode::Left));
2513 type_str(&mut s, "d");
2514 assert_eq!(s.input, "address");
2515 s.handle_key(key(KeyCode::End));
2516 type_str(&mut s, " 0xab cd");
2517 s.handle_key(ctrl('w'));
2518 assert_eq!(s.input, "address 0xab ");
2519 s.handle_key(key(KeyCode::Backspace));
2520 s.handle_key(ctrl('u'));
2521 assert_eq!(s.input, "");
2522 }
2523
2524 #[test]
2525 fn test_cursor_keys_and_idle_escape() {
2526 let mut s = state();
2527 type_str(&mut s, "bc");
2528 s.handle_key(key(KeyCode::Home));
2529 type_str(&mut s, "a");
2530 s.handle_key(ctrl('e'));
2531 type_str(&mut s, "d");
2532 assert_eq!(s.input, "abcd");
2533 s.handle_key(ctrl('a'));
2534 s.handle_key(key(KeyCode::Delete));
2535 assert_eq!(s.input, "bcd");
2536 s.handle_key(key(KeyCode::End));
2537 s.handle_key(key(KeyCode::Delete));
2538 assert_eq!(s.input, "bcd", "Delete at the end does nothing");
2539 s.handle_key(key(KeyCode::Right));
2540 type_str(&mut s, "e");
2541 assert_eq!(s.input, "bcde");
2542 s.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT));
2544 assert_eq!(s.input, "bcde");
2545 s.handle_key(key(KeyCode::Esc));
2546 assert_eq!(s.input, "", "Esc clears the line when idle");
2547 s.handle_key(ctrl('c'));
2549 assert!(s.scrollback_text().contains("Ctrl-D to leave"));
2550 s.handle_key(key(KeyCode::Up));
2552 assert_eq!(s.input, "bcde");
2553 s.cleared = None;
2555 s.set_input(String::new());
2556 s.handle_key(key(KeyCode::Up));
2557 s.handle_key(key(KeyCode::Down));
2558 assert_eq!(s.input, "");
2559 s.handle_key(key(KeyCode::PageDown));
2560 }
2561
2562 #[test]
2563 fn test_multibyte_input_does_not_panic() {
2564 let mut s = state();
2565 type_str(&mut s, "tokens add €UR");
2566 s.handle_key(key(KeyCode::Left));
2567 s.handle_key(key(KeyCode::Left));
2568 s.handle_key(key(KeyCode::Backspace));
2569 assert_eq!(s.input, "tokens add UR");
2570 }
2571
2572 #[test]
2577 fn test_line_feed_from_cooked_mode_submits_like_enter() {
2578 for lf in [ctrl('j'), key(KeyCode::Char('\n'))] {
2579 let mut s = state();
2580 type_str(&mut s, "ctx");
2581 assert_eq!(s.handle_key(lf), Action::Submit("ctx".into()), "{lf:?}");
2582 assert_eq!(s.input, "");
2583 }
2584 }
2585
2586 #[test]
2589 fn test_line_feed_picks_palette_entry_like_enter() {
2590 let mut s = state();
2591 s.handle_key(ctrl('k'));
2592 s.handle_key(ctrl('j'));
2593 assert!(s.overlay.is_none());
2594 assert!(!s.input.contains('\n'));
2595 }
2596
2597 #[test]
2598 fn test_enter_submits_and_records_history() {
2599 let mut s = state();
2600 type_str(&mut s, "venues list");
2601 assert_eq!(
2602 s.handle_key(key(KeyCode::Enter)),
2603 Action::Submit("venues list".into())
2604 );
2605 assert_eq!(s.input, "");
2606 type_str(&mut s, "venues list");
2607 s.handle_key(key(KeyCode::Enter));
2608 assert_eq!(s.history, vec!["venues list"]);
2610 }
2611
2612 #[test]
2613 fn test_history_browsing_restores_draft() {
2614 let mut s = state();
2615 for cmd in ["one", "two"] {
2616 type_str(&mut s, cmd);
2617 s.handle_key(key(KeyCode::Enter));
2618 }
2619 type_str(&mut s, "draft");
2620 s.handle_key(key(KeyCode::Up));
2621 assert_eq!(s.input, "two");
2622 s.handle_key(key(KeyCode::Up));
2623 s.handle_key(key(KeyCode::Up));
2624 assert_eq!(s.input, "one");
2625 s.handle_key(key(KeyCode::Down));
2626 assert_eq!(s.input, "two");
2627 s.handle_key(key(KeyCode::Down));
2628 assert_eq!(s.input, "draft");
2629 }
2630
2631 #[test]
2632 fn test_ctrl_z_suspends_and_keeps_the_draft_and_the_palette() {
2633 let mut s = state();
2636 type_str(&mut s, "draf");
2637 assert_eq!(s.handle_key(ctrl('z')), Action::Suspend);
2638 assert_eq!(s.input, "draf");
2639 s.handle_key(ctrl('k'));
2640 assert_eq!(s.handle_key(ctrl('z')), Action::Suspend);
2641 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
2642 }
2643
2644 #[test]
2645 fn test_ctrl_c_and_esc_cancel_only_when_running() {
2646 let _lock = super::super::DIAG_LOCK.blocking_lock();
2647 let mut s = state();
2648 type_str(&mut s, "abc");
2649 assert_eq!(s.handle_key(ctrl('c')), Action::None);
2650 assert_eq!(s.input, "", "Ctrl-C clears the line when idle");
2651 assert_eq!(s.handle_key(ctrl('d')), Action::Exit);
2652
2653 let inv = Invocation {
2654 command: venues_list(),
2655 route: Route::Pane,
2656 ai: false,
2657 argv: vec![],
2658 };
2659 s.start("venues list", &inv);
2660 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
2661 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
2662 assert_eq!(
2663 s.handle_key(ctrl('d')),
2664 Action::None,
2665 "no exit while running"
2666 );
2667 type_str(&mut s, "x");
2668 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
2669 assert_eq!(s.input, "x", "the next line waits for the command to end");
2670 s.finish(Ending::Cancelled);
2671 assert!(s.scrollback_text().contains("Cancelled after"));
2672 }
2673
2674 #[test]
2675 fn test_session_command_output_reaches_scrollback() {
2676 let mut s = state();
2677 let plan = s.submit("chain base", &Config::default());
2678 assert!(matches!(plan, Plan::Done));
2679 let text = s.scrollback_text();
2680 assert!(text.contains("scope:auto> chain base"));
2681 assert!(text.contains("Chain pinned to: base"));
2682 assert_eq!(s.ctx.chain, "base");
2683 }
2684
2685 #[test]
2686 fn test_ansi_colors_become_styles_not_escape_codes() {
2687 let mut s = state();
2688 s.push_ansi("\x1b[31mred\x1b[0m plain\n");
2689 assert_eq!(s.scrollback_text(), "red plain\n");
2690 let first = &s.scrollback[0].spans[0];
2691 assert_eq!(first.style.fg, Some(Color::Red));
2692 }
2693
2694 #[test]
2695 fn test_scrollback_is_capped() {
2696 let mut s = state();
2697 let big: String = (0..SCROLLBACK_LIMIT + 50)
2698 .map(|i| format!("l{}\n", i))
2699 .collect();
2700 s.push_ansi(&big);
2701 assert_eq!(s.scrollback.len(), SCROLLBACK_LIMIT);
2702 assert!(s.scrollback_text().starts_with("l50\n"));
2703 }
2704
2705 #[test]
2706 fn test_render_shows_latest_output_prompt_and_status() {
2707 let mut s = state();
2708 for i in 0..40 {
2709 s.push_ansi(&format!("line {}\n", i));
2710 }
2711 type_str(&mut s, "venues");
2712 let text = screen(&s, 60, 15);
2713 assert!(text.contains("line 39"), "{}", text);
2714 assert!(!text.contains("line 0 "), "{}", text);
2715 assert!(text.contains("scope:auto>"));
2716 assert!(text.contains("venues"));
2717 assert!(text.contains("chain auto"));
2718
2719 s.handle_key(key(KeyCode::PageUp));
2720 let text = screen(&s, 60, 15);
2721 assert!(!text.contains("line 39"), "{}", text);
2722 assert!(text.contains("scrolled up 7 lines"), "{}", text);
2724 }
2725
2726 fn status_row(s: &TuiState, w: u16) -> String {
2729 let line = fit_status(&s.status_parts(), w as usize);
2730 let text: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
2731 format!("{:<width$}", text, width = w as usize)
2732 }
2733
2734 fn running_state(input: &str) -> (TuiState, Output) {
2735 let mut s = state();
2736 let inv = Invocation {
2737 command: venues_list(),
2738 route: Route::Pane,
2739 ai: false,
2740 argv: vec![],
2741 };
2742 let out = s.start(input, &inv);
2743 (s, out)
2744 }
2745
2746 fn status_text(s: &TuiState) -> String {
2748 let line = fit_status(&s.status_parts(), 160);
2749 line.spans.iter().map(|sp| sp.content.as_ref()).collect()
2750 }
2751
2752 #[test]
2753 fn test_esc_hint_says_what_esc_does_with_each_overlay_open() {
2754 let _lock = super::super::DIAG_LOCK.blocking_lock();
2757 type Open = fn(&mut TuiState);
2758 let opens: [(&str, Open); 4] = [
2759 ("Esc closes palette", |s| {
2760 s.handle_key(ctrl('k'));
2761 }),
2762 ("Esc closes menu", |s| {
2763 type_str(s, "ad");
2764 s.handle_key(key(KeyCode::Tab));
2765 }),
2766 ("Esc closes search", |s| {
2767 s.handle_key(ctrl('f'));
2768 }),
2769 ("Esc closes history search", |s| {
2770 s.handle_key(ctrl('r'));
2771 }),
2772 ];
2773 for (hint, open) in opens {
2774 let mut s = vocab_state();
2775 let inv = Invocation {
2776 command: venues_list(),
2777 route: Route::Pane,
2778 ai: false,
2779 argv: vec![],
2780 };
2781 let _out = s.start("venues list", &inv);
2782 screen(&s, 80, 24);
2783 open(&mut s);
2784 assert!(s.overlay.is_some(), "{hint}: no overlay");
2785 let bar = status_text(&s);
2786 assert!(bar.contains(hint), "{hint}: {bar:?}");
2787 assert!(!bar.contains("Esc cancels"), "{hint}: {bar:?}");
2788 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::None, "{hint}");
2791 assert!(s.overlay.is_none(), "{hint}");
2792 assert!(status_text(&s).contains("Esc cancels"), "{hint}");
2793 }
2794 }
2795
2796 #[test]
2797 fn test_idle_esc_hint_shows_only_while_an_overlay_is_open() {
2798 let mut s = state();
2799 assert!(!status_text(&s).contains("Esc"), "{}", status_text(&s));
2800 screen(&s, 80, 24);
2801 s.handle_key(ctrl('k'));
2802 assert!(status_text(&s).contains("Esc closes palette"));
2803 s.handle_key(key(KeyCode::Esc));
2804 assert!(status_text(&s).contains("help"), "{}", status_text(&s));
2805 }
2806
2807 #[test]
2808 fn test_trimmed_outside_output_is_not_kept_for_copy_or_save() {
2809 let mut s = state();
2812 let (o, done) = Output::capture_merged();
2813 crate::outln!(o, "earlier data").unwrap();
2814 s.show_outside(&done, &Ending::Finished(Ok(())));
2815 assert_eq!(s.last_output.as_deref(), Some("earlier data\n"));
2816 let (terminal, _screen) = Output::capture();
2817 let (o, long) = terminal.teed(64);
2818 for i in 0..100 {
2819 crate::outln!(o, "row {i}").unwrap();
2820 }
2821 assert!(long.trimmed());
2822 s.show_outside(&long, &Ending::Finished(Ok(())));
2823 assert_eq!(s.last_output, None, "neither the part nor the old copy");
2824 assert!(
2825 s.scrollback_text()
2826 .contains("only the end is kept here, and Ctrl-Y and save do not have it."),
2827 "{}",
2828 s.scrollback_text()
2829 );
2830 assert!(matches!(s.handle_key(ctrl('y')), Action::None));
2831 assert!(s.scrollback_text().contains("nothing to copy yet"));
2832 }
2833
2834 #[test]
2835 fn test_an_outside_run_that_prompted_keeps_only_its_data_for_copy() {
2836 let mut s = state();
2839 let (terminal, _screen) = Output::capture();
2840 let (o, copy) = terminal.teed(1 << 20);
2841 let pick = crate::cli::prompt::pick(
2842 &mut std::io::Cursor::new(b"1\n".to_vec()),
2843 &o,
2844 "1 USDC\n",
2845 "token",
2846 1,
2847 );
2848 assert_eq!(pick.unwrap(), 0);
2849 crate::cli::prompt::ask(
2850 &mut std::io::Cursor::new(b"n\n".to_vec()),
2851 &o,
2852 "Save this token for future use? [y/N]: ",
2853 )
2854 .unwrap();
2855 crate::outln!(o, "report").unwrap();
2856 s.show_outside(©, &Ending::Finished(Ok(())));
2857 assert_eq!(s.last_output.as_deref(), Some("report\n"));
2858 let shown = s.scrollback_text();
2859 assert!(
2860 shown.contains("Select token (1-1)"),
2861 "the user still sees it"
2862 );
2863 }
2864
2865 #[test]
2866 fn test_a_failed_outside_command_sets_the_failed_pane_state() {
2867 let mut s = state();
2869 let err = Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into())));
2870 s.record_suspended(
2871 "crawl USDC",
2872 &[],
2873 std::time::Duration::from_secs(1),
2874 String::new(),
2875 &err,
2876 );
2877 assert_eq!(s.pane_state(), PaneState::Failed);
2878 for ok in [Ending::Finished(Ok(())), Ending::Cancelled] {
2879 s.submit("crawl USDC", &Config::default());
2880 s.record_suspended(
2881 "crawl USDC",
2882 &[],
2883 std::time::Duration::from_secs(1),
2884 String::new(),
2885 &ok,
2886 );
2887 assert_eq!(s.pane_state(), PaneState::Idle);
2888 }
2889 }
2890
2891 #[test]
2892 fn test_status_bar_keeps_the_running_state_at_every_width() {
2893 let _lock = super::super::DIAG_LOCK.blocking_lock();
2896 let (s, _out) = running_state("market summary USDC --venue binance --every 2s");
2897 for w in [120, 60, 40, 30] {
2898 let row = status_row(&s, w);
2899 assert!(row.contains("running "), "{w}: {row:?}");
2900 assert!(row.contains("Esc cancels"), "{w}: {row:?}");
2901 }
2902 let wide = status_row(&s, 120);
2903 assert!(
2904 wide.contains("limit") && wide.contains("format"),
2905 "{wide:?}"
2906 );
2907 assert!(wide.contains("market summary USDC"), "{wide:?}");
2908 }
2909
2910 #[test]
2911 fn test_status_bar_drops_limit_and_format_before_the_cancel_hint() {
2912 let _lock = super::super::DIAG_LOCK.blocking_lock();
2913 let (s, _out) = running_state("market summary USDC --venue binance --every 2s");
2914 let row = status_row(&s, 40);
2915 assert!(!row.contains("limit"), "{row:?}");
2916 assert!(!row.contains("format"), "{row:?}");
2917 assert!(row.contains('…'), "{row:?}");
2919 }
2920
2921 #[test]
2922 fn test_idle_status_bar_keeps_help_at_a_narrow_width() {
2923 let s = state();
2924 let row = status_row(&s, 30);
2925 assert!(row.contains("help"), "{row:?}");
2926 assert!(!row.contains("limit"), "{row:?}");
2927 }
2928
2929 #[test]
2930 fn test_scrolled_status_bar_keeps_the_scroll_hint_at_a_narrow_width() {
2931 let mut s = state();
2932 for i in 0..40 {
2933 s.push_ansi(&format!("line {}\n", i));
2934 }
2935 screen(&s, 40, 12);
2936 s.handle_key(key(KeyCode::PageUp));
2937 let row = status_row(&s, 40);
2938 assert!(row.contains("scrolled up 4 lines"), "{row:?}");
2939 }
2940
2941 fn scrollable() -> TuiState {
2943 let mut s = state();
2944 for i in 0..30 {
2945 s.push_ansi(&format!("line {}\n", i));
2946 }
2947 screen(&s, 60, 15);
2948 s
2949 }
2950
2951 #[test]
2952 fn test_page_up_stops_at_the_top_so_page_down_always_moves() {
2953 let mut s = scrollable();
2954 for _ in 0..50 {
2955 s.handle_key(key(KeyCode::PageUp));
2956 }
2957 let top = screen(&s, 60, 15);
2958 assert!(top.contains("scrolled up 22 lines"), "{top}");
2959 s.handle_key(key(KeyCode::PageDown));
2960 let after = screen(&s, 60, 15);
2961 assert_ne!(top, after, "PageDown must change the view");
2962 assert!(after.contains("scrolled up 15 lines"), "{after}");
2963 }
2964
2965 #[test]
2966 fn test_ctrl_home_and_ctrl_end_jump_to_the_top_and_the_bottom() {
2967 let mut s = scrollable();
2968 s.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL));
2969 assert!(screen(&s, 60, 15).contains("line 0"));
2970 assert!(screen(&s, 60, 15).contains("scrolled up 22 lines"));
2971 s.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL));
2972 let text = screen(&s, 60, 15);
2973 assert!(text.contains("line 29"), "{text}");
2974 assert!(!text.contains("scrolled up"), "{text}");
2975 }
2976
2977 #[test]
2978 fn test_end_on_an_empty_input_jumps_to_the_bottom_else_moves_the_cursor() {
2979 let mut s = scrollable();
2980 s.handle_key(key(KeyCode::PageUp));
2981 s.handle_key(key(KeyCode::End));
2982 assert_eq!(s.scroll.get(), 0);
2983 type_str(&mut s, "abc");
2984 s.handle_key(key(KeyCode::Home));
2985 s.handle_key(key(KeyCode::PageUp));
2986 s.handle_key(key(KeyCode::End));
2987 assert_eq!(s.cursor, 3, "End moves the cursor on a non-empty input");
2988 assert_eq!(s.scroll.get(), 7, "and leaves the view alone");
2989 }
2990
2991 #[test]
2992 fn test_keys_that_edit_the_input_snap_the_view_to_the_bottom() {
2993 for edit in ["char", "backspace", "delete", "paste"] {
2994 let mut s = scrollable();
2995 type_str(&mut s, "ab");
2996 s.handle_key(key(KeyCode::Left));
2997 s.handle_key(key(KeyCode::PageUp));
2998 assert!(s.scroll.get() > 0, "{edit}");
2999 match edit {
3000 "char" => drop(s.handle_key(key(KeyCode::Char('a')))),
3001 "backspace" => drop(s.handle_key(key(KeyCode::Backspace))),
3002 "delete" => drop(s.handle_key(key(KeyCode::Delete))),
3003 _ => s.paste("x"),
3004 }
3005 assert_eq!(s.scroll.get(), 0, "{edit}");
3006 }
3007 }
3008
3009 #[test]
3010 fn test_navigation_keys_and_modifiers_alone_do_not_snap_the_view() {
3011 let mut s = scrollable();
3012 s.handle_key(key(KeyCode::PageUp));
3013 for k in [
3014 key(KeyCode::Left),
3015 key(KeyCode::Right),
3016 key(KeyCode::Home),
3017 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
3018 key(KeyCode::Tab),
3019 ] {
3020 s.handle_key(k);
3021 assert_eq!(s.scroll.get(), 7, "{k:?}");
3022 }
3023 }
3024
3025 #[test]
3026 fn test_status_shows_the_running_state_and_the_scroll_state_together() {
3027 let _lock = super::super::DIAG_LOCK.blocking_lock();
3028 let (mut s, _out) = running_state("market summary USDC --every 2s");
3029 for i in 0..40 {
3030 s.push_ansi(&format!("line {}\n", i));
3031 }
3032 screen(&s, 120, 12);
3033 s.handle_key(key(KeyCode::PageUp));
3034 let row = status_row(&s, 120);
3035 assert!(row.contains("running "), "{row:?}");
3036 assert!(row.contains("scrolled up 4 lines"), "{row:?}");
3037 let row = status_row(&s, 40);
3039 assert!(row.contains("Esc cancels"), "{row:?}");
3040 }
3041
3042 const TOO_SMALL: &str = "Terminal too small: need at least 40×10";
3043
3044 #[test]
3045 fn test_the_too_small_text_is_the_spec_text() {
3046 assert_eq!(too_small_note(), TOO_SMALL);
3047 }
3048
3049 #[test]
3050 fn test_below_the_minimum_size_only_the_too_small_line_is_drawn() {
3051 let mut s = state();
3052 s.push_ansi("some output\n");
3053 type_str(&mut s, "draft");
3054 let text = screen(&s, 39, 9);
3056 assert!(text.contains(TOO_SMALL), "{text}");
3057 let drawn: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
3058 assert_eq!(drawn.len(), 1, "only the message: {text}");
3059 assert!(!text.contains("draft"), "{text}");
3060 assert_eq!(s.input, "draft", "state is kept");
3061 let text = screen(&s, 40, 10);
3062 assert!(!text.contains(TOO_SMALL), "{text}");
3063 assert!(text.contains("draft"), "{text}");
3064 assert!(screen(&s, 40, 9).contains(TOO_SMALL));
3065 assert!(screen(&s, 39, 10).contains(TOO_SMALL));
3066 }
3067
3068 #[test]
3069 fn test_the_too_small_message_wraps_on_words_and_is_centered() {
3070 let s = state();
3071 let text = screen(&s, 30, 8);
3072 let rows: Vec<&str> = text.lines().collect();
3073 let at = rows
3074 .iter()
3075 .position(|r| r.contains("Terminal too small: need at"))
3076 .expect(&text);
3077 assert!(rows[at + 1].contains("least 40×10"), "{text}");
3078 assert_eq!(at, 3, "centered vertically: {text}");
3079 assert!(rows[at].starts_with(' '), "centered horizontally: {text}");
3080 assert!(screen(&s, 30, 1).contains("Terminal too small"));
3082 for w in 0..12 {
3083 for h in 0..4 {
3084 screen(&s, w, h);
3085 }
3086 }
3087 }
3088
3089 #[test]
3090 fn test_too_small_clears_the_pane_width_so_no_override_is_set() {
3091 let s = state();
3092 screen(&s, 80, 24);
3093 assert!(s.pane_width().is_some());
3094 screen(&s, 30, 8);
3095 assert_eq!(s.pane_width(), None);
3096 }
3097
3098 #[test]
3099 fn test_overlays_do_not_act_while_the_terminal_is_too_small() {
3100 let mut s = state();
3101 type_str(&mut s, "dra");
3102 s.handle_key(ctrl('k'));
3103 assert!(s.overlay.is_some());
3104 screen(&s, 30, 8);
3105 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3106 assert!(s.overlay.is_none(), "the overlay is closed");
3107 assert_eq!(s.input, "dra", "the palette did not fill the input");
3108 screen(&s, 80, 24);
3111 s.handle_key(ctrl('k'));
3112 screen(&s, 30, 8);
3113 s.handle_key(key(KeyCode::Char('x')));
3114 assert!(s.overlay.is_none());
3115 assert_eq!(s.input, "dra");
3116 screen(&s, 80, 24);
3118 s.handle_key(ctrl('k'));
3119 screen(&s, 30, 8);
3120 s.handle_key(key(KeyCode::Esc));
3121 assert!(s.overlay.is_none());
3122 assert_eq!(s.input, "dra");
3123 }
3124
3125 #[test]
3126 fn test_enter_does_nothing_while_too_small_and_a_command_runs() {
3127 let _lock = super::super::DIAG_LOCK.blocking_lock();
3128 let (mut s, _out) = running_state("market summary USDC --every 2s");
3129 type_str(&mut s, "ctx");
3130 screen(&s, 30, 8);
3131 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3132 assert_eq!(s.input, "ctx");
3133 assert!(s.running.is_some());
3134 }
3135
3136 #[test]
3137 fn test_enter_does_nothing_while_the_terminal_is_too_small() {
3138 let mut s = state();
3139 type_str(&mut s, "help");
3140 screen(&s, 30, 8);
3141 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3142 assert_eq!(s.input, "help", "the draft is not lost or run");
3143 s.handle_key(key(KeyCode::Char('x')));
3145 assert_eq!(s.input, "helpx");
3146 screen(&s, 80, 24);
3148 assert!(matches!(
3149 s.handle_key(key(KeyCode::Enter)),
3150 Action::Submit(_)
3151 ));
3152 }
3153
3154 #[test]
3155 fn test_fit_status_never_panics_and_never_exceeds_the_width() {
3156 let text = |t: &str| vec![Span::styled(t.to_string(), ACCENT)];
3159 let mut parts = StatusParts {
3160 context: vec![
3161 labeled("chain", "auto".into()),
3162 labeled("format", "Table".into()),
3163 labeled("limit", "100".into()),
3164 ],
3165 state: text("running 12s · Esc cancels"),
3166 scrolled: Some(text("scrolled up 7 lines · PgDn")),
3167 search: None,
3168 command: Some("market summary ✓✓ --every 2s".into()),
3169 };
3170 for search in [None, Some(text("search: alpha ✓ 1/2"))] {
3171 parts.search = search;
3172 for w in 0..=130 {
3173 let line = fit_status(&parts, w);
3174 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
3175 assert!(cols(&text) <= w, "{w}: {text:?}");
3176 }
3177 }
3178 parts.search = None;
3179 let text: String = fit_status(&parts, 0)
3180 .spans
3181 .iter()
3182 .map(|s| s.content.as_ref())
3183 .collect();
3184 assert!(text.is_empty());
3185 }
3186
3187 use crate::cli::output::RunProgress;
3188 use std::time::Duration;
3189
3190 #[test]
3191 fn test_running_pane_shows_a_spinner_and_the_repeat_status() {
3192 let _lock = super::super::DIAG_LOCK.blocking_lock();
3193 let (s, out) = running_state("market summary USDC --every 2s");
3194 let hook = out.progress_hook().expect("the pane output has a hook");
3195 *hook.lock().unwrap() = RunProgress {
3196 run: 2,
3197 total: Some(4),
3198 next_at: Some(Instant::now() + Duration::from_secs(3)),
3199 };
3200 let text = screen(&s, 80, 12);
3201 assert!(text.contains("run 2 of 4 · next in 3s"), "{text}");
3202 assert!(
3203 text.contains("running market summary USDC --every 2s…"),
3204 "{text}"
3205 );
3206 let row = text.lines().find(|l| l.contains("running market")).unwrap();
3208 let glyph = row.trim_start().chars().next().unwrap().to_string();
3209 assert!(
3210 SPINNER_FRAMES.contains(&glyph.as_str()) || SPINNER_ASCII.contains(&glyph.as_str()),
3211 "{row:?}"
3212 );
3213 }
3214
3215 #[test]
3216 fn test_spinner_uses_ascii_without_color_and_advances_with_time() {
3217 assert_eq!(spinner_glyph(Duration::from_millis(0), false), "-");
3218 assert_eq!(spinner_glyph(Duration::from_millis(100), false), "\\");
3219 assert_eq!(spinner_glyph(Duration::from_millis(200), false), "|");
3220 assert_eq!(spinner_glyph(Duration::from_millis(300), false), "/");
3221 assert_eq!(spinner_glyph(Duration::from_millis(400), false), "-");
3222 assert_ne!(
3223 spinner_glyph(Duration::from_millis(0), true),
3224 spinner_glyph(Duration::from_millis(100), true)
3225 );
3226 }
3227
3228 #[test]
3229 fn test_spinner_and_run_status_never_enter_the_record() {
3230 let _lock = super::super::DIAG_LOCK.blocking_lock();
3233 let dir = tempfile::tempdir().unwrap();
3234 let journal = Journal::at(dir.path().join("history"), dir.path().join("events"));
3235 let mut s = TuiState::new(SessionContext::default(), journal, Vocab::default());
3236 let inv = Invocation {
3237 command: venues_list(),
3238 route: Route::Pane,
3239 ai: false,
3240 argv: vec![],
3241 };
3242 let out = s.start("market summary USDC --every 2s", &inv);
3243 crate::outln!(out, "data line").unwrap();
3244 *out.progress_hook().unwrap().lock().unwrap() = RunProgress {
3245 run: 2,
3246 total: Some(4),
3247 next_at: Some(Instant::now() + Duration::from_secs(3)),
3248 };
3249 let drawn = screen(&s, 80, 12);
3250 assert!(drawn.contains("run 2 of 4"), "{drawn}");
3251 s.finish(Ending::Finished(Ok(())));
3252 let kept = format!(
3253 "{}\n{}",
3254 s.scrollback_text(),
3255 s.last_output.clone().unwrap_or_default()
3256 );
3257 assert!(kept.contains("data line"));
3258 for gone in [
3259 "run 2 of",
3260 "next in",
3261 "running market",
3262 "⠋",
3263 "Running market",
3264 ] {
3265 assert!(!kept.contains(gone), "{gone:?} leaked into: {kept}");
3266 }
3267 let events = std::fs::read_to_string(dir.path().join("events")).unwrap();
3268 assert!(
3269 !events.contains("run 2 of") && !events.contains("next in"),
3270 "{events}"
3271 );
3272 let after = screen(&s, 80, 12);
3274 assert!(!after.contains("run 2 of"), "{after}");
3275 }
3276
3277 #[test]
3278 fn test_render_shows_live_output_of_a_running_command() {
3279 let _lock = super::super::DIAG_LOCK.blocking_lock();
3281 let mut s = state();
3282 let inv = Invocation {
3283 command: venues_list(),
3284 route: Route::Pane,
3285 ai: false,
3286 argv: vec![],
3287 };
3288 let out = s.start("market summary USDC --every 30s", &inv);
3289 crate::outln!(out, "\x1b[32mrun 1: HEALTHY\x1b[0m").unwrap();
3290 let text = screen(&s, 80, 15);
3291 assert!(text.contains("run 1: HEALTHY"), "{}", text);
3292 assert!(text.contains("running 0s · Esc cancels"), "{}", text);
3293 s.finish(Ending::Finished(Ok(())));
3294 assert!(s.scrollback_text().contains("run 1: HEALTHY"));
3295 }
3296
3297 #[test]
3298 fn test_newest_line_is_on_the_last_pane_row() {
3299 let mut s = state();
3302 for i in 0..40 {
3303 s.push_ansi(&format!("line {}\n", i));
3304 }
3305 let rows: Vec<String> = screen(&s, 40, 15).lines().map(str::to_string).collect();
3306 assert!(rows[8].contains("line 39"), "{:#?}", rows);
3308 assert!(rows[9].starts_with('─'), "{:#?}", rows);
3309 }
3310
3311 #[test]
3312 fn test_hint_row_shows_usage_while_typing() {
3313 let mut s = state();
3314 type_str(&mut s, "market summary");
3315 let text = screen(&s, 100, 15);
3316 assert!(text.contains("scope market summary"), "{}", text);
3317 assert!(text.contains("next: <SYMBOL>"), "{}", text);
3318 }
3319
3320 #[test]
3321 fn test_render_tiny_terminal_does_not_panic() {
3322 let mut s = state();
3323 s.push_ansi("x\n");
3324 screen(&s, 10, 5);
3325 screen(&s, 1, 1);
3326 }
3327
3328 #[tokio::test]
3329 async fn test_finish_with_error_shows_cli_error_text_and_logs() {
3330 let _lock = super::super::DIAG_LOCK.lock().await;
3331 let dir = tempfile::tempdir().unwrap();
3332 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
3333 let mut s = TuiState::new(SessionContext::default(), journal, Vocab::default());
3334 let inv = Invocation {
3335 command: venues_list(),
3336 route: Route::Pane,
3337 ai: false,
3338 argv: vec!["scope".into(), "venues".into(), "list".into()],
3339 };
3340 s.start("venues list", &inv);
3341 s.finish(Ending::Finished(Err(
3342 scope::error::ScopeError::InvalidAddress("0xbad".into()),
3343 )));
3344 let text = s.scrollback_text();
3345 assert!(text.contains("Invalid address format: 0xbad"), "{}", text);
3346 assert!(text.contains("EVM: 0x followed by 40 hex characters"));
3347 let log = std::fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
3348 let ev: serde_json::Value = serde_json::from_str(log.lines().last().unwrap()).unwrap();
3349 assert_eq!(ev["outcome"], "error");
3350 assert_eq!(ev["route"], "pane");
3351 assert_eq!(ev["argv"][1], "venues");
3352 }
3353
3354 fn palette_selected(s: &TuiState) -> usize {
3355 let Some(Overlay::Palette(p)) = &s.overlay else {
3356 panic!("palette expected")
3357 };
3358 p.selected
3359 }
3360
3361 #[test]
3362 fn test_palette_selection_wraps_and_a_new_query_resets_it() {
3363 let mut s = state();
3366 s.handle_key(ctrl_k());
3367 let count = match &s.overlay {
3368 Some(Overlay::Palette(p)) => p.matches().len(),
3369 _ => panic!("palette expected"),
3370 };
3371 assert!(count > 2);
3372 s.handle_key(key(KeyCode::Up));
3373 assert_eq!(palette_selected(&s), count - 1);
3374 s.handle_key(key(KeyCode::Down));
3375 assert_eq!(palette_selected(&s), 0);
3376 s.handle_key(key(KeyCode::Tab));
3377 s.handle_key(key(KeyCode::BackTab));
3378 assert_eq!(palette_selected(&s), 0);
3379 s.handle_key(key(KeyCode::Down));
3380 s.handle_key(key(KeyCode::Backspace));
3381 assert_eq!(palette_selected(&s), 0);
3382 s.handle_key(key(KeyCode::Down));
3383 type_str(&mut s, "m");
3384 assert_eq!(palette_selected(&s), 0);
3385 }
3386
3387 #[test]
3388 fn test_palette_esc_closes_and_enter_without_a_match_keeps_the_draft() {
3389 let mut s = state();
3390 type_str(&mut s, "draft");
3391 s.handle_key(ctrl_k());
3392 s.handle_key(key(KeyCode::Esc));
3393 assert!(s.overlay.is_none());
3394 s.handle_key(ctrl_k());
3395 type_str(&mut s, "zzzzqqqq");
3396 s.handle_key(key(KeyCode::Down));
3397 s.handle_key(key(KeyCode::Enter));
3398 assert!(s.overlay.is_none());
3399 assert_eq!(s.input, "draft", "no match: nothing is filled in");
3400 }
3401
3402 #[test]
3403 fn test_menu_up_wraps_to_the_last_candidate() {
3404 let mut s = vocab_state();
3405 type_str(&mut s, "market summary USDC --venue b");
3406 s.handle_key(key(KeyCode::Tab));
3407 s.handle_key(key(KeyCode::Up));
3408 s.handle_key(key(KeyCode::Enter));
3409 assert_eq!(s.input, "market summary USDC --venue bitget ");
3410 s.set_input("market summary USDC --venue b".to_string());
3411 s.handle_key(key(KeyCode::Tab));
3412 s.handle_key(key(KeyCode::Down));
3413 s.handle_key(key(KeyCode::BackTab));
3414 s.handle_key(key(KeyCode::Enter));
3415 assert_eq!(s.input, "market summary USDC --venue binance ");
3416 }
3417
3418 #[test]
3419 fn test_record_signal_writes_the_signal_outcome_to_the_journal() {
3420 let dir = tempfile::tempdir().unwrap();
3423 let events = dir.path().join("e.jsonl");
3424 let mut s = TuiState::new(
3425 SessionContext::default(),
3426 Journal::at(dir.path().join("h"), events.clone()),
3427 Vocab::default(),
3428 );
3429 s.record_signal("HUP", "suspend", "setup", &["scope".into(), "setup".into()]);
3430 let row: serde_json::Value =
3431 serde_json::from_str(std::fs::read_to_string(events).unwrap().trim()).unwrap();
3432 assert_eq!(row["outcome"], "signal:HUP");
3433 assert_eq!(row["route"], "suspend");
3434 assert_eq!(row["input"], "setup");
3435 assert_eq!(row["argv"][1], "setup");
3436 }
3437
3438 #[test]
3439 fn test_failed_suspended_command_is_journaled_as_error_with_its_text() {
3440 let dir = tempfile::tempdir().unwrap();
3441 let events = dir.path().join("e.jsonl");
3442 let mut s = TuiState::new(
3443 SessionContext::default(),
3444 Journal::at(dir.path().join("h"), events.clone()),
3445 Vocab::default(),
3446 );
3447 let failed = Err(scope::error::ScopeError::Other("setup broke".into()));
3448 s.record_suspended(
3449 "setup",
3450 &["scope".into(), "setup".into()],
3451 std::time::Duration::from_millis(5),
3452 "2026-01-01T00:00:00Z".into(),
3453 &Ending::Finished(failed),
3454 );
3455 let row: serde_json::Value =
3456 serde_json::from_str(std::fs::read_to_string(events).unwrap().trim()).unwrap();
3457 assert_eq!(row["outcome"], "error");
3458 assert_eq!(row["route"], "suspend");
3459 assert!(row["error"].as_str().unwrap().contains("setup broke"));
3460 }
3461
3462 #[test]
3463 fn test_unwritable_event_log_is_reported_in_the_pane() {
3464 let dir = tempfile::tempdir().unwrap();
3467 let mut s = TuiState::new(
3469 SessionContext::default(),
3470 Journal::at(dir.path().join("h"), dir.path().to_path_buf()),
3471 Vocab::default(),
3472 );
3473 s.record_signal("TERM", "session", "", &[]);
3474 assert!(
3475 s.scrollback_text()
3476 .contains("Could not write the TUI event log"),
3477 "{}",
3478 s.scrollback_text()
3479 );
3480 }
3481
3482 #[test]
3483 fn test_paste_joins_lines_and_never_runs() {
3484 let mut s = state();
3485 s.paste("venues list\nmarket summary USDC\n");
3486 assert_eq!(s.input, "venues list market summary USDC");
3487 assert!(s.scrollback_text().contains("pasted 2 lines as one line"));
3488 }
3489
3490 #[test]
3491 fn test_paste_one_line_adds_no_note_and_crlf_counts_once() {
3492 let mut s = state();
3493 s.paste("help\r\n");
3494 assert_eq!(s.input, "help");
3495 assert!(!s.scrollback_text().contains("pasted"));
3496 s.set_input(String::new());
3497 s.paste("a\r\nb\rc");
3498 assert_eq!(s.input, "a b c");
3499 assert!(s.scrollback_text().contains("pasted 3 lines as one line"));
3500 }
3501
3502 #[test]
3503 fn test_paste_inserts_at_the_cursor() {
3504 let mut s = state();
3505 type_str(&mut s, "ac");
3506 s.handle_key(key(KeyCode::Left));
3507 s.paste("b");
3508 assert_eq!(s.input, "abc");
3509 type_str(&mut s, "!");
3510 assert_eq!(s.input, "ab!c", "the cursor sits after the paste");
3511 }
3512
3513 #[test]
3514 fn test_paste_cannot_inject_control_characters() {
3515 let mut s = state();
3516 s.paste("a\x1b[2Jb\tc\x07\x7fd\u{9b}e");
3517 assert_eq!(s.input, "a[2Jb cde");
3518 }
3519
3520 #[test]
3521 fn test_paste_closes_the_completion_menu() {
3522 let mut s = vocab_state();
3523 type_str(&mut s, "market summary X --venue b");
3524 s.handle_key(key(KeyCode::Tab));
3525 assert!(s.overlay.is_some());
3526 s.paste("z");
3527 assert!(s.overlay.is_none());
3528 }
3529
3530 #[test]
3531 fn test_paste_while_a_command_runs_goes_to_the_input() {
3532 let _lock = super::super::DIAG_LOCK.blocking_lock();
3533 let mut s = state();
3534 let inv = Invocation {
3535 command: venues_list(),
3536 route: Route::Pane,
3537 ai: false,
3538 argv: vec![],
3539 };
3540 s.start("venues list", &inv);
3541 s.paste("market summary USDC\n");
3542 assert_eq!(s.input, "market summary USDC");
3543 assert!(s.running.is_some(), "the paste does not run or cancel");
3544 s.finish(Ending::Cancelled);
3545 }
3546
3547 #[test]
3548 fn test_long_input_keeps_cursor_visible() {
3549 let mut s = state();
3550 s.paste(&"x".repeat(300));
3551 assert_eq!(cursor_pos(&s, 60, 12).x, 58);
3553 let sc = screen(&s, 60, 12);
3554 let row = sc.lines().find(|l| l.contains("xxxx")).unwrap();
3555 let cells: Vec<char> = row.chars().collect();
3556 assert_eq!(cells[0], '│');
3557 assert!(
3558 cells[1..58].iter().all(|&c| c == 'x'),
3559 "text ends at the cursor"
3560 );
3561 assert_eq!(cells[58], ' ', "the cursor cell is empty");
3562 assert_eq!(cells[59], '│', "the border is not overwritten");
3563 }
3564
3565 #[test]
3566 fn test_cursor_scrolls_back_with_home() {
3567 let mut s = state();
3568 s.paste(&"x".repeat(100));
3569 s.handle_key(key(KeyCode::Home));
3570 assert_eq!(
3571 cursor_pos(&s, 60, 12).x,
3572 1,
3573 "Home shows the start of the line"
3574 );
3575 s.handle_key(key(KeyCode::End));
3576 assert_eq!(cursor_pos(&s, 60, 12).x, 58);
3577 }
3578
3579 #[test]
3580 fn test_cursor_follows_display_width_of_cjk() {
3581 let mut s = state();
3583 type_str(&mut s, "日本 ok");
3584 s.handle_key(key(KeyCode::End));
3585 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 7);
3586 s.handle_key(key(KeyCode::Left));
3587 s.handle_key(key(KeyCode::Left));
3588 s.handle_key(key(KeyCode::Left));
3589 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 4, "after 日本");
3590 }
3591
3592 #[test]
3593 fn test_cursor_follows_display_width_of_emoji() {
3594 let mut s = state();
3595 type_str(&mut s, "😀x");
3596 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 3);
3597 }
3598
3599 #[test]
3600 fn test_wide_input_scrolls_by_columns_not_chars() {
3601 let mut s = state();
3603 s.paste(&"日".repeat(40));
3604 assert_eq!(cursor_pos(&s, 60, 12).x, 57);
3606 }
3607
3608 #[test]
3609 fn test_menu_width_uses_display_width() {
3610 let wide_a = "日本日本日本a";
3611 let wide_b = "日本日本日本b";
3612 let mut s = TuiState::new(
3613 SessionContext::default(),
3614 Journal::default(),
3615 Vocab {
3616 venues: vec![wide_a.into(), wide_b.into()],
3617 ..Vocab::default()
3618 },
3619 );
3620 type_str(&mut s, "market summary X --venue ");
3621 s.handle_key(key(KeyCode::Tab));
3622 assert!(matches!(s.overlay, Some(Overlay::Menu(_))));
3623 let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap();
3624 term.draw(|f| s.render(f)).unwrap();
3625 let buf = term.backend().buffer().clone();
3626 let row = (0..14)
3629 .find(|&y| (0..60).any(|x| buf[(x, y)].symbol() == "a"))
3630 .unwrap();
3631 assert_eq!(buf[(16, row)].symbol(), "│");
3632 }
3633
3634 fn start_pane_command(s: &mut TuiState) {
3635 let inv = Invocation {
3636 command: venues_list(),
3637 route: Route::Pane,
3638 ai: false,
3639 argv: vec![],
3640 };
3641 s.start("venues list", &inv);
3642 }
3643
3644 #[test]
3645 fn test_ctrl_d_while_running_explains_and_does_not_exit() {
3646 let _lock = super::super::DIAG_LOCK.blocking_lock();
3647 let mut s = state();
3648 start_pane_command(&mut s);
3649 assert_eq!(s.handle_key(ctrl('d')), Action::None);
3650 assert!(s.scrollback_text().contains(BUSY_NOTE));
3651 s.finish(Ending::Cancelled);
3652 }
3653
3654 #[test]
3655 fn test_exit_typed_while_running_explains_and_keeps_the_line() {
3656 let _lock = super::super::DIAG_LOCK.blocking_lock();
3657 for word in ["exit", "quit", "q", "EXIT"] {
3658 let mut s = state();
3659 start_pane_command(&mut s);
3660 type_str(&mut s, word);
3661 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None, "{word}");
3662 assert!(s.scrollback_text().contains(BUSY_NOTE), "{word}");
3663 assert_eq!(s.input, word, "the line stays");
3664 s.finish(Ending::Cancelled);
3665 }
3666 }
3667
3668 #[test]
3669 fn test_any_line_while_running_shows_the_hint_and_keeps_the_input() {
3670 let _lock = super::super::DIAG_LOCK.blocking_lock();
3671 let mut s = state();
3672 start_pane_command(&mut s);
3673 type_str(&mut s, "ctx");
3674 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
3675 assert_eq!(s.input, "ctx");
3676 assert!(screen(&s, 100, 14).contains(BUSY_NOTE), "hint row");
3677 assert!(
3678 !s.scrollback_text().contains(BUSY_NOTE),
3679 "no scrollback note"
3680 );
3681 s.finish(Ending::Cancelled);
3682 }
3683
3684 #[test]
3685 fn test_empty_enter_while_running_shows_no_hint() {
3686 let _lock = super::super::DIAG_LOCK.blocking_lock();
3687 let mut s = state();
3688 start_pane_command(&mut s);
3689 s.handle_key(key(KeyCode::Enter));
3690 assert!(!screen(&s, 100, 14).contains(BUSY_NOTE));
3691 s.finish(Ending::Cancelled);
3692 }
3693
3694 #[test]
3695 fn test_busy_hint_clears_on_the_next_key() {
3696 let _lock = super::super::DIAG_LOCK.blocking_lock();
3697 let mut s = state();
3698 start_pane_command(&mut s);
3699 s.handle_key(ctrl('d'));
3700 let hint_row = screen(&s, 100, 14).lines().nth(12).unwrap().to_string();
3701 assert!(hint_row.contains(BUSY_NOTE));
3702 s.handle_key(key(KeyCode::Char('x')));
3703 let hint_row = screen(&s, 100, 14).lines().nth(12).unwrap().to_string();
3704 assert!(!hint_row.contains(BUSY_NOTE));
3705 s.finish(Ending::Cancelled);
3706 }
3707
3708 #[test]
3709 fn test_busy_note_is_not_repeated_in_a_row() {
3710 let _lock = super::super::DIAG_LOCK.blocking_lock();
3711 let mut s = state();
3712 start_pane_command(&mut s);
3713 s.handle_key(ctrl('d'));
3714 s.handle_key(ctrl('d'));
3715 assert_eq!(s.scrollback_text().matches(BUSY_NOTE).count(), 1);
3716 s.finish(Ending::Cancelled);
3717 }
3718
3719 #[test]
3720 fn test_ctrl_d_exits_once_the_command_ended() {
3721 let _lock = super::super::DIAG_LOCK.blocking_lock();
3722 let mut s = state();
3723 start_pane_command(&mut s);
3724 s.handle_key(ctrl('d'));
3725 s.finish(Ending::Cancelled);
3726 assert_eq!(s.handle_key(ctrl('d')), Action::Exit);
3727 }
3728
3729 #[test]
3730 fn test_ctrl_d_with_text_and_no_command_does_nothing() {
3731 let mut s = state();
3732 type_str(&mut s, "ab");
3733 assert_eq!(s.handle_key(ctrl('d')), Action::None);
3734 assert!(!s.scrollback_text().contains(BUSY_NOTE));
3735 }
3736
3737 #[test]
3738 fn test_paste_drops_bidi_controls() {
3739 let mut s = state();
3740 s.paste("a\u{202E}b\u{2066}c\u{2069}d\u{202A}");
3741 assert_eq!(s.input, "abcd");
3742 }
3743
3744 #[test]
3745 fn test_paste_is_capped_with_a_note() {
3746 let mut s = state();
3747 s.paste(&"x".repeat(PASTE_LIMIT + 50));
3748 assert_eq!(s.input.chars().count(), PASTE_LIMIT);
3749 assert!(
3750 s.scrollback_text()
3751 .contains("paste truncated to 10000 characters")
3752 );
3753 let mut t = state();
3754 t.paste(&"x".repeat(PASTE_LIMIT));
3755 assert!(
3756 !t.scrollback_text().contains("truncated"),
3757 "exact limit is not truncated"
3758 );
3759 }
3760
3761 #[test]
3762 fn test_paste_goes_into_the_open_palette_query() {
3763 let mut s = state();
3764 s.handle_key(ctrl('k'));
3765 s.paste("ven\nues");
3766 match &s.overlay {
3767 Some(Overlay::Palette(p)) => assert_eq!(p.query, "ven ues"),
3768 _ => panic!("the palette stays open"),
3769 }
3770 assert_eq!(s.input, "", "the input line is untouched");
3771 }
3772
3773 fn row_cells(s: &TuiState, w: u16, h: u16, needle: &str) -> (Vec<String>, u16) {
3774 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
3775 term.draw(|f| s.render(f)).unwrap();
3776 let buf = term.backend().buffer().clone();
3777 let y = (0..h)
3778 .find(|&y| (0..w).any(|x| buf[(x, y)].symbol() == needle))
3779 .unwrap();
3780 (
3781 (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect(),
3782 y,
3783 )
3784 }
3785
3786 #[test]
3787 fn test_left_mid_line_keeps_the_text_right_of_the_cursor_visible() {
3788 let mut s = state();
3789 s.paste(&format!("{}{}", "a".repeat(100), "b".repeat(5)));
3790 let _ = cursor_pos(&s, 60, 12);
3791 for _ in 0..3 {
3792 s.handle_key(key(KeyCode::Left));
3793 }
3794 let (cells, _) = row_cells(&s, 60, 12, "b");
3795 assert_eq!(cursor_pos(&s, 60, 12).x, 55, "the window did not move");
3796 assert_eq!(cells[55], "b", "text under and right of the cursor stays");
3797 assert_eq!(cells[57], "b", "the last char is still shown");
3798 }
3799
3800 #[test]
3801 fn test_window_moves_back_when_the_cursor_leaves_on_the_left() {
3802 let mut s = state();
3803 s.paste(&"x".repeat(100));
3804 let _ = cursor_pos(&s, 60, 12);
3805 for _ in 0..80 {
3806 s.handle_key(key(KeyCode::Left));
3807 }
3808 assert_eq!(cursor_pos(&s, 60, 12).x, 1, "cursor at the left edge");
3809 }
3810
3811 #[test]
3812 fn test_wide_chars_are_never_cut_at_the_left_edge() {
3813 let mut s = state();
3816 s.paste(&"日".repeat(40));
3817 let (cells, _) = row_cells(&s, 60, 12, "日");
3818 assert_eq!(cells[1], "日", "the first shown cell holds a whole char");
3819 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 80 - 24);
3820 assert_eq!(cells[59], "│", "border intact");
3821 }
3822
3823 #[test]
3824 fn test_cursor_on_a_wide_char_is_never_half_visible() {
3825 let mut s = state();
3826 s.paste(&"x".repeat(56));
3827 s.paste("日日");
3828 s.handle_key(key(KeyCode::Left));
3829 let p = cursor_pos(&s, 60, 12);
3831 assert!(
3832 p.x + 2 <= 59,
3833 "wide char at the cursor fits inside the border"
3834 );
3835 }
3836
3837 fn run_pane(s: &mut TuiState, text: &str, ending: Ending) {
3839 let inv = Invocation {
3840 command: venues_list(),
3841 route: Route::Pane,
3842 ai: false,
3843 argv: vec![],
3844 };
3845 let out = s.start("venues list", &inv);
3846 out.write_out(format_args!("{}", text)).unwrap();
3847 s.finish(ending);
3848 }
3849
3850 #[test]
3851 fn test_ctrl_y_copies_the_plain_output_of_the_last_success() {
3852 let mut s = state();
3853 assert_eq!(s.handle_key(ctrl('y')), Action::None);
3854 assert!(s.scrollback_text().contains("nothing to copy yet"));
3855 run_pane(&mut s, "\x1b[1;36mA\x1b[0m\nb\n", Ending::Finished(Ok(())));
3856 assert_eq!(s.handle_key(ctrl('y')), Action::Copy("A\nb\n".into()));
3857 }
3858
3859 #[test]
3860 fn test_failed_or_cancelled_run_keeps_the_earlier_copy() {
3861 let mut s = state();
3862 run_pane(&mut s, "good\n", Ending::Finished(Ok(())));
3863 run_pane(
3864 &mut s,
3865 "partial\n",
3866 Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into()))),
3867 );
3868 run_pane(&mut s, "partial\n", Ending::Cancelled);
3869 assert_eq!(s.handle_key(ctrl('y')), Action::Copy("good\n".into()));
3870 }
3871
3872 #[test]
3873 fn test_save_without_output_says_so_and_escape_is_refused() {
3874 let mut s = state();
3875 s.submit("save out.json", &Config::default());
3876 assert!(s.scrollback_text().contains("nothing to save yet"));
3877 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
3878 s.submit("save ../escape.json", &Config::default());
3879 assert!(
3880 s.scrollback_text()
3881 .contains("save writes inside the current directory or to an absolute path")
3882 );
3883 s.submit("save", &Config::default());
3884 assert!(s.scrollback_text().contains("Usage: save <file> [--force]"));
3885 }
3886
3887 #[test]
3888 fn test_pane_has_no_side_borders() {
3889 let mut s = state();
3890 s.push_note("hello");
3891 let text = screen(&s, 40, 12);
3892 let row = text.lines().find(|l| l.contains("hello")).unwrap();
3893 assert!(row.starts_with("ℹ hello"), "{row:?}");
3894 }
3895
3896 fn last_line(s: &TuiState) -> &Line<'static> {
3897 s.scrollback.last().unwrap()
3898 }
3899
3900 #[test]
3901 fn test_ok_run_ends_with_done_footer() {
3902 let mut s = state();
3903 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
3904 let text = last_line(&s).to_string();
3905 assert!(
3906 regex::Regex::new(r"^✓ Done in \d+\.\ds\.$")
3907 .unwrap()
3908 .is_match(&text),
3909 "{text}"
3910 );
3911 assert_eq!(last_line(&s).style, OK);
3912 }
3913
3914 #[test]
3915 fn test_failed_run_ends_with_a_red_failed_footer() {
3916 let mut s = state();
3917 let err = Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into())));
3918 s.start("x", &pane_invocation());
3919 s.finish_with(err);
3920 let line = last_line(&s);
3921 let text = line.to_string();
3922 assert!(
3923 regex::Regex::new(r"^✗ Failed in \d+\.\ds\.$")
3924 .unwrap()
3925 .is_match(&text),
3926 "{text}"
3927 );
3928 assert_eq!(line.style, ERR.add_modifier(Modifier::BOLD));
3929 }
3930
3931 #[test]
3932 fn test_cancelled_run_ends_with_cancelled_after_footer() {
3933 let mut s = state();
3934 run_pane(&mut s, "x\n", Ending::Cancelled);
3935 let text = last_line(&s).to_string();
3936 assert!(
3937 regex::Regex::new(r"^Cancelled after \d+\.\ds\.$")
3938 .unwrap()
3939 .is_match(&text),
3940 "{text}"
3941 );
3942 assert_eq!(last_line(&s).style, WARN);
3943 }
3944
3945 fn pane_invocation() -> Invocation {
3946 Invocation {
3947 command: venues_list(),
3948 route: Route::Pane,
3949 ai: false,
3950 argv: vec![],
3951 }
3952 }
3953
3954 fn warned_json_run(s: &mut TuiState) {
3955 let inv = pane_invocation();
3956 let out = s.start("address 0x1 --format json", &inv);
3957 crate::errln!(out, "\u{26a0} Token balances unavailable").unwrap();
3958 crate::outln!(out, "{{\"a\": 1}}").unwrap();
3959 s.finish(Ending::Finished(Ok(())));
3960 }
3961
3962 #[test]
3963 fn test_last_output_excludes_warnings_the_pane_still_shows() {
3964 let mut s = state();
3965 warned_json_run(&mut s);
3966 let Action::Copy(text) = s.handle_key(ctrl('y')) else {
3967 panic!("expected a copy");
3968 };
3969 serde_json::from_str::<serde_json::Value>(&text).unwrap();
3970 assert!(!text.contains("Token balances"));
3971 assert!(s.scrollback_text().contains("Token balances unavailable"));
3972 }
3973
3974 #[test]
3975 fn test_a_success_with_no_output_clears_the_copy() {
3976 let mut s = state();
3977 run_pane(&mut s, "data\n", Ending::Finished(Ok(())));
3978 run_pane(&mut s, "", Ending::Finished(Ok(())));
3979 assert_eq!(s.handle_key(ctrl('y')), Action::None);
3980 s.submit("save out.json", &Config::default());
3981 assert!(s.scrollback_text().contains("nothing to save yet"));
3982 }
3983
3984 #[test]
3985 fn test_ctrl_y_refuses_output_over_the_osc52_cap() {
3986 let mut s = state();
3987 let big = "x".repeat(clip::OSC52_MAX_PAYLOAD);
3988 run_pane(&mut s, &format!("{big}\n"), Ending::Finished(Ok(())));
3989 assert_eq!(s.handle_key(ctrl('y')), Action::None);
3990 assert!(
3991 s.scrollback_text()
3992 .contains("too large to copy (1 lines); use save <file>")
3993 );
3994 }
3995
3996 #[test]
3997 fn test_save_with_unbalanced_quotes_gets_the_save_usage() {
3998 let mut s = state();
3999 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4000 s.submit("save \"unclosed", &Config::default());
4001 let text = s.scrollback_text();
4002 assert!(
4003 text.contains("Unbalanced quotes. Usage: save <file> [--force]"),
4004 "{text}"
4005 );
4006 }
4007
4008 fn pane_rows(s: &TuiState, w: u16) -> Vec<String> {
4010 let h = 200;
4011 let rows: Vec<String> = screen(s, w, h).lines().map(str::to_string).collect();
4012 rows[1..(h as usize - 6)].to_vec()
4014 }
4015
4016 #[tokio::test]
4017 async fn test_venues_list_rules_never_wrap_in_a_narrow_pane() {
4018 let _lock = super::super::DIAG_LOCK.lock().await;
4023 let mut s = state();
4024 let inv = pane_invocation();
4025 let out = s.start("venues list", &inv);
4026 let factory = scope::chains::mocks::MockClientFactory::new();
4027 let res = run_invocation(inv, &Config::default(), &factory, &out).await;
4028 s.finish(Ending::Finished(res));
4029 let rows = pane_rows(&s, 40);
4030 for pair in rows.windows(2) {
4031 let continued = pair[0].trim_end().ends_with('─') && pair[1].starts_with('─');
4032 assert!(!continued, "a rule wrapped:\n{}", rows.join("\n"));
4033 }
4034 let first = rows
4035 .iter()
4036 .position(|r| r.starts_with("┌─ Available Venues"));
4037 let last = rows.iter().position(|r| r.starts_with('└'));
4038 let (Some(first), Some(last)) = (first, last) else {
4039 panic!("no section:\n{}", rows.join("\n"));
4040 };
4041 for row in &rows[first + 1..last] {
4042 assert!(
4043 row.starts_with('│') || row.starts_with('├'),
4044 "row lost its gutter: {row:?}\n{}",
4045 rows.join("\n")
4046 );
4047 }
4048 for row in &rows {
4049 assert!(row.width() <= 40, "{row:?}");
4050 }
4051 }
4052
4053 #[test]
4054 fn test_pane_wraps_never_reach_the_copy() {
4055 let mut s = state();
4058 let long = format!("│ {}", "word ".repeat(30).trim_end());
4059 run_pane(&mut s, &format!("{long}\n"), Ending::Finished(Ok(())));
4060 let rows = pane_rows(&s, 40);
4061 let wrapped: Vec<_> = rows.iter().filter(|r| r.contains("word")).collect();
4062 assert!(wrapped.len() > 1, "{rows:#?}");
4063 assert!(
4064 wrapped.iter().all(|r| r.starts_with("│ word")),
4065 "{rows:#?}"
4066 );
4067 let Action::Copy(text) = s.handle_key(ctrl('y')) else {
4068 panic!("expected a copy");
4069 };
4070 assert_eq!(text, format!("{long}\n"));
4071 }
4072
4073 #[test]
4074 fn test_render_records_the_pane_width() {
4075 let s = state();
4078 assert_eq!(s.pane_width(), None, "nothing drawn yet");
4079 screen(&s, 40, 20);
4080 assert_eq!(s.pane_width(), Some(40));
4081 }
4082
4083 fn alpha_state() -> TuiState {
4086 let mut s = state();
4087 s.push_ansi("alpha\nbeta\nalpha2\n");
4088 screen(&s, 80, 20);
4089 s
4090 }
4091
4092 fn open_search(s: &mut TuiState, query: &str) {
4093 s.handle_key(ctrl('f'));
4094 type_str(s, query);
4095 screen(s, 80, 20);
4096 }
4097
4098 #[test]
4099 fn test_search_goes_to_the_newest_match_and_n_and_shift_n_walk_the_matches() {
4100 let mut s = alpha_state();
4101 open_search(&mut s, "alpha");
4102 assert!(
4103 status_row(&s, 80).contains("search: alpha 1/2"),
4104 "newest first"
4105 );
4106 assert_eq!(s.input, "", "typing goes to the query, not the input");
4107 s.handle_key(key(KeyCode::Enter));
4108 s.handle_key(key(KeyCode::Char('n')));
4109 assert!(status_row(&s, 80).contains("search: alpha 2/2"));
4110 s.handle_key(key(KeyCode::Char('n')));
4111 assert!(status_row(&s, 80).contains("1/2"), "n wraps to the newest");
4112 s.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
4113 assert!(status_row(&s, 80).contains("2/2"), "N wraps to the oldest");
4114 s.handle_key(key(KeyCode::Esc));
4115 assert!(s.overlay.is_none());
4116 assert!(!status_row(&s, 80).contains("search:"));
4117 }
4118
4119 #[test]
4120 fn test_n_is_a_query_character_while_editing() {
4121 let mut s = alpha_state();
4122 open_search(&mut s, "an");
4123 assert!(status_row(&s, 80).contains("search: an"));
4124 assert!(status_row(&s, 80).contains("no match"));
4125 }
4126
4127 #[test]
4128 fn test_slash_opens_search_only_on_an_empty_input() {
4129 let mut s = alpha_state();
4130 s.handle_key(key(KeyCode::Char('/')));
4131 assert!(matches!(s.overlay, Some(Overlay::Search(_))));
4132 assert_eq!(s.input, "");
4133 s.handle_key(key(KeyCode::Esc));
4134 type_str(&mut s, "a/b");
4135 assert!(s.overlay.is_none());
4136 assert_eq!(s.input, "a/b", "a slash inside text is text");
4137 }
4138
4139 #[test]
4140 fn test_search_is_case_insensitive_and_marks_matched_rows() {
4141 let mut s = alpha_state();
4142 open_search(&mut s, "ALPHA");
4143 let text = screen(&s, 80, 20);
4144 assert!(status_row(&s, 80).contains("1/2"));
4145 assert_eq!(text.matches('▶').count(), 2, "{text}");
4146 }
4147
4148 #[test]
4149 fn test_no_match_says_so_and_n_does_nothing() {
4150 let mut s = alpha_state();
4151 open_search(&mut s, "zzz");
4152 assert!(status_row(&s, 80).contains("no match"));
4153 s.handle_key(key(KeyCode::Enter));
4154 s.handle_key(key(KeyCode::Char('n')));
4155 s.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
4156 assert!(status_row(&s, 80).contains("no match"));
4157 assert!(!screen(&s, 80, 20).contains('▶'));
4158 }
4159
4160 fn needle_state() -> TuiState {
4161 let mut s = state();
4162 s.push_ansi("needle first\n");
4163 for i in 0..40 {
4164 s.push_ansi(&format!("line {}\n", i));
4165 }
4166 screen(&s, 60, 15);
4167 s
4168 }
4169
4170 #[test]
4171 fn test_search_scrolls_to_an_old_match_and_esc_restores_the_view() {
4172 let mut s = needle_state();
4173 s.handle_key(key(KeyCode::PageUp));
4174 let before = screen(&s, 60, 15);
4175 assert!(!before.contains("needle"));
4176 s.handle_key(ctrl('f'));
4177 type_str(&mut s, "needle");
4178 let during = screen(&s, 60, 15);
4179 assert!(during.contains("needle first"), "{during}");
4180 assert!(during.contains('▶'));
4181 s.handle_key(key(KeyCode::Esc));
4182 assert_eq!(screen(&s, 60, 15), before, "the view the user had");
4183 }
4184
4185 fn long_match_state() -> TuiState {
4186 let mut s = state();
4187 s.push_ansi(&format!("needle {}\n", "word ".repeat(40)));
4188 for i in 0..30 {
4189 s.push_ansi(&format!("line {}\n", i));
4190 }
4191 screen(&s, 60, 15);
4192 s
4193 }
4194
4195 #[test]
4196 fn test_a_long_match_highlights_every_row_it_spans() {
4197 let mut s = long_match_state();
4198 s.handle_key(ctrl('f'));
4199 type_str(&mut s, "needle");
4200 let text = screen(&s, 60, 15);
4201 assert!(text.matches('▶').count() >= 2, "{text}");
4202 let mut term = Terminal::new(TestBackend::new(60, 15)).unwrap();
4204 term.draw(|f| s.render(f)).unwrap();
4205 let buf = term.backend().buffer().clone();
4206 let reversed = (0..15)
4207 .filter(|&y| buf[(5, y)].modifier.contains(Modifier::REVERSED))
4208 .count();
4209 assert!(reversed >= 2, "{text}");
4210 }
4211
4212 #[test]
4213 fn test_search_follows_a_resize() {
4214 let mut s = long_match_state();
4215 s.handle_key(ctrl('f'));
4216 type_str(&mut s, "needle");
4217 assert!(screen(&s, 60, 15).contains("needle"));
4218 assert!(
4219 screen(&s, 100, 20).contains("needle"),
4220 "visible after a resize"
4221 );
4222 assert!(screen(&s, 45, 12).contains("needle"), "and again");
4223 }
4224
4225 #[test]
4226 fn test_a_paste_goes_into_the_search_query() {
4227 let mut s = alpha_state();
4228 s.handle_key(ctrl('f'));
4229 s.paste("alpha\n");
4230 screen(&s, 80, 20);
4231 assert_eq!(s.input, "");
4232 assert!(status_row(&s, 80).contains("search: alpha 1/2"));
4233 }
4234
4235 #[test]
4236 fn test_esc_closes_the_search_before_it_cancels_a_running_command() {
4237 let _lock = super::super::DIAG_LOCK.blocking_lock();
4238 let (mut s, _out) = running_state("market summary USDC --every 2s");
4239 s.handle_key(ctrl('f'));
4240 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::None);
4241 assert!(s.overlay.is_none());
4242 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
4243 }
4244
4245 #[test]
4246 fn test_ctrl_c_still_cancels_a_running_command_with_a_search_open() {
4247 let _lock = super::super::DIAG_LOCK.blocking_lock();
4248 let (mut s, _out) = running_state("market summary USDC --every 2s");
4249 s.handle_key(ctrl('f'));
4250 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
4251 assert!(s.overlay.is_none());
4252 }
4253
4254 #[test]
4255 fn test_status_keeps_search_before_the_command_text_and_always_the_cancel_hint() {
4256 let _lock = super::super::DIAG_LOCK.blocking_lock();
4257 let (mut s, _out) = running_state("market summary USDC --every 2s");
4258 for i in 0..5 {
4259 s.push_ansi(&format!("alpha {}\n", i));
4260 }
4261 screen(&s, 140, 12);
4262 s.handle_key(ctrl('f'));
4263 type_str(&mut s, "alpha");
4264 screen(&s, 140, 12);
4265 let wide = status_row(&s, 140);
4266 assert!(
4267 wide.contains("search: alpha 1/5") && wide.contains("market summary"),
4268 "{wide}"
4269 );
4270 for w in 32..140 {
4272 let row = status_row(&s, w);
4273 assert!(row.contains("Esc closes search"), "{w}: {row:?}");
4274 if row.contains("market") {
4275 assert!(
4276 row.contains("search:"),
4277 "{w}: command without search: {row:?}"
4278 );
4279 }
4280 }
4281 let search_without_command = (26..140).any(|w| {
4282 let row = status_row(&s, w);
4283 row.contains("search:") && !row.contains("market")
4284 });
4285 assert!(search_without_command, "the command goes first");
4286 }
4287
4288 #[test]
4289 fn test_search_closes_when_the_terminal_is_too_small_and_restores_the_view() {
4290 let mut s = needle_state();
4291 s.handle_key(key(KeyCode::PageUp));
4292 let before = screen(&s, 60, 15);
4293 s.handle_key(ctrl('f'));
4294 type_str(&mut s, "needle");
4295 screen(&s, 30, 8);
4296 assert_eq!(s.handle_key(key(KeyCode::Char('x'))), Action::None);
4297 assert!(s.overlay.is_none());
4298 assert_eq!(s.input, "");
4299 assert_eq!(screen(&s, 60, 15), before);
4300 }
4301
4302 fn history_state() -> TuiState {
4303 let mut s = state();
4304 s.history = vec![
4305 "venues list".into(),
4306 "ctx".into(),
4307 "market summary USDC".into(),
4308 ];
4309 s
4310 }
4311
4312 #[test]
4313 fn test_ctrl_r_filters_the_history_and_enter_accepts_without_running() {
4314 let mut s = history_state();
4315 s.handle_key(ctrl('r'));
4316 type_str(&mut s, "ven");
4317 assert_eq!(s.input, "venues list");
4318 screen(&s, 80, 20);
4319 assert!(status_row(&s, 80).contains("1/1"), "{}", status_row(&s, 80));
4320 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
4321 assert!(s.overlay.is_none());
4322 assert_eq!(s.input, "venues list", "accepted into the input, not run");
4323 }
4324
4325 #[test]
4326 fn test_ctrl_r_again_cycles_to_older_matches() {
4327 let mut s = history_state();
4328 s.history.push("venues x".into());
4329 s.handle_key(ctrl('r'));
4330 type_str(&mut s, "ven");
4331 assert_eq!(s.input, "venues x", "most recent first");
4332 s.handle_key(ctrl('r'));
4333 assert_eq!(s.input, "venues list");
4334 s.handle_key(ctrl('r'));
4335 assert_eq!(s.input, "venues x", "wraps");
4336 }
4337
4338 #[test]
4339 fn test_esc_in_history_search_restores_the_draft() {
4340 let mut s = history_state();
4341 type_str(&mut s, "dra");
4342 s.handle_key(ctrl('r'));
4343 type_str(&mut s, "ven");
4344 assert_eq!(s.input, "venues list");
4345 s.handle_key(key(KeyCode::Esc));
4346 assert_eq!(s.input, "dra");
4347 assert!(s.overlay.is_none());
4348 }
4349
4350 #[test]
4351 fn test_history_search_with_no_match_says_so_and_keeps_the_draft() {
4352 let mut s = history_state();
4353 type_str(&mut s, "dra");
4354 s.handle_key(ctrl('r'));
4355 type_str(&mut s, "zzz");
4356 screen(&s, 80, 20);
4357 assert!(status_row(&s, 80).contains("no match"));
4358 assert_eq!(s.input, "dra");
4359 for _ in 0..3 {
4360 s.handle_key(key(KeyCode::Backspace));
4361 }
4362 assert_eq!(s.input, "dra", "an empty query matches nothing");
4363 }
4364
4365 #[test]
4366 fn test_a_paste_goes_into_the_history_query() {
4367 let mut s = history_state();
4368 s.handle_key(ctrl('r'));
4369 s.paste("market");
4370 assert_eq!(s.input, "market summary USDC");
4371 }
4372
4373 #[test]
4374 fn test_n_and_shift_n_go_in_opposite_directions_over_three_matches() {
4375 let mut s = state();
4376 s.push_ansi("alpha\nalpha2\nbeta\nalpha3\n");
4377 screen(&s, 80, 20);
4378 open_search(&mut s, "alpha");
4379 let at = |s: &TuiState| status_row(s, 80);
4380 assert!(at(&s).contains("1/3"));
4381 s.handle_key(key(KeyCode::Enter));
4382 let shift_n = KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT);
4383 s.handle_key(shift_n);
4384 assert!(
4385 at(&s).contains("3/3"),
4386 "N wraps from the newest to the oldest"
4387 );
4388 s.handle_key(key(KeyCode::Char('n')));
4389 assert!(
4390 at(&s).contains("1/3"),
4391 "n wraps from the oldest to the newest"
4392 );
4393 s.handle_key(key(KeyCode::Char('n')));
4394 assert!(at(&s).contains("2/3"));
4395 s.handle_key(shift_n);
4396 assert!(at(&s).contains("1/3"), "N from the middle goes newer");
4397 s.handle_key(key(KeyCode::Char('n')));
4398 s.handle_key(key(KeyCode::Char('n')));
4399 assert!(at(&s).contains("3/3"));
4400 s.handle_key(key(KeyCode::Char('n')));
4401 assert!(at(&s).contains("1/3"));
4402 }
4403
4404 fn fill_to_limit(s: &mut TuiState, filler_first: usize) {
4405 for i in 0..filler_first {
4406 s.push_ansi(&format!("filler {i}\n"));
4407 }
4408 s.push_ansi("needle one\n");
4409 for i in 0..100 {
4410 s.push_ansi(&format!("more {i}\n"));
4411 }
4412 s.push_ansi("needle two\n");
4413 while s.scrollback.len() < SCROLLBACK_LIMIT {
4414 s.push_ansi("tail\n");
4415 }
4416 }
4417
4418 #[test]
4419 fn test_search_keeps_its_match_when_the_scrollback_drops_old_lines() {
4420 let mut s = state();
4421 fill_to_limit(&mut s, 100);
4422 assert_eq!(s.scrollback.len(), SCROLLBACK_LIMIT);
4423 screen(&s, 80, 20);
4424 open_search(&mut s, "needle");
4425 s.handle_key(key(KeyCode::Enter));
4426 s.handle_key(key(KeyCode::Char('n')));
4427 assert!(status_row(&s, 80).contains("2/2"), "on the older match");
4428 for i in 0..50 {
4429 s.push_ansi(&format!("new {i}\n"));
4430 }
4431 let row = screen_status(&s);
4432 assert!(
4433 row.contains("search: needle 2/2"),
4434 "still the same match: {row}"
4435 );
4436 s.handle_key(key(KeyCode::Char('n')));
4437 assert!(status_row(&s, 80).contains("1/2"));
4438 s.handle_key(key(KeyCode::Char('n')));
4439 for i in 0..100 {
4440 s.push_ansi(&format!("newer {i}\n"));
4441 }
4442 let row = screen_status(&s);
4444 assert!(row.contains("needle 1/1"), "{row}");
4445 }
4446
4447 fn screen_status(s: &TuiState) -> String {
4448 screen(s, 80, 20);
4449 status_row(s, 80)
4450 }
4451
4452 #[test]
4453 fn test_search_finds_the_live_output_of_a_running_command() {
4454 let _lock = super::super::DIAG_LOCK.blocking_lock();
4455 let (mut s, out) = running_state("market summary USDC --every 2s");
4456 s.push_ansi("needle done\n");
4457 out.write_out(format_args!("needle live\nother\n")).unwrap();
4458 screen(&s, 80, 20);
4459 open_search(&mut s, "needle");
4460 let row = status_row(&s, 140);
4461 assert!(row.contains("search: needle 1/2"), "{row}");
4462 assert!(screen(&s, 80, 20).matches('▶').count() == 2);
4463 }
4464
4465 #[test]
4466 fn test_opening_a_search_keeps_the_top_of_the_view_where_it_was() {
4467 let mut s = state();
4470 for i in 0..40 {
4471 s.push_ansi(&format!("r{i:02} {}\n", "x".repeat(55)));
4472 }
4473 screen(&s, 60, 15);
4474 s.handle_key(key(KeyCode::PageUp));
4475 s.handle_key(key(KeyCode::PageUp));
4476 let top_label = |text: &str| text.lines().nth(1).unwrap()[..3].trim().to_string();
4477 let before = top_label(&screen(&s, 60, 15));
4478 s.handle_key(ctrl('f'));
4479 let during = screen(&s, 60, 15);
4480 assert!(
4481 during.lines().nth(1).unwrap().contains(&before),
4482 "{before} stays at the top: {during}"
4483 );
4484 s.handle_key(key(KeyCode::Esc));
4485 assert_eq!(top_label(&screen(&s, 60, 15)), before);
4486 }
4487
4488 #[test]
4489 fn test_the_status_does_not_offer_pgdn_while_a_search_is_open() {
4490 let mut s = scrollable();
4491 s.handle_key(key(KeyCode::PageUp));
4492 assert!(status_row(&s, 80).contains("PgDn"));
4493 s.handle_key(ctrl('f'));
4494 screen(&s, 60, 15);
4495 let row = status_row(&s, 80);
4496 assert!(row.contains("scrolled up"), "{row}");
4497 assert!(!row.contains("PgDn"), "{row}");
4498 }
4499
4500 #[test]
4501 fn test_search_matches_the_text_of_colored_output_not_its_codes() {
4502 let mut s = state();
4503 s.push_ansi("al\x1b[31mpha\x1b[0m\n");
4504 screen(&s, 80, 20);
4505 open_search(&mut s, "alpha");
4506 assert!(status_row(&s, 80).contains("1/1"));
4507 s.handle_key(key(KeyCode::Esc));
4508 open_search(&mut s, "31m");
4509 assert!(status_row(&s, 80).contains("no match"));
4510 }
4511
4512 fn buffer(s: &TuiState, w: u16, h: u16) -> ratatui::buffer::Buffer {
4515 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
4516 term.draw(|f| s.render(f)).unwrap();
4517 term.backend().buffer().clone()
4518 }
4519
4520 fn row_text(buf: &ratatui::buffer::Buffer, y: u16) -> String {
4521 (0..buf.area.width)
4522 .map(|x| buf[(x, y)].symbol().to_string())
4523 .collect()
4524 }
4525
4526 fn rule_fg(buf: &ratatui::buffer::Buffer) -> Color {
4528 let x = (0..buf.area.width)
4529 .find(|&x| buf[(x, 0)].symbol() == "─")
4530 .expect("a rule cell");
4531 buf[(x, 0)].fg
4532 }
4533
4534 fn failed_state() -> TuiState {
4535 let mut s = state();
4536 s.start("x", &pane_invocation());
4537 s.finish(Ending::Finished(Err(scope::error::ScopeError::Chain(
4538 "boom".into(),
4539 ))));
4540 s
4541 }
4542
4543 #[test]
4544 fn test_pane_rule_is_accent_idle_warn_running_and_err_after_a_failure() {
4545 let idle = state();
4547 assert_eq!(rule_fg(&buffer(&idle, 60, 14)), Color::Cyan);
4548 let (running, _out) = running_state("venues list");
4549 assert_eq!(rule_fg(&buffer(&running, 60, 14)), Color::Yellow);
4550 let failed = failed_state();
4551 assert_eq!(rule_fg(&buffer(&failed, 60, 14)), Color::Red);
4552 }
4553
4554 #[test]
4555 fn test_failed_rule_lasts_until_the_next_command_starts() {
4556 let mut s = failed_state();
4557 assert_eq!(s.pane_state(), PaneState::Failed);
4558 s.start("venues list", &pane_invocation());
4559 assert_eq!(s.pane_state(), PaneState::Running);
4560 s.finish(Ending::Finished(Ok(())));
4561 assert_eq!(s.pane_state(), PaneState::Idle);
4562 }
4563
4564 #[test]
4565 fn test_cancelled_run_leaves_the_rule_idle() {
4566 let mut s = state();
4567 run_pane(&mut s, "x\n", Ending::Cancelled);
4568 assert_eq!(s.pane_state(), PaneState::Idle);
4569 }
4570
4571 #[test]
4572 fn test_state_word_is_at_the_right_end_of_the_top_rule_and_title_stays_left() {
4573 let buf = buffer(&state(), 60, 14);
4574 let top = row_text(&buf, 0);
4575 let title = format!(" scope v{} ", scope::VERSION);
4576 assert!(top.starts_with(&title), "{top:?}");
4577 assert!(top.trim_end().ends_with(" idle"), "{top:?}");
4578 assert!(buf[(1, 0)].modifier.contains(Modifier::BOLD), "title bold");
4579 assert_eq!(buf[(1, 0)].fg, Color::Cyan);
4580 }
4581
4582 #[test]
4583 fn test_prompt_spans_scope_accent_chain_key_rest_default() {
4584 let mut s = state();
4585 s.ctx.chain = "base".into();
4586 let prompt = s.prompt();
4587 assert_eq!(prompt.to_string(), "scope:base> ");
4588 assert_eq!(prompt.spans[0].style, ACCENT);
4589 assert_eq!(prompt.spans[1].style, KEY);
4590 assert_eq!(prompt.spans[2].style, Style::new());
4591 s.push_echo("venues list");
4592 let echo = last_line(&s);
4593 assert_eq!(echo.spans[0].style, ACCENT);
4594 assert_eq!(echo.spans[1].style, KEY);
4595 assert_eq!(echo.spans[3].content, "venues list");
4596 assert_eq!(echo.spans[3].style, Style::new());
4597 }
4598
4599 #[test]
4600 fn test_notes_carry_an_info_glyph_and_dim_text() {
4601 let mut s = state();
4602 s.push_note("hello");
4603 let note = last_line(&s);
4604 assert_eq!(note.to_string(), "ℹ hello");
4605 assert_eq!(note.spans[0].style, INFO);
4606 assert_eq!(note.spans[1].style, SECONDARY);
4607 }
4608
4609 #[test]
4610 fn test_selected_palette_row_is_reversed_cyan() {
4611 let mut s = state();
4612 s.handle_key(ctrl_k());
4613 let buf = buffer(&s, 100, 24);
4614 let selected = buf.content.iter().any(|c| {
4615 c.fg == Color::Cyan && c.modifier.contains(Modifier::REVERSED) && c.symbol() != " "
4616 });
4617 assert!(selected, "a reversed cyan palette row");
4618 let names = buf
4620 .content
4621 .iter()
4622 .any(|c| c.fg == Color::Cyan && !c.modifier.contains(Modifier::REVERSED));
4623 assert!(names);
4624 }
4625
4626 #[test]
4627 fn test_search_match_is_reversed_yellow() {
4628 let mut s = alpha_state();
4629 open_search(&mut s, "alpha");
4630 let buf = buffer(&s, 80, 20);
4631 let hits = buf
4632 .content
4633 .iter()
4634 .filter(|c| c.fg == Color::Yellow && c.modifier.contains(Modifier::REVERSED))
4635 .count();
4636 assert!(hits >= 2 * "alpha".len(), "{hits} matched cells");
4637 }
4638
4639 #[test]
4640 fn test_status_values_are_accent_and_labels_secondary() {
4641 let line = fit_status(&state().status_parts(), 100);
4642 let find = |text: &str| {
4643 line.spans
4644 .iter()
4645 .find(|sp| sp.content.contains(text))
4646 .unwrap_or_else(|| panic!("no span with {text:?}"))
4647 };
4648 assert_eq!(find("chain").style, SECONDARY);
4649 assert_eq!(find("auto").style, ACCENT);
4650 assert_eq!(find("format").style, SECONDARY);
4651 assert_eq!(find("help").style, ACCENT);
4652 }
4653
4654 #[test]
4655 fn test_running_status_is_warn_and_esc_cancels_is_bold() {
4656 let (s, _out) = running_state("venues list");
4657 let line = fit_status(&s.status_parts(), 120);
4658 let find = |text: &str| {
4659 line.spans
4660 .iter()
4661 .find(|sp| sp.content.contains(text))
4662 .unwrap_or_else(|| panic!("no span with {text:?}"))
4663 };
4664 assert_eq!(find("running").style, WARN);
4665 assert_eq!(
4666 find("Esc cancels").style,
4667 Style::new().add_modifier(Modifier::BOLD)
4668 );
4669 assert_eq!(find("`venues list`").style, ACCENT);
4670 }
4671
4672 #[test]
4673 fn test_hint_next_argument_is_a_key_and_busy_note_is_warn() {
4674 let mut s = state();
4675 type_str(&mut s, "address");
4676 let buf = buffer(&s, 100, 14);
4677 let hint_row = 14 - 2;
4678 let next = (0..100u16)
4679 .rfind(|&x| buf[(x, hint_row)].symbol() == "<")
4680 .unwrap();
4681 assert_eq!(buf[(next, hint_row)].fg, Color::Magenta);
4682 assert!(buf[(next, hint_row)].modifier.contains(Modifier::BOLD));
4683 let (mut busy, _out) = running_state("venues list");
4684 busy.busy_hint = true;
4685 let buf = buffer(&busy, 100, 14);
4686 let x = (0..100u16)
4687 .find(|&x| buf[(x, hint_row)].symbol() == "A")
4688 .unwrap();
4689 assert_eq!(buf[(x, hint_row)].fg, Color::Yellow);
4690 }
4691
4692 #[test]
4693 fn test_with_color_off_every_state_still_differs_by_text() {
4694 let strip = |s: &TuiState| {
4696 let mut buf = buffer(s, 60, 14);
4697 crate::cli::plain::strip_color(&mut buf);
4698 assert!(buf.content.iter().all(|c| c.fg == Color::Reset));
4699 buf
4700 };
4701 let idle = row_text(&strip(&state()), 0);
4702 let (running, _out) = running_state("venues list");
4703 let running = row_text(&strip(&running), 0);
4704 let failed = row_text(&strip(&failed_state()), 0);
4705 assert!(idle.trim_end().ends_with(" idle"), "{idle:?}");
4706 assert!(running.trim_end().ends_with(" running"), "{running:?}");
4707 assert!(failed.trim_end().ends_with(" failed"), "{failed:?}");
4708 let mut s = state();
4710 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4711 assert!(last_line(&s).to_string().starts_with("✓ Done in "));
4712 let failed = failed_state();
4713 assert!(last_line(&failed).to_string().starts_with("✗ Failed in "));
4714 assert!(
4716 last_line(&failed)
4717 .style
4718 .add_modifier
4719 .contains(Modifier::BOLD)
4720 );
4721 let buf = strip(&failed);
4722 assert!(
4723 buf.content
4724 .iter()
4725 .any(|c| c.symbol() == "✗" && c.modifier.contains(Modifier::BOLD))
4726 );
4727 }
4728
4729 #[test]
4730 fn test_ctrl_k_and_tab_open_no_overlay_while_the_terminal_is_too_small() {
4731 let mut s = state();
4733 type_str(&mut s, "m");
4734 screen(&s, 80, 24);
4735 s.handle_key(key(KeyCode::Tab));
4736 assert!(
4737 matches!(s.overlay, Some(Overlay::Menu(_))),
4738 "Tab offers a menu at a normal size"
4739 );
4740 s.handle_key(key(KeyCode::Esc));
4741 screen(&s, 30, 8);
4742 s.handle_key(key(KeyCode::Tab));
4743 assert!(s.overlay.is_none());
4744 assert_eq!(s.input, "m", "Tab did not complete unseen");
4745 s.handle_key(ctrl('k'));
4746 assert!(s.overlay.is_none());
4747 }
4748
4749 #[test]
4750 fn test_status_styles_come_from_the_parts_not_from_their_text() {
4751 let mut s = alpha_state();
4754 s.push_ansi("x · running\n");
4755 open_search(&mut s, "x · running");
4756 let line = fit_status(&s.status_parts(), 120);
4757 let at = |text: &str| {
4758 line.spans
4759 .iter()
4760 .position(|sp| sp.content.contains(text))
4761 .unwrap_or_else(|| panic!("no {text:?} in {line:?}"))
4762 };
4763 for sp in &line.spans[at("search:")..=at("1/1")] {
4764 assert_eq!(sp.style, ACCENT, "{sp:?} in {line:?}");
4765 }
4766 let (run, _out) = running_state("venues list");
4769 let line = fit_status(&run.status_parts(), 120);
4770 let style_of = |text: &str| {
4771 line.spans
4772 .iter()
4773 .find(|sp| sp.content == text)
4774 .unwrap_or_else(|| panic!("no span {text:?} in {line:?}"))
4775 .style
4776 };
4777 assert_eq!(
4778 style_of("Esc cancels"),
4779 Style::new().add_modifier(Modifier::BOLD)
4780 );
4781 assert_eq!(style_of(STATUS_SEP), SECONDARY);
4782 assert_eq!(style_of("`venues list`"), ACCENT);
4783 }
4784
4785 #[test]
4786 fn test_help_shows_key_names_in_the_key_style() {
4787 let mut s = state();
4788 assert!(matches!(s.submit("help", &Config::default()), Plan::Done));
4789 let span = s
4790 .scrollback
4791 .iter()
4792 .flat_map(|l| l.spans.iter())
4793 .find(|sp| sp.content == "Ctrl-K")
4794 .expect("help names Ctrl-K");
4795 assert_eq!(span.style.fg, Some(Color::Magenta));
4796 assert!(span.style.add_modifier.contains(Modifier::BOLD));
4797 assert_eq!(span.style, Style::reset().patch(KEY));
4800 let what = s
4801 .scrollback
4802 .iter()
4803 .flat_map(|l| l.spans.iter())
4804 .find(|sp| sp.content == "palette")
4805 .expect("help says what Ctrl-K does");
4806 assert_eq!(what.style, Style::reset().patch(SECONDARY));
4807 }
4808
4809 #[test]
4810 fn test_any_accepted_line_clears_the_failed_state() {
4811 for line in ["ctx", "format json", "setup"] {
4814 let mut s = failed_state();
4815 assert_eq!(s.pane_state(), PaneState::Failed);
4816 s.submit(line, &Config::default());
4817 assert_eq!(s.pane_state(), PaneState::Idle, "{line}");
4818 }
4819 let mut s = failed_state();
4821 s.submit(" ", &Config::default());
4822 assert_eq!(s.pane_state(), PaneState::Failed);
4823 }
4824}