1use super::clip::{self, SaveError};
9use super::exec::{self, Invocation, Plan, Route};
10use super::fit;
11use super::grid::{self, GridAction, GridMsg, GridState};
12use super::journal::{Event, Journal};
13use super::market_view;
14use super::repeat_view::{self, Ended, RepeatView};
15use super::vocab::Vocab;
16use super::wizard::{self, WizardAction, WizardState};
17use crate::cli::interactive::SessionContext;
18use crate::cli::output::{Captured, Output, SeriesHook};
19use crate::cli::textfit::{ELLIPSIS, cols, cut_with_ellipsis, draw_note_alone};
20use crate::cli::theme::{ACCENT, ERR, INFO, KEY, MATCH, OK, PaneState, SECONDARY, SELECTED, WARN};
21use ansi_to_tui::IntoText;
22use chrono::Utc;
23use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
24use ratatui::Frame;
25use ratatui::layout::{Constraint, Layout, Position};
26use ratatui::style::{Modifier, Style};
27use ratatui::text::{Line, Span, Text};
28use ratatui::widgets::{Block, Borders, Paragraph};
29use scope::config::Config;
30use scope::display::theme::color_enabled;
31use std::cell::Cell;
32use std::time::Instant;
33use unicode_width::UnicodeWidthStr;
34
35pub const SCROLLBACK_LIMIT: usize = 5000;
37
38pub const MIN_W: u16 = 40;
40pub const MIN_H: u16 = 10;
42
43fn too_small_note() -> String {
46 format!("Terminal too small: need at least {MIN_W}×{MIN_H}")
47}
48
49pub const PASTE_LIMIT: usize = 10_000;
51
52const BIDI_CONTROLS: [std::ops::RangeInclusive<char>; 2] =
54 ['\u{202A}'..='\u{202E}', '\u{2066}'..='\u{2069}'];
55
56pub const BUSY_NOTE: &str = "A command is running; Esc or Ctrl-C cancels it first.";
58
59const MENU_ROWS: usize = 8;
61
62pub enum Overlay {
64 Menu(Menu),
66 Palette(super::palette::PaletteState),
68 Search(SearchState),
70 HistorySearch(HistorySearchState),
72 Choice(ChoiceState),
74}
75
76pub struct ChoiceState {
78 prompt: String,
79 options: Vec<String>,
80 selected: usize,
81 reply: Option<tokio::sync::oneshot::Sender<Option<usize>>>,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87enum EscAction {
88 ClosePalette,
89 CloseMenu,
90 AnswerChoice,
92 CloseSearch,
93 CloseHistorySearch,
94 Cancel,
96 ClearLine,
98}
99
100impl EscAction {
101 fn hint(self) -> Option<&'static str> {
104 match self {
105 Self::ClosePalette => Some("Esc closes palette"),
106 Self::CloseMenu => Some("Esc closes menu"),
107 Self::AnswerChoice => Some("↑↓ Enter chooses · Esc cancels"),
108 Self::CloseSearch => Some("Esc closes search"),
109 Self::CloseHistorySearch => Some("Esc closes history search"),
110 Self::Cancel => Some("Esc cancels"),
111 Self::ClearLine => None,
112 }
113 }
114}
115
116const NO_MATCH: &str = "no match";
118
119const NOTE_GLYPH: &str = "ℹ";
121
122const MATCH_MARK: &str = "▶ ";
124
125const MATCH_BLANK: &str = " ";
127
128struct SearchView {
130 matches: Vec<usize>,
131 current: Option<usize>,
132 text: String,
133}
134
135pub struct SearchState {
137 pub query: String,
139 editing: bool,
141 current: Option<usize>,
143 saved_scroll: usize,
145}
146
147pub struct HistorySearchState {
149 pub query: String,
151 current: Option<usize>,
153 draft: String,
155}
156
157pub struct Menu {
159 pub start: usize,
161 pub candidates: Vec<String>,
163 pub selected: usize,
165}
166
167fn line_feed_as_enter(key: KeyEvent) -> KeyEvent {
171 let ctrl_j = key.code == KeyCode::Char('j') && key.modifiers.contains(KeyModifiers::CONTROL);
172 if ctrl_j || key.code == KeyCode::Char('\n') {
173 KeyEvent {
174 code: KeyCode::Enter,
175 modifiers: KeyModifiers::NONE,
176 ..key
177 }
178 } else {
179 key
180 }
181}
182
183fn common_prefix(items: &[String]) -> String {
184 let first = &items[0];
185 let mut end = first.len();
186 for s in &items[1..] {
187 end = end.min(
188 first
189 .bytes()
190 .zip(s.bytes())
191 .take_while(|(a, b)| a == b)
192 .count(),
193 );
194 }
195 while !first.is_char_boundary(end) {
196 end -= 1;
197 }
198 first[..end].to_string()
199}
200
201#[derive(Debug, PartialEq)]
203pub enum Action {
204 None,
206 Submit(String),
208 Cancel,
210 Exit,
212 Suspend,
214 Copy(String),
217 StopWatch,
219 StartWatch(Box<scope::watch::Watchlist>),
221}
222
223fn alt_key(key: &KeyEvent) -> bool {
225 key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::CONTROL)
226}
227
228const NO_GRID_NOTE: &str =
230 "No watchlist runs. Start one with `watch open <name>` (`watch list` lists them).";
231
232const PALETTE_MARKER: &str = "> ";
234
235const PALETTE_LEGEND: &str = "↑↓ select · Enter puts it on the line (Enter again runs) · Esc close";
238
239pub struct Running {
241 pub input: String,
243 pub argv: Vec<String>,
245 pub started: Instant,
247 pub started_at: String,
249 pub captured: Captured,
251 out: Output,
253 series: Option<SeriesHook>,
255 _diag: scope::diag::RedirectGuard,
257}
258
259pub enum Ending {
261 Finished(scope::error::Result<()>),
263 Cancelled,
265}
266
267impl Ending {
268 fn outcome(&self) -> (&'static str, Option<String>) {
271 match self {
272 Ending::Finished(Ok(())) => ("ok", None),
273 Ending::Finished(Err(e)) => ("error", Some(e.to_string())),
274 Ending::Cancelled => ("cancelled", None),
275 }
276 }
277}
278
279pub struct TuiState {
281 pub input: String,
283 cursor: usize,
285 pub history: Vec<String>,
287 hist_pos: Option<usize>,
289 draft: String,
291 scrollback: Vec<Line<'static>>,
293 scroll: Cell<usize>,
295 pin_top: Cell<Option<usize>>,
299 too_small: Cell<bool>,
301 last_max_top: Cell<usize>,
303 last_pane_height: Cell<usize>,
306 pub ctx: SessionContext,
308 pub running: Option<Running>,
310 failed: bool,
313 scroll_x: Cell<usize>,
316 pane_width: Cell<u16>,
319 busy_hint: bool,
321 dropped_lines: usize,
323 cleared: Option<String>,
326 last_output: Option<String>,
329 repeat_view: Option<RepeatView>,
331 last_series: Option<SeriesHook>,
333 journal: Journal,
335 vocab: Vocab,
337 pub overlay: Option<Overlay>,
339 pub(crate) asker: crate::cli::output::Asker,
341 reveal: Cell<bool>,
344 search_width: Cell<usize>,
347 pub grid: Option<GridState>,
350 pub grid_shown: bool,
352 grid_rx: Option<std::sync::mpsc::Receiver<GridMsg>>,
354 grid_out: Option<(Output, Captured)>,
356 grid_started: Option<Instant>,
358 pub wizard: Option<WizardState>,
360 pub start: Option<crate::cli::Commands>,
363}
364
365impl TuiState {
366 pub fn new(ctx: SessionContext, journal: Journal, vocab: Vocab) -> Self {
368 let history = journal.load_history();
369 Self {
370 input: String::new(),
371 cursor: 0,
372 history,
373 hist_pos: None,
374 draft: String::new(),
375 scrollback: Vec::new(),
376 scroll: Cell::new(0),
377 pin_top: Cell::new(None),
378 too_small: Cell::new(false),
379 last_max_top: Cell::new(0),
380 last_pane_height: Cell::new(0),
381 ctx,
382 running: None,
383 failed: false,
384 scroll_x: Cell::new(0),
385 pane_width: Cell::new(0),
386 busy_hint: false,
387 last_output: None,
388 dropped_lines: 0,
389 cleared: None,
390 repeat_view: None,
391 last_series: None,
392 journal,
393 vocab,
394 overlay: None,
395 asker: crate::cli::output::Asker::default(),
396 reveal: Cell::new(false),
397 search_width: Cell::new(0),
398 grid: None,
399 grid_shown: false,
400 grid_rx: None,
401 grid_out: None,
402 grid_started: None,
403 wizard: None,
404 start: None,
405 }
406 }
407
408 pub fn journal(&self) -> &Journal {
410 &self.journal
411 }
412
413 pub fn set_vocab(&mut self, vocab: Vocab) {
415 self.vocab = vocab;
416 }
417
418 pub fn handle_key(&mut self, key: KeyEvent) -> Action {
422 let key = line_feed_as_enter(key);
423 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
424 self.busy_hint = false;
425 if ctrl && key.code == KeyCode::Char('z') {
428 return Action::Suspend;
429 }
430 if let Some(Overlay::Choice(ch)) = &mut self.overlay {
434 let n = ch.options.len().max(1);
435 match key.code {
436 KeyCode::Esc => return self.press_esc(),
437 KeyCode::Char('c') if ctrl => return Action::Cancel,
438 _ if self.too_small.get() => {}
439 KeyCode::Down | KeyCode::Tab => ch.selected = (ch.selected + 1) % n,
440 KeyCode::Up | KeyCode::BackTab => ch.selected = (ch.selected + n - 1) % n,
441 KeyCode::Enter => {
442 let pick = ch.selected;
443 self.answer_choice(Some(pick));
444 }
445 _ => {}
446 }
447 return Action::None;
448 }
449 if self.wizard.is_some() && !self.too_small.get() {
451 return self.wizard_key(key);
452 }
453 if ctrl && key.code == KeyCode::Char('g') {
455 if self.grid.is_some() {
456 self.grid_shown = !self.grid_shown;
457 } else {
458 self.push_note(NO_GRID_NOTE);
459 }
460 return Action::None;
461 }
462 if self.grid_shown && !self.too_small.get() {
463 return self.grid_key(key);
464 }
465 if self.running.is_none()
468 && let Some(view) = self.repeat_view.take()
469 {
470 self.leave_repeat_view(view);
471 }
472 if ctrl
476 && key.code == KeyCode::Char('c')
477 && matches!(
478 self.overlay,
479 Some(Overlay::Palette(_) | Overlay::Search(_) | Overlay::HistorySearch(_))
480 )
481 {
482 self.close_overlay();
483 }
484 if self.too_small.get() && self.overlay.is_some() {
487 self.close_overlay();
488 return Action::None;
489 }
490 if key.code == KeyCode::Esc {
491 return self.press_esc();
492 }
493 match self.overlay.take() {
494 Some(Overlay::Search(st)) => {
495 self.search_key(st, key);
496 return Action::None;
497 }
498 Some(Overlay::HistorySearch(st)) => {
499 self.history_search_key(st, key);
500 return Action::None;
501 }
502 other => self.overlay = other,
503 }
504 if let Some(Overlay::Palette(p)) = &mut self.overlay {
505 let count = p.matches().len();
506 match key.code {
507 KeyCode::Enter => {
508 let fill = p
509 .matches()
510 .get(p.selected)
511 .map(|&i| p.items[i].fill.clone());
512 self.overlay = None;
513 if let Some(fill) = fill {
514 self.set_input(fill);
515 }
516 }
517 KeyCode::Down | KeyCode::Tab if count > 0 => p.selected = (p.selected + 1) % count,
518 KeyCode::Up | KeyCode::BackTab if count > 0 => {
519 p.selected = (p.selected + count - 1) % count
520 }
521 KeyCode::Backspace => {
522 p.query.pop();
523 p.selected = 0;
524 }
525 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
526 p.query.push(c);
527 p.selected = 0;
528 }
529 _ => {}
530 }
531 return Action::None;
532 }
533 if let Some(Overlay::Menu(menu)) = &mut self.overlay {
534 match key.code {
535 KeyCode::Tab | KeyCode::Down => {
536 menu.selected = (menu.selected + 1) % menu.candidates.len();
537 return Action::None;
538 }
539 KeyCode::BackTab | KeyCode::Up => {
540 menu.selected =
541 (menu.selected + menu.candidates.len() - 1) % menu.candidates.len();
542 return Action::None;
543 }
544 KeyCode::Enter => {
545 let (start, pick) = (menu.start, menu.candidates[menu.selected].clone());
546 self.overlay = None;
547 self.replace_word(start, &pick, true);
548 return Action::None;
549 }
550 _ => self.overlay = None,
551 }
552 }
553 match key.code {
554 KeyCode::Char('c') if ctrl => {
555 if self.running.is_some() {
556 return Action::Cancel;
557 }
558 if self.input.is_empty() {
559 self.push_note("Type `exit` or press Ctrl-D to leave.");
560 } else {
561 self.set_input(String::new());
562 }
563 }
564 KeyCode::Char('y') if ctrl => return self.copy_key(),
565 KeyCode::Char('d') if ctrl => {
566 if self.running.is_some() {
567 self.busy_hint = true;
568 self.push_busy_note();
569 } else if self.input.is_empty() {
570 return Action::Exit;
571 }
572 }
573 KeyCode::Enter => {
574 if self.too_small.get() {
576 return Action::None;
577 }
578 if self.running.is_some() {
580 if !self.input.trim().is_empty() {
581 self.busy_hint = true;
582 if self.input_is_exit() {
583 self.push_busy_note();
584 }
585 }
586 return Action::None;
587 }
588 let line = std::mem::take(&mut self.input);
589 self.cursor = 0;
590 self.hist_pos = None;
591 self.scroll.set(0);
592 if !line.trim().is_empty() && self.history.last() != Some(&line) {
593 self.history.push(line.clone());
594 }
595 return Action::Submit(line);
596 }
597 KeyCode::Tab if !self.too_small.get() => self.tab_complete(),
600 KeyCode::Up if self.input.is_empty() && self.cleared.is_some() => {
601 if let Some(line) = self.cleared.take() {
602 self.set_input(line);
603 }
604 }
605 KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => self.scroll_lines(1),
607 KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => self.scroll_lines(-1),
608 KeyCode::Up => self.history_prev(),
609 KeyCode::Down => self.history_next(),
610 KeyCode::PageUp => {
611 let max = self.last_max_top.get();
612 self.scroll.set(
613 self.scroll
614 .get()
615 .min(max)
616 .saturating_add(self.page())
617 .min(max),
618 );
619 }
620 KeyCode::PageDown => {
621 self.scroll.set(
622 self.scroll
623 .get()
624 .min(self.last_max_top.get())
625 .saturating_sub(self.page()),
626 );
627 }
628 KeyCode::Home if ctrl => self.scroll.set(self.last_max_top.get()),
629 KeyCode::End if ctrl => self.scroll.set(0),
630 KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
631 KeyCode::Right => self.cursor = (self.cursor + 1).min(self.char_len()),
632 KeyCode::Home => self.cursor = 0,
633 KeyCode::End if self.input.is_empty() => self.scroll.set(0),
636 KeyCode::End => self.cursor = self.char_len(),
637 KeyCode::Char('f') if ctrl && !self.too_small.get() => self.open_search(),
638 KeyCode::Char('/')
639 if !ctrl
640 && !key.modifiers.contains(KeyModifiers::ALT)
641 && self.input.is_empty()
642 && !self.too_small.get() =>
643 {
644 self.open_search()
645 }
646 KeyCode::Char('r') if ctrl && !self.too_small.get() => self.open_history_search(),
647 KeyCode::Char('k') if ctrl && !self.too_small.get() => {
648 self.overlay = Some(Overlay::Palette(super::palette::PaletteState::new(
649 super::palette::items(&self.history),
650 )));
651 }
652 KeyCode::Char('a') if ctrl => self.cursor = 0,
653 KeyCode::Char('b') if ctrl => self.cursor = self.cursor.saturating_sub(1),
657 KeyCode::Char('b') if alt_key(&key) => self.cursor = self.word_left(),
658 KeyCode::Char('f') if alt_key(&key) => self.cursor = self.word_right(),
659 KeyCode::Char('k') if alt_key(&key) => {
660 let kept: String = self.input.chars().take(self.cursor).collect();
661 let cut: String = self.input.chars().skip(self.cursor).collect();
662 self.keep_cleared(cut);
663 self.input = kept;
664 }
665 KeyCode::Char('e') if ctrl => self.cursor = self.char_len(),
666 KeyCode::Char('u') if ctrl => {
667 let cut: String = self.input.chars().take(self.cursor).collect();
668 let rest: String = self.input.chars().skip(self.cursor).collect();
669 self.keep_cleared(cut);
670 self.input = rest;
671 self.cursor = 0;
672 }
673 KeyCode::Char('w') if ctrl => self.delete_word(),
674 KeyCode::Backspace => {
675 self.scroll.set(0);
676 if self.cursor > 0 {
677 self.cursor -= 1;
678 self.remove_char(self.cursor);
679 }
680 }
681 KeyCode::Delete => {
682 self.scroll.set(0);
683 if self.cursor < self.char_len() {
684 self.remove_char(self.cursor);
685 }
686 }
687 KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => {
688 self.scroll.set(0);
689 let at = self.byte_index(self.cursor);
690 self.input.insert(at, c);
691 self.cursor += 1;
692 }
693 _ => {}
694 }
695 Action::None
696 }
697
698 pub fn paste(&mut self, text: &str) {
703 let unified = text.replace("\r\n", "\n").replace('\r', "\n");
704 let body = unified.strip_suffix('\n').unwrap_or(&unified);
705 let lines = body.split('\n').count();
706 let mut clean: String = body
707 .replace('\n', " ")
708 .chars()
709 .map(|c| if c == '\t' { ' ' } else { c })
710 .filter(|c| !c.is_control() && !BIDI_CONTROLS.iter().any(|r| r.contains(c)))
711 .collect();
712 if clean.is_empty() || matches!(self.overlay, Some(Overlay::Choice(_))) {
715 return;
716 }
717 if !matches!(
719 self.overlay,
720 Some(Overlay::Search(_) | Overlay::HistorySearch(_))
721 ) {
722 self.scroll.set(0);
723 }
724 let truncated = clean.chars().count() > PASTE_LIMIT;
725 if truncated {
726 clean = clean.chars().take(PASTE_LIMIT).collect();
727 }
728 match self.overlay.take() {
729 Some(Overlay::Search(st)) => self.search_type(st, &clean),
730 Some(Overlay::HistorySearch(st)) => self.history_search_type(st, &clean),
731 Some(Overlay::Palette(mut p)) => {
733 p.query.push_str(&clean);
734 p.selected = 0;
735 self.overlay = Some(Overlay::Palette(p));
736 }
737 _ => {
738 let at = self.byte_index(self.cursor);
739 self.input.insert_str(at, &clean);
740 self.cursor += clean.chars().count();
741 }
742 }
743 if lines > 1 {
744 self.push_note(&format!("pasted {} lines as one line", lines));
745 }
746 if truncated {
747 self.push_note(&format!("paste truncated to {} characters", PASTE_LIMIT));
748 }
749 }
750
751 fn close_overlay(&mut self) {
756 match self.overlay.take() {
757 Some(Overlay::Search(st)) => self.scroll.set(st.saved_scroll),
758 Some(Overlay::HistorySearch(st)) => self.set_input(st.draft),
759 _ => {}
760 }
761 }
762
763 fn open_search(&mut self) {
764 self.overlay = Some(Overlay::Search(SearchState {
765 query: String::new(),
766 editing: true,
767 current: None,
768 saved_scroll: self.scroll.get().min(self.last_max_top.get()),
769 }));
770 }
771
772 fn open_history_search(&mut self) {
773 self.hist_pos = None;
774 self.overlay = Some(Overlay::HistorySearch(HistorySearchState {
775 query: String::new(),
776 current: None,
777 draft: self.input.clone(),
778 }));
779 }
780
781 fn live_output(&self) -> Vec<Line<'static>> {
784 self.running
785 .as_ref()
786 .and_then(|run| run.captured.out().into_text().ok())
787 .map(|t| t.lines)
788 .unwrap_or_default()
789 }
790
791 fn search_matches(&self, query: &str) -> Vec<usize> {
794 self.matches_in(query, &self.live_output())
795 }
796
797 fn matches_in(&self, query: &str, live: &[Line<'static>]) -> Vec<usize> {
798 if query.is_empty() {
799 return Vec::new();
800 }
801 let needle = query.to_lowercase();
802 self.scrollback
803 .iter()
804 .chain(live)
805 .enumerate()
806 .filter(|(_, line)| line.to_string().to_lowercase().contains(&needle))
807 .map(|(i, _)| i)
808 .collect()
809 }
810
811 fn history_matches(&self, query: &str) -> Vec<usize> {
813 if query.is_empty() {
814 return Vec::new();
815 }
816 let needle = query.to_lowercase();
817 self.history
818 .iter()
819 .enumerate()
820 .filter(|(_, line)| line.to_lowercase().contains(&needle))
821 .map(|(i, _)| i)
822 .collect()
823 }
824
825 fn rank(matches: &[usize], current: Option<usize>) -> Option<(usize, usize)> {
828 let at = matches.binary_search(¤t?).ok()?;
829 Some((matches.len() - at, matches.len()))
830 }
831
832 fn step(matches: &[usize], current: Option<usize>, older: bool) -> Option<usize> {
834 let last = matches.len().checked_sub(1)?;
835 let at = current.and_then(|c| matches.binary_search(&c).ok());
836 let next = match (at, older) {
837 (None, _) => last,
838 (Some(0), true) => last,
839 (Some(a), true) => a - 1,
840 (Some(a), false) if a == last => 0,
841 (Some(a), false) => a + 1,
842 };
843 Some(matches[next])
844 }
845
846 fn search_type(&mut self, mut st: SearchState, text: &str) {
848 st.editing = true;
849 st.query.push_str(text);
850 self.search_refind(&mut st);
851 self.overlay = Some(Overlay::Search(st));
852 }
853
854 fn search_refind(&self, st: &mut SearchState) {
855 st.current = self.search_matches(&st.query).last().copied();
856 self.reveal.set(st.current.is_some());
857 }
858
859 fn search_key(&mut self, mut st: SearchState, key: KeyEvent) {
860 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
861 let alt = key.modifiers.contains(KeyModifiers::ALT);
862 let walk = |s: &mut Self, st: &mut SearchState, older: bool| {
863 let found = Self::step(&s.search_matches(&st.query), st.current, older);
864 if found.is_some() {
865 st.current = found;
866 s.reveal.set(true);
867 }
868 };
869 match key.code {
870 KeyCode::Char('f') if ctrl => st.editing = !st.editing,
871 KeyCode::Enter if st.editing => st.editing = false,
872 KeyCode::Enter => walk(self, &mut st, true),
874 KeyCode::Backspace if st.editing => {
875 st.query.pop();
876 self.search_refind(&mut st);
877 }
878 KeyCode::Backspace => st.editing = true,
879 KeyCode::Char(c) if !ctrl && !alt && st.editing => {
880 let mut buf = [0u8; 4];
881 return self.search_type(st, c.encode_utf8(&mut buf));
882 }
883 KeyCode::Char('n') if !ctrl && !alt => walk(self, &mut st, true),
884 KeyCode::Char('N') if !ctrl && !alt => walk(self, &mut st, false),
885 _ => {}
886 }
887 self.overlay = Some(Overlay::Search(st));
888 }
889
890 fn history_search_type(&mut self, mut st: HistorySearchState, text: &str) {
892 st.query.push_str(text);
893 self.history_refind(&mut st);
894 self.overlay = Some(Overlay::HistorySearch(st));
895 }
896
897 fn history_refind(&mut self, st: &mut HistorySearchState) {
899 st.current = self.history_matches(&st.query).last().copied();
900 self.show_history_match(st);
901 }
902
903 fn show_history_match(&mut self, st: &HistorySearchState) {
904 let line = match st.current {
905 Some(i) => self.history[i].clone(),
906 None => st.draft.clone(),
907 };
908 self.set_input(line);
909 }
910
911 fn history_search_key(&mut self, mut st: HistorySearchState, key: KeyEvent) {
912 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
913 let alt = key.modifiers.contains(KeyModifiers::ALT);
914 match key.code {
915 KeyCode::Enter => return,
917 KeyCode::Char('r') if ctrl => {
918 let found = Self::step(&self.history_matches(&st.query), st.current, true);
919 if found.is_some() {
920 st.current = found;
921 self.show_history_match(&st);
922 }
923 }
924 KeyCode::Backspace => {
925 st.query.pop();
926 self.history_refind(&mut st);
927 }
928 KeyCode::Char(c) if !ctrl && !alt => {
929 let mut buf = [0u8; 4];
930 return self.history_search_type(st, c.encode_utf8(&mut buf));
931 }
932 _ => {}
933 }
934 self.overlay = Some(Overlay::HistorySearch(st));
935 }
936
937 fn search_view(&self, live: &[Line<'static>]) -> Option<SearchView> {
940 match &self.overlay {
941 Some(Overlay::Search(st)) => {
942 let matches = self.matches_in(&st.query, live);
943 let current = st
945 .current
946 .filter(|c| matches.binary_search(c).is_ok())
947 .or(matches.last().copied());
948 let text = Self::search_text("search", &st.query, &matches, current);
949 Some(SearchView {
950 matches,
951 current,
952 text,
953 })
954 }
955 Some(Overlay::HistorySearch(st)) => {
956 let matches = self.history_matches(&st.query);
957 let text = Self::search_text("history search", &st.query, &matches, st.current);
958 Some(SearchView {
959 matches: Vec::new(),
960 current: None,
961 text,
962 })
963 }
964 _ => None,
965 }
966 }
967
968 fn search_text(label: &str, query: &str, matches: &[usize], cur: Option<usize>) -> String {
969 if query.is_empty() {
970 return format!("{label}: type to search");
971 }
972 match Self::rank(matches, cur) {
973 Some((n, total)) => format!("{label}: {query} {n}/{total}"),
974 None => format!("{label}: {query} {NO_MATCH}"),
975 }
976 }
977
978 fn page(&self) -> usize {
981 self.last_pane_height.get().saturating_sub(1).max(1)
982 }
983
984 fn nav_keys(&self) -> Vec<(&'static str, &'static str)> {
987 if self.grid_shown {
988 return vec![("Ctrl-G", "shell"), ("Enter", "detail"), ("Esc", "stop")];
989 }
990 let mut keys = Vec::new();
991 let scrolled = self.scroll.get().min(self.last_max_top.get()) > 0;
992 if scrolled {
993 keys.push(("PgDn/wheel/Shift-↓", "down"));
994 keys.push(("Ctrl-End", "bottom"));
995 } else if self.last_max_top.get() > 0 {
996 keys.push(("PgUp/wheel/Shift-↑", "scroll back"));
997 }
998 if self.grid.is_some() {
999 keys.push(("Ctrl-G", "grid"));
1000 }
1001 keys.extend([
1002 ("↑↓", "history"),
1003 ("Tab", "complete"),
1004 ("Ctrl-K", "commands"),
1005 ("Ctrl-F", "find"),
1006 ("Ctrl-R", "search history"),
1007 ("help", "all commands"),
1008 ]);
1009 keys
1010 }
1011
1012 #[cfg(test)]
1014 pub(crate) fn scrolled(&self) -> usize {
1015 self.scroll.get()
1016 }
1017
1018 pub fn wheel(&mut self, rows: isize) {
1021 let list_open = matches!(
1022 self.overlay,
1023 Some(Overlay::Palette(_) | Overlay::Menu(_) | Overlay::Choice(_))
1024 );
1025 if !self.grid_shown && !list_open {
1026 self.scroll_lines(rows);
1027 }
1028 }
1029
1030 pub fn scroll_lines(&mut self, rows: isize) {
1033 let max = self.last_max_top.get();
1034 let now = self.scroll.get().min(max);
1035 let next = if rows >= 0 {
1036 now.saturating_add(rows.unsigned_abs()).min(max)
1037 } else {
1038 now.saturating_sub(rows.unsigned_abs())
1039 };
1040 self.scroll.set(next);
1041 }
1042
1043 fn input_scroll(&self, width: usize) -> usize {
1048 let mut bounds = vec![0usize];
1049 let mut col = 0;
1050 for c in self.input.chars() {
1051 col += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
1052 bounds.push(col);
1053 }
1054 let total = col;
1055 let cursor_col = self.cursor_column();
1056 let cursor_w = self
1057 .input
1058 .chars()
1059 .nth(self.cursor)
1060 .map_or(1, |c| {
1061 unicode_width::UnicodeWidthChar::width(c)
1062 .unwrap_or(0)
1063 .max(1)
1064 })
1065 .min(width);
1066 let down = |x: usize| bounds.iter().copied().rfind(|&b| b <= x).unwrap_or(0);
1067 let up = |x: usize| bounds.iter().copied().find(|&b| b >= x).unwrap_or(total);
1068 let mut scroll = down(self.scroll_x.get().min((total + 1).saturating_sub(width)));
1069 if cursor_col < scroll {
1070 scroll = cursor_col;
1071 }
1072 if cursor_col + cursor_w > scroll + width {
1073 scroll = up(cursor_col + cursor_w - width);
1074 }
1075 self.scroll_x.set(scroll);
1076 scroll
1077 }
1078
1079 fn cursor_column(&self) -> usize {
1081 UnicodeWidthStr::width(&self.input[..self.byte_index(self.cursor)])
1082 }
1083
1084 fn input_is_exit(&self) -> bool {
1086 self.input
1087 .split_whitespace()
1088 .next()
1089 .is_some_and(|w| exec::session_command_name(w) == "exit")
1090 }
1091
1092 fn push_busy_note(&mut self) {
1094 let last = self.scrollback.last().map(|l| l.to_string());
1095 if last.as_deref() != Some(format!("{NOTE_GLYPH} {BUSY_NOTE}").as_str()) {
1096 self.push_note(BUSY_NOTE);
1097 }
1098 }
1099
1100 fn tab_complete(&mut self) {
1101 if self.cursor != self.char_len() {
1102 return;
1103 }
1104 let c = super::complete::complete(&self.input, &self.vocab);
1105 match c.candidates.len() {
1106 0 => {}
1107 1 => self.replace_word(c.start, &c.candidates[0], true),
1108 _ => {
1109 let prefix = common_prefix(&c.candidates);
1110 self.replace_word(c.start, &prefix, false);
1111 self.overlay = Some(Overlay::Menu(Menu {
1112 start: c.start,
1113 candidates: c.candidates,
1114 selected: 0,
1115 }));
1116 }
1117 }
1118 }
1119
1120 fn replace_word(&mut self, start: usize, word: &str, space: bool) {
1122 self.input.truncate(start);
1123 self.input.push_str(word);
1124 if space {
1125 self.input.push(' ');
1126 }
1127 self.cursor = self.char_len();
1128 }
1129
1130 fn char_len(&self) -> usize {
1131 self.input.chars().count()
1132 }
1133
1134 fn byte_index(&self, char_idx: usize) -> usize {
1135 self.input
1136 .char_indices()
1137 .nth(char_idx)
1138 .map_or(self.input.len(), |(i, _)| i)
1139 }
1140
1141 fn remove_char(&mut self, char_idx: usize) {
1142 let at = self.byte_index(char_idx);
1143 self.input.remove(at);
1144 }
1145
1146 fn keep_cleared(&mut self, text: String) {
1148 if !text.trim().is_empty() {
1149 self.cleared = Some(text);
1150 }
1151 }
1152
1153 fn word_left(&self) -> usize {
1155 let chars: Vec<char> = self.input.chars().collect();
1156 let mut at = self.cursor;
1157 while at > 0 && chars[at - 1] == ' ' {
1158 at -= 1;
1159 }
1160 while at > 0 && chars[at - 1] != ' ' {
1161 at -= 1;
1162 }
1163 at
1164 }
1165
1166 fn word_right(&self) -> usize {
1168 let chars: Vec<char> = self.input.chars().collect();
1169 let mut at = self.cursor;
1170 while at < chars.len() && chars[at] == ' ' {
1171 at += 1;
1172 }
1173 while at < chars.len() && chars[at] != ' ' {
1174 at += 1;
1175 }
1176 at
1177 }
1178
1179 fn delete_word(&mut self) {
1180 let chars: Vec<char> = self.input.chars().collect();
1181 let mut start = self.cursor;
1182 while start > 0 && chars[start - 1] == ' ' {
1183 start -= 1;
1184 }
1185 while start > 0 && chars[start - 1] != ' ' {
1186 start -= 1;
1187 }
1188 self.input = chars[..start].iter().chain(&chars[self.cursor..]).collect();
1189 self.cursor = start;
1190 }
1191
1192 fn set_input(&mut self, s: String) {
1193 self.cursor = s.chars().count();
1194 self.input = s;
1195 }
1196
1197 fn history_prev(&mut self) {
1198 if self.history.is_empty() {
1199 return;
1200 }
1201 let pos = match self.hist_pos {
1202 None => {
1203 self.draft = self.input.clone();
1204 self.history.len() - 1
1205 }
1206 Some(0) => 0,
1207 Some(p) => p - 1,
1208 };
1209 self.hist_pos = Some(pos);
1210 self.set_input(self.history[pos].clone());
1211 }
1212
1213 fn history_next(&mut self) {
1214 match self.hist_pos {
1215 None => {}
1216 Some(p) if p + 1 < self.history.len() => {
1217 self.hist_pos = Some(p + 1);
1218 self.set_input(self.history[p + 1].clone());
1219 }
1220 Some(_) => {
1221 self.hist_pos = None;
1222 let draft = std::mem::take(&mut self.draft);
1223 self.set_input(draft);
1224 }
1225 }
1226 }
1227
1228 pub fn push_ansi(&mut self, text: &str) {
1232 let text = text.strip_suffix('\n').unwrap_or(text);
1233 if text.is_empty() {
1234 return;
1235 }
1236 let lines = match text.into_text() {
1237 Ok(t) => t.lines,
1238 Err(_) => text.lines().map(|l| Line::raw(l.to_string())).collect(),
1240 };
1241 self.push_lines(lines);
1242 }
1243
1244 pub fn push_note(&mut self, note: &str) {
1246 self.push_lines(vec![Line::from(vec![
1247 Span::styled(NOTE_GLYPH, INFO),
1248 Span::styled(format!(" {note}"), SECONDARY),
1249 ])]);
1250 }
1251
1252 pub(crate) fn push_styled_note(&mut self, note: &str, style: Style) {
1254 self.push_lines(vec![Line::styled(note.to_string(), style)]);
1255 }
1256
1257 fn push_lines(&mut self, lines: Vec<Line<'static>>) {
1258 self.scrollback.extend(lines);
1259 let excess = self.scrollback.len().saturating_sub(SCROLLBACK_LIMIT);
1260 self.scrollback.drain(..excess);
1261 self.dropped_lines += excess;
1262 if let Some(Overlay::Search(st)) = &mut self.overlay {
1265 st.current = st.current.and_then(|c| c.checked_sub(excess));
1266 }
1267 }
1268
1269 fn push_echo(&mut self, line: &str) {
1270 self.push_lines(vec![Line::from(vec![
1271 Span::styled("scope", ACCENT),
1272 Span::styled(format!(":{}", self.ctx.chain), KEY),
1273 Span::raw("> "),
1274 Span::raw(line.to_string()),
1275 ])]);
1276 }
1277
1278 fn prompt(&self) -> Line<'static> {
1280 Line::from(vec![
1281 Span::styled("scope", ACCENT),
1282 Span::styled(format!(":{}", self.ctx.chain), KEY),
1283 Span::raw("> "),
1284 ])
1285 }
1286
1287 fn pane_state(&self) -> PaneState {
1289 if self.running.is_some() {
1290 PaneState::Running
1291 } else if self.failed {
1292 PaneState::Failed
1293 } else {
1294 PaneState::Idle
1295 }
1296 }
1297
1298 pub fn scrollback_text(&self) -> String {
1300 let mut s = String::new();
1301 for line in &self.scrollback {
1302 for span in &line.spans {
1303 s.push_str(&span.content);
1304 }
1305 s.push('\n');
1306 }
1307 s
1308 }
1309
1310 fn grid_key(&mut self, key: KeyEvent) -> Action {
1314 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1315 if ctrl && key.code == KeyCode::Char('d') {
1316 return Action::Exit;
1317 }
1318 let Some(g) = self.grid.as_mut() else {
1319 self.grid_shown = false;
1320 return Action::None;
1321 };
1322 let action = if ctrl && key.code == KeyCode::Char('c') {
1324 if g.is_running() {
1325 GridAction::Stop
1326 } else {
1327 GridAction::Close
1328 }
1329 } else {
1330 g.key(key)
1331 };
1332 match action {
1333 GridAction::None => Action::None,
1334 GridAction::Shell => {
1335 self.grid_shown = false;
1336 Action::None
1337 }
1338 GridAction::Stop => Action::StopWatch,
1339 GridAction::Close => {
1340 self.close_grid();
1341 Action::None
1342 }
1343 GridAction::Run(line) => {
1344 if self.running.is_some() {
1345 self.busy_hint = true;
1346 return Action::None;
1347 }
1348 self.grid_shown = false;
1349 Action::Submit(line)
1350 }
1351 }
1352 }
1353
1354 pub fn open_wizard(&mut self, list: scope::watch::Watchlist, notes: Vec<String>) {
1357 self.close_overlay();
1358 for n in ¬es {
1359 self.push_note(n);
1360 }
1361 self.wizard = Some(WizardState::new(
1362 list,
1363 notes,
1364 self.vocab.venues.clone(),
1365 scope::paths::watchlists_dir(),
1366 ));
1367 }
1368
1369 fn wizard_key(&mut self, key: KeyEvent) -> Action {
1371 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('d') {
1372 return Action::Exit;
1373 }
1374 let Some(w) = self.wizard.as_mut() else {
1375 return Action::None;
1376 };
1377 match w.key(key) {
1378 WizardAction::None => Action::None,
1379 WizardAction::Leave => {
1380 self.wizard = None;
1381 self.push_note("Left the monitor wizard.");
1382 Action::None
1383 }
1384 WizardAction::Saved(path) => {
1385 let name = w.list.name.clone();
1386 self.push_note(&format!(
1387 "Saved the watchlist to {}. `watch open {name}` runs it again.",
1388 path.display()
1389 ));
1390 Action::None
1391 }
1392 WizardAction::Start(list) => {
1393 self.wizard = None;
1394 Action::StartWatch(list)
1395 }
1396 }
1397 }
1398
1399 pub fn watch_running(&self) -> bool {
1401 self.grid.as_ref().is_some_and(GridState::is_running)
1402 }
1403
1404 pub fn start_watch(
1408 &mut self,
1409 list: scope::watch::Watchlist,
1410 ) -> (Output, scope::diag::Sink, std::sync::mpsc::Sender<GridMsg>) {
1411 let (out, captured) = Output::capture_merged();
1412 let sink = captured.out_sink();
1415 let out = out.with_asker(self.asker.clone());
1416 let (tx, rx) = std::sync::mpsc::channel();
1417 self.push_note(&format!(
1418 "Watchlist {} started: {} target(s), every {}. Ctrl-G switches between the grid and the shell.",
1419 list.name,
1420 list.targets.len(),
1421 scope::watch::time::format_duration(list.interval.as_secs())
1422 ));
1423 self.grid = Some(GridState::new(list, out.progress_hook()));
1424 self.grid_shown = true;
1425 self.grid_rx = Some(rx);
1426 self.grid_out = Some((out.clone(), captured));
1427 self.grid_started = Some(Instant::now());
1428 (out, sink, tx)
1429 }
1430
1431 pub fn settings_reloaded(&mut self) {
1434 if let Some(g) = self.grid.as_mut().filter(|g| g.is_running()) {
1435 g.stale = true;
1436 self.push_note(grid::STALE_NOTE);
1437 }
1438 }
1439
1440 pub fn poll_watch(&mut self) {
1442 let (Some(rx), Some(g)) = (&self.grid_rx, self.grid.as_mut()) else {
1443 return;
1444 };
1445 while let Ok(msg) = rx.try_recv() {
1446 g.apply(msg);
1447 }
1448 }
1449
1450 pub fn take_bell(&mut self) -> bool {
1452 self.grid
1453 .as_mut()
1454 .is_some_and(|g| std::mem::take(&mut g.bell))
1455 }
1456
1457 fn grid_secs(&self) -> f64 {
1458 self.grid_started.map_or(0.0, |t| t.elapsed().as_secs_f64())
1459 }
1460
1461 pub fn watch_finished(&mut self, res: scope::error::Result<()>) {
1463 self.poll_watch();
1464 let secs = self.grid_secs();
1465 let (footer, style) = match &res {
1466 Ok(()) => (format!("✓ Done in {secs:.1}s."), OK),
1467 Err(e) => (
1468 format!("✗ Failed in {secs:.1}s: {e}"),
1469 ERR.add_modifier(Modifier::BOLD),
1470 ),
1471 };
1472 if let Some((out, _)) = &self.grid_out
1473 && let Err(e) = &res
1474 {
1475 let _ = crate::cli::errors::display_error(e, out);
1476 }
1477 if let Some(g) = self.grid.as_mut() {
1478 g.end(footer, style);
1479 }
1480 self.grid_rx = None;
1481 self.drop_watch_question();
1482 }
1483
1484 pub fn stop_watch(&mut self) {
1487 self.poll_watch();
1488 let secs = self.grid_secs();
1489 let mut footer = format!("Stopped after {secs:.1}s.");
1490 if let Some(g) = &self.grid {
1491 let list = g.ready.as_ref().map_or(&g.list, |r| &r.list);
1492 if let (Some(path), Some(last)) = (&list.outputs.report, &g.last) {
1493 match crate::cli::watch::run::write_report(path, list, last) {
1494 Ok(()) => footer.push_str(&format!(" Report saved to {}.", path.display())),
1495 Err(e) => footer.push_str(&format!(" The report was not written: {e}")),
1496 }
1497 }
1498 }
1499 if let Some(g) = self.grid.as_mut() {
1500 g.end(footer, WARN);
1501 }
1502 self.grid_rx = None;
1503 self.drop_watch_question();
1504 }
1505
1506 fn drop_watch_question(&mut self) {
1508 if self.running.is_none() {
1509 if matches!(self.overlay, Some(Overlay::Choice(_))) {
1510 self.overlay = None;
1511 }
1512 self.asker.lock().unwrap_or_else(|e| e.into_inner()).take();
1513 }
1514 }
1515
1516 pub fn close_grid(&mut self) {
1519 let Some(g) = self.grid.take() else {
1520 return;
1521 };
1522 self.grid_shown = false;
1523 if let Some(last) = &g.last {
1524 self.push_ansi(&grid::snapshot_text(&last.snapshot));
1525 }
1526 if let Some((_, captured)) = self.grid_out.take() {
1527 self.push_ansi(&captured.out());
1528 }
1529 self.push_note(&grid::summary(&g));
1530 if let Some((footer, style)) = &g.ended {
1531 self.push_styled_note(footer, *style);
1532 }
1533 }
1534
1535 pub fn show_grid(&mut self) {
1537 if self.grid.is_some() {
1538 self.grid_shown = true;
1539 } else {
1540 self.push_note(NO_GRID_NOTE);
1541 }
1542 }
1543
1544 pub fn submit(&mut self, line: &str, config: &Config) -> Plan {
1549 self.push_echo(line);
1550 if !line.trim().is_empty() {
1554 self.failed = false;
1555 }
1556 if line
1559 .split_whitespace()
1560 .next()
1561 .map(exec::session_command_name)
1562 .as_deref()
1563 == Some("clear")
1564 {
1565 self.scrollback.clear();
1566 self.dropped_lines = 0;
1567 self.scroll.set(0);
1568 return Plan::Done;
1569 }
1570 if line
1571 .split_whitespace()
1572 .next()
1573 .is_some_and(|w| w.eq_ignore_ascii_case("save"))
1574 {
1575 let words = exec::split_line(line);
1576 self.save(line, words.as_ref().map(|w| &w[1..]));
1577 return Plan::Done;
1578 }
1579 let (out, captured) = Output::capture_merged();
1580 let started = Instant::now();
1581 let started_at = Utc::now().to_rfc3339();
1582 let plan = match exec::plan(line, &mut self.ctx, config, &out) {
1583 Ok(plan) => plan,
1584 Err(e) => {
1586 self.push_note(&format!("Output error: {}", e));
1587 Plan::Done
1588 }
1589 };
1590 let start = self.scrollback.len();
1591 self.push_ansi(&captured.out());
1592 let first = line
1594 .split_whitespace()
1595 .next()
1596 .map(exec::session_command_name);
1597 if first.as_deref() == Some("help") && self.scrollback.len() > start {
1598 self.pin_top.set(Some(start));
1599 }
1600 if !line.trim().is_empty() && !matches!(plan, Plan::Run(_)) {
1601 self.log(Event {
1602 started: started_at,
1603 duration_ms: started.elapsed().as_millis(),
1604 input: line,
1605 argv: &[],
1606 route: "session",
1607 outcome: "session".into(),
1608 error: None,
1609 });
1610 }
1611 plan
1612 }
1613
1614 fn log_action(
1617 &mut self,
1618 input: &str,
1619 started: Instant,
1620 outcome: &'static str,
1621 error: Option<String>,
1622 ) {
1623 self.log(Event {
1624 started: Utc::now().to_rfc3339(),
1625 duration_ms: started.elapsed().as_millis(),
1626 input,
1627 argv: &[],
1628 route: "session",
1629 outcome: outcome.into(),
1630 error,
1631 });
1632 }
1633
1634 fn save(&mut self, line: &str, words: Option<&[String]>) {
1637 let started = Instant::now();
1638 let result = match words {
1639 None => Err(SaveError::Refused(format!(
1640 "Unbalanced quotes. {}",
1641 clip::SAVE_USAGE
1642 ))),
1643 Some(args) => {
1644 let args: Vec<&str> = args.iter().map(String::as_str).collect();
1645 let home = scope::paths::user_home();
1646 match std::env::current_dir() {
1647 Ok(cwd) => {
1648 let csv = args
1650 .iter()
1651 .any(|a| a.to_ascii_lowercase().ends_with(".csv"))
1652 .then(|| self.last_series.as_ref().and_then(repeat_view::series_csv))
1653 .flatten();
1654 let text = csv.as_deref().or(self.last_output.as_deref());
1655 clip::save(&args, text, &cwd, home.as_deref())
1656 }
1657 Err(e) => Err(SaveError::failed(e, "Could not read the current directory")),
1658 }
1659 }
1660 };
1661 match result {
1662 Ok(note) => {
1663 self.push_note(¬e);
1664 self.log_action(line, started, "session", None);
1665 }
1666 Err(e) => {
1667 self.push_note(&e.message());
1668 let outcome = match e {
1669 SaveError::Refused(_) => "refused",
1670 SaveError::Failed(_) => "error",
1671 };
1672 self.log_action(line, started, outcome, Some(e.message()));
1673 }
1674 }
1675 }
1676
1677 fn copy_key(&mut self) -> Action {
1680 let started = Instant::now();
1681 let refusal = match self.last_output.clone() {
1682 None => "nothing to copy yet".to_string(),
1683 Some(text) if clip::fits_osc52(&text) => return Action::Copy(text),
1684 Some(text) => format!(
1685 "too large to copy ({} lines); use save <file>",
1686 text.lines().count()
1687 ),
1688 };
1689 self.push_note(&refusal);
1690 self.log_action("Ctrl-Y", started, "refused", Some(refusal));
1691 Action::None
1692 }
1693
1694 pub fn copied(&mut self, lines: usize, result: std::io::Result<()>) {
1696 let started = Instant::now();
1697 match result {
1698 Ok(()) => {
1699 self.push_note(&format!("copied {} lines", lines));
1700 self.log_action("Ctrl-Y", started, "session", None);
1701 }
1702 Err(e) => {
1703 let note = format!("Could not copy: {}", e);
1704 self.push_note(¬e);
1705 self.log_action("Ctrl-Y", started, "error", Some(note));
1706 }
1707 }
1708 }
1709
1710 pub fn start(&mut self, input: &str, inv: &Invocation) -> Output {
1712 let (out, captured) = Output::capture_merged();
1713 let out = out.with_asker(self.asker.clone());
1715 let diag = scope::diag::redirect(captured.out_sink());
1716 self.failed = false;
1717 self.last_series = None;
1718 self.running = Some(Running {
1719 input: input.to_string(),
1720 argv: inv.argv.clone(),
1721 started: Instant::now(),
1722 started_at: Utc::now().to_rfc3339(),
1723 captured,
1724 series: out.series_hook(),
1725 out: out.clone(),
1726 _diag: diag,
1727 });
1728 out
1729 }
1730
1731 pub fn finish(&mut self, ending: Ending) {
1734 if matches!(self.overlay, Some(Overlay::Choice(_))) {
1736 self.overlay = None;
1737 }
1738 self.asker.lock().unwrap_or_else(|e| e.into_inner()).take();
1739 self.finish_with(ending);
1740 }
1741
1742 pub fn poll_ask(&mut self) {
1745 if matches!(self.overlay, Some(Overlay::Choice(_))) {
1746 return;
1747 }
1748 let ask = self.asker.lock().unwrap_or_else(|e| e.into_inner()).take();
1749 if let Some(ask) = ask {
1750 self.close_overlay();
1752 self.overlay = Some(Overlay::Choice(ChoiceState {
1753 prompt: ask.prompt,
1754 options: ask.options,
1755 selected: 0,
1756 reply: Some(ask.reply),
1757 }));
1758 }
1759 }
1760
1761 fn answer_choice(&mut self, answer: Option<usize>) {
1763 if let Some(Overlay::Choice(mut ch)) = self.overlay.take()
1764 && let Some(reply) = ch.reply.take()
1765 {
1766 let _ = reply.send(answer);
1768 }
1769 }
1770
1771 fn finish_with(&mut self, ending: Ending) {
1774 let Some(run) = self.running.take() else {
1775 return;
1776 };
1777 let (outcome, error) = ending.outcome();
1778 if let Ending::Finished(Err(e)) = &ending {
1779 if let Err(write_err) = crate::cli::errors::display_error(e, &run.out) {
1781 self.push_note(&format!("Output error: {}", write_err));
1782 }
1783 }
1784 let secs = run.started.elapsed().as_secs_f64();
1785 let (footer, style) = match &ending {
1786 Ending::Cancelled => (format!("Cancelled after {:.1}s.", secs), WARN),
1787 Ending::Finished(Err(_)) => {
1788 self.failed = true;
1789 (
1790 format!("✗ Failed in {:.1}s.", secs),
1791 ERR.add_modifier(Modifier::BOLD),
1792 )
1793 }
1794 Ending::Finished(Ok(())) => (format!("✓ Done in {:.1}s.", secs), OK),
1795 };
1796 match run.series.clone().filter(repeat_view::has_runs) {
1797 Some(series) => {
1800 self.last_output =
1801 repeat_view::final_snapshot(&series).map(|t| crate::cli::ansi::strip(&t));
1802 self.last_series = Some(series.clone());
1803 self.repeat_view = Some(RepeatView {
1804 series,
1805 ended: Some(Ended {
1806 text: footer,
1807 style,
1808 }),
1809 pending: run.captured.out(),
1810 });
1811 }
1812 None => {
1813 self.keep_copy(&run.captured, &ending);
1814 self.push_output(&run, &ending);
1815 self.push_styled_note(&footer, style);
1816 }
1817 }
1818 self.log(Event {
1819 started: run.started_at,
1820 duration_ms: run.started.elapsed().as_millis(),
1821 input: &run.input,
1822 argv: &run.argv,
1823 route: "pane",
1824 outcome: outcome.into(),
1825 error,
1826 });
1827 }
1828
1829 fn push_output(&mut self, run: &Running, ending: &Ending) {
1834 let merged = run.captured.out();
1835 let view = run
1836 .out
1837 .view_hook()
1838 .and_then(|h| h.lock().unwrap_or_else(|e| e.into_inner()).take());
1839 match view {
1840 Some(view) if matches!(ending, Ending::Finished(Ok(()))) => {
1841 let data = run.captured.data();
1842 let notes = if data.is_empty() {
1843 merged
1844 } else {
1845 merged.replacen(&data, "", 1)
1846 };
1847 let width = self.pane_width().unwrap_or(80);
1848 let size = market_view::Size::for_width(width);
1849 self.push_lines(market_view::lines(&view, size));
1850 self.push_lines(market_view::narrow_hint(width).into_iter().collect());
1851 self.push_ansi(¬es);
1852 }
1853 _ => self.push_ansi(&merged),
1854 }
1855 }
1856
1857 fn leave_repeat_view(&mut self, view: RepeatView) {
1861 if let Some(snapshot) = repeat_view::final_snapshot(&view.series) {
1862 self.push_ansi(&snapshot);
1863 }
1864 self.push_ansi(&view.pending);
1865 self.push_note(&repeat_view::summary(&view.series));
1866 if let Some(ended) = view.ended {
1867 self.push_styled_note(&ended.text, ended.style);
1868 }
1869 }
1870
1871 fn active_view(&self) -> Option<RepeatView> {
1874 if let Some(run) = &self.running {
1875 let series = run.series.as_ref().filter(|s| repeat_view::has_runs(s))?;
1876 return Some(RepeatView {
1877 series: series.clone(),
1878 ended: None,
1879 pending: String::new(),
1880 });
1881 }
1882 self.repeat_view.clone()
1883 }
1884
1885 fn keep_copy(&mut self, captured: &Captured, ending: &Ending) {
1890 if matches!(ending, Ending::Finished(Ok(()))) {
1891 let data = crate::cli::ansi::strip(&captured.data());
1892 self.last_output = (!captured.trimmed() && !data.trim().is_empty()).then_some(data);
1893 }
1894 }
1895
1896 pub fn show_outside(&mut self, copy: &Captured, ending: &Ending) {
1901 if copy.trimmed() {
1902 self.push_note(
1903 "Its output was long: only the end is kept here, and Ctrl-Y and save do not have it.",
1904 );
1905 }
1906 self.keep_copy(copy, ending);
1907 self.push_ansi(©.out());
1908 }
1909
1910 pub fn record_suspended(
1913 &mut self,
1914 input: &str,
1915 inv_argv: &[String],
1916 duration: std::time::Duration,
1917 started_at: String,
1918 ending: &Ending,
1919 ) {
1920 let (outcome, error) = ending.outcome();
1921 self.failed = matches!(ending, Ending::Finished(Err(_)));
1924 let word = if outcome == "error" {
1926 "failed"
1927 } else {
1928 outcome
1929 };
1930 self.push_note(&format!(
1931 "Ran outside the TUI: {} ({}, {:.1}s).",
1932 input,
1933 word,
1934 duration.as_secs_f64()
1935 ));
1936 self.log(Event {
1937 started: started_at,
1938 duration_ms: duration.as_millis(),
1939 input,
1940 argv: inv_argv,
1941 route: "suspend",
1942 outcome: outcome.into(),
1943 error,
1944 });
1945 }
1946
1947 pub fn record_signal(&mut self, name: &str, route: &'static str, input: &str, argv: &[String]) {
1951 self.log(Event {
1952 started: Utc::now().to_rfc3339(),
1953 duration_ms: 0,
1954 input,
1955 argv,
1956 route,
1957 outcome: format!("signal:{}", name).into(),
1958 error: None,
1959 });
1960 }
1961
1962 fn log(&mut self, event: Event<'_>) {
1963 if let Err(e) = self.journal.record(&event) {
1964 self.push_note(&format!("Could not write the TUI event log: {}", e));
1965 }
1966 }
1967
1968 pub fn pane_width(&self) -> Option<usize> {
1973 match self.pane_width.get() {
1974 0 => None,
1975 w => Some(w as usize),
1976 }
1977 }
1978
1979 pub fn render(&self, f: &mut Frame) {
1981 let area = f.area();
1982 let too_small = area.width < MIN_W || area.height < MIN_H;
1983 self.too_small.set(too_small);
1984 if too_small {
1985 self.pane_width.set(0);
1989 self.last_pane_height.set(0);
1990 draw_note_alone(f, &too_small_note());
1991 return;
1992 }
1993 let [pane, input, hint_row, status] = Layout::vertical([
1994 Constraint::Min(3),
1995 Constraint::Length(3),
1996 Constraint::Length(1),
1997 Constraint::Length(1),
1998 ])
1999 .areas(f.area());
2000
2001 let live_out = self.live_output();
2003 let liveness = match &self.running {
2006 Some(run) => self.liveness_lines(run, color_enabled()),
2007 None => Vec::new(),
2008 };
2009 let state = self.pane_state();
2011 let block = Block::new()
2012 .borders(Borders::TOP | Borders::BOTTOM)
2013 .border_style(state.style())
2014 .title(Line::styled(
2015 format!(" scope v{} ", scope::VERSION),
2016 state.style().add_modifier(Modifier::BOLD),
2017 ))
2018 .title(Line::styled(format!(" {} ", state.word()), state.style()).right_aligned());
2019 let inner = block.inner(pane);
2020 self.pane_width.set(inner.width);
2021 let view = self.search_view(&live_out);
2023 if let Some(w) = &self.wizard {
2024 wizard::draw(f, pane, w);
2025 self.last_max_top.set(0);
2026 self.last_pane_height.set(inner.height as usize);
2027 } else if let (true, Some(g)) = (self.grid_shown, &self.grid) {
2028 grid::draw(f, pane, g);
2030 self.last_max_top.set(0);
2031 self.last_pane_height.set(inner.height as usize);
2032 } else if let Some(repeating) = self.active_view() {
2033 let status = if self.running.is_some() {
2035 liveness
2036 } else {
2037 Vec::new()
2038 };
2039 f.render_widget(block, pane);
2040 repeat_view::draw(f, inner, &repeating, status);
2041 self.last_max_top.set(0);
2042 self.last_pane_height.set(inner.height as usize);
2043 } else {
2044 let width = inner.width as usize;
2048 let in_pane_search = matches!(self.overlay, Some(Overlay::Search(_)));
2051 let (rows, starts) = match (&view, in_pane_search) {
2052 (Some(v), true) => {
2053 let entries: Vec<_> =
2054 self.scrollback.iter().chain(&live_out).cloned().collect();
2055 marked_rows(&entries, liveness.clone(), &v.matches, v.current, width)
2056 }
2057 _ => {
2058 let mut lines = Vec::new();
2059 if self.dropped_lines > 0 {
2060 lines.push(Line::styled(
2062 format!(
2063 "… {} older lines dropped (the pane keeps the last {SCROLLBACK_LIMIT})",
2064 self.dropped_lines
2065 ),
2066 SECONDARY,
2067 ));
2068 }
2069 lines.extend(self.scrollback.iter().cloned());
2070 lines.extend(live_out.iter().cloned());
2071 lines.extend(liveness.iter().cloned());
2072 (fit::fit(lines, width), Vec::new())
2073 }
2074 };
2075 let para = Paragraph::new(Text::from(rows));
2076 let total = para.line_count(inner.width);
2077 let max_top = total.saturating_sub(inner.height as usize);
2078 self.last_max_top.set(max_top);
2079 self.last_pane_height.set(inner.height as usize);
2080 if let Some(line) = self.pin_top.take() {
2081 let mut above = Vec::new();
2083 if self.dropped_lines > 0 {
2084 above.push(Line::raw(""));
2085 }
2086 above.extend(self.scrollback.iter().take(line).cloned());
2087 let row = fit::fit(above, width).len();
2088 self.scroll.set(max_top.saturating_sub(row));
2089 }
2090 if let (Some(v), true) = (&view, in_pane_search) {
2091 let before = self.search_width.replace(width);
2092 let height = inner.height as usize;
2093 if self.reveal.replace(false) {
2094 self.reveal_match(&starts, v.current, height, max_top);
2095 } else if before == 0 && self.scroll.get() > 0 {
2096 let lines: Vec<_> = self.scrollback.iter().chain(&live_out).cloned().collect();
2099 let old = entry_starts(&lines, width);
2100 let total =
2101 old.last().copied().unwrap_or(0) + fit::fit(liveness.clone(), width).len();
2102 let old_max = total.saturating_sub(height);
2103 let old_top = old_max - self.scroll.get().min(old_max);
2104 let entry = old.partition_point(|&s| s <= old_top).saturating_sub(1);
2105 let top = starts.get(entry).copied().unwrap_or(max_top).min(max_top);
2106 self.scroll.set(max_top - top);
2107 } else if before != width {
2108 self.reveal_match(&starts, v.current, height, max_top);
2109 }
2110 } else {
2111 self.search_width.set(0);
2112 }
2113 let top = max_top.saturating_sub(self.scroll.get().min(max_top));
2114 f.render_widget(para.block(block).scroll((top as u16, 0)), pane);
2115 }
2116
2117 let block = Block::bordered().title(self.prompt());
2119 let inner_width = input.width.saturating_sub(2).max(1) as usize;
2121 let col = self.cursor_column();
2122 let scroll_x = self.input_scroll(inner_width);
2123 f.render_widget(
2124 Paragraph::new(self.input.as_str())
2125 .block(block)
2126 .scroll((0, scroll_x.min(u16::MAX as usize) as u16)),
2127 input,
2128 );
2129 let cursor_x = input.x + 1 + (col - scroll_x).min(u16::MAX as usize) as u16;
2130 f.set_cursor_position(Position::new(
2131 cursor_x.min(input.right().saturating_sub(2)),
2132 input.y + 1,
2133 ));
2134
2135 if let Some(Overlay::Menu(m)) = &self.overlay {
2136 let rows = m.candidates.len().min(MENU_ROWS);
2137 let first = m.selected.saturating_sub(rows - 1);
2138 let height = (rows as u16 + 2).min(input.y);
2139 let width = m
2140 .candidates
2141 .iter()
2142 .map(|c| UnicodeWidthStr::width(c.as_str()))
2143 .max()
2144 .unwrap_or(0) as u16
2145 + 4;
2146 let area = ratatui::layout::Rect {
2147 x: input.x,
2148 y: input.y.saturating_sub(height),
2149 width: width.min(input.width),
2150 height,
2151 };
2152 let lines: Vec<Line> = m.candidates[first..first + rows]
2153 .iter()
2154 .enumerate()
2155 .map(|(i, c)| {
2156 let style = if first + i == m.selected {
2157 SELECTED
2158 } else {
2159 Style::new()
2160 };
2161 Line::styled(c.clone(), style)
2162 })
2163 .collect();
2164 f.render_widget(ratatui::widgets::Clear, area);
2165 f.render_widget(Paragraph::new(lines).block(Block::bordered()), area);
2166 }
2167
2168 if let Some(Overlay::Choice(ch)) = &self.overlay {
2169 let frame = f.area();
2170 let fit = usize::from(input.y.saturating_sub(2)).max(1);
2173 let rows = ch.options.len().clamp(1, MENU_ROWS).min(fit);
2174 let first = ch.selected.saturating_sub(rows - 1);
2175 let height = (rows as u16 + 2).min(input.y);
2176 const NUMBER_COLS: usize = 4;
2178 let widest = ch
2179 .options
2180 .iter()
2181 .map(|o| UnicodeWidthStr::width(o.as_str()) + NUMBER_COLS)
2182 .chain([UnicodeWidthStr::width(ch.prompt.as_str())])
2183 .max()
2184 .unwrap_or(0) as u16;
2185 let area = ratatui::layout::Rect {
2186 x: input.x,
2187 y: input.y.saturating_sub(height),
2188 width: (widest + 2).min(frame.width.saturating_sub(input.x)),
2189 height,
2190 };
2191 let shown = ch.options.iter().enumerate().skip(first).take(rows);
2192 let lines: Vec<Line> = shown
2193 .map(|(i, o)| {
2194 let style = if i == ch.selected {
2195 SELECTED
2196 } else {
2197 Style::new()
2198 };
2199 Line::styled(format!("{:>2}. {o}", i + 1), style)
2200 })
2201 .collect();
2202 f.render_widget(ratatui::widgets::Clear, area);
2203 f.render_widget(
2204 Paragraph::new(lines).block(Block::bordered().title(ch.prompt.as_str())),
2205 area,
2206 );
2207 }
2208
2209 if self.busy_hint {
2210 f.render_widget(
2211 Paragraph::new(Line::styled(format!(" {}", BUSY_NOTE), WARN)),
2212 hint_row,
2213 );
2214 } else if let Some(h) = super::hint::hint(&self.input) {
2215 let mut spans = vec![Span::styled(format!(" {}", h.text), SECONDARY)];
2216 if let Some(next) = h.next {
2217 spans.push(Span::styled(" next: ", SECONDARY));
2218 spans.push(Span::styled(next, KEY));
2219 }
2220 f.render_widget(Paragraph::new(Line::from(spans)), hint_row);
2221 } else if self.overlay.is_none() {
2222 f.render_widget(
2224 Paragraph::new(nav_hints(&self.nav_keys(), hint_row.width as usize)),
2225 hint_row,
2226 );
2227 }
2228 if let Some(Overlay::Palette(p)) = &self.overlay {
2229 let area = f.area();
2230 let width = area.width.saturating_sub(4).min(80);
2231 let height = area.height.saturating_sub(4).min(16);
2232 let rect = ratatui::layout::Rect {
2233 x: area.x + (area.width.saturating_sub(width)) / 2,
2234 y: area.y + 1,
2235 width,
2236 height,
2237 };
2238 let rows = height.saturating_sub(4) as usize;
2240 let matches = p.matches();
2241 let first = p.selected.saturating_sub(rows.saturating_sub(1));
2242 let mut lines = vec![Line::from(format!("> {}", p.query))];
2243 for (n, &i) in matches.iter().enumerate().skip(first).take(rows) {
2244 let it = &p.items[i];
2245 let (marker, style) = if n == p.selected {
2247 (PALETTE_MARKER, SELECTED)
2248 } else {
2249 (" ", ACCENT)
2250 };
2251 lines.push(Line::from(vec![
2252 Span::styled(marker, KEY),
2253 Span::styled(format!("{:<28}", it.label), style),
2254 Span::styled(format!(" {}", it.description), SECONDARY),
2255 ]));
2256 }
2257 lines.push(Line::styled(PALETTE_LEGEND, SECONDARY));
2258 f.render_widget(ratatui::widgets::Clear, rect);
2259 f.render_widget(
2260 Paragraph::new(lines).block(Block::bordered().title(" Commands (Ctrl-K) ")),
2261 rect,
2262 );
2263 }
2264 f.render_widget(
2265 Paragraph::new(fit_status(
2266 &self.status_parts_with(view.map(|v| v.text)),
2267 status.width as usize,
2268 )),
2269 status,
2270 );
2271 }
2272
2273 fn reveal_match(
2277 &self,
2278 starts: &[usize],
2279 current: Option<usize>,
2280 height: usize,
2281 max_top: usize,
2282 ) {
2283 let Some(at) = current.filter(|&c| c + 1 < starts.len()) else {
2284 return;
2285 };
2286 let (first, rows) = (starts[at], starts[at + 1] - starts[at]);
2287 let top = first
2288 .saturating_sub(height.saturating_sub(rows) / 2)
2289 .min(max_top);
2290 self.scroll.set(max_top - top);
2291 }
2292
2293 fn liveness_lines(&self, run: &Running, color: bool) -> Vec<Line<'static>> {
2295 let dim = SECONDARY;
2296 let elapsed = run.started.elapsed();
2297 let mut lines = vec![Line::from(vec![
2298 Span::styled(spinner_glyph(elapsed, color), WARN),
2299 Span::styled(
2300 format!(" running {}… {}s", run.input, elapsed.as_secs()),
2301 dim,
2302 ),
2303 ])];
2304 if let Some(hook) = run.out.progress_hook() {
2305 let p = hook.lock().unwrap_or_else(|e| e.into_inner()).clone();
2307 if p.run > 0 {
2308 let mut text = match p.total {
2309 Some(total) => format!("run {} of {}", p.run, total),
2310 None => format!("run {}", p.run),
2311 };
2312 if let Some(at) = p.next_at {
2313 let wait = at.saturating_duration_since(Instant::now());
2314 text.push_str(&format!(" · next in {}s", wait.as_secs_f64().ceil() as u64));
2315 }
2316 lines.push(Line::styled(text, dim));
2317 }
2318 }
2319 lines
2320 }
2321
2322 fn esc_action(&self) -> EscAction {
2326 match (&self.overlay, &self.running) {
2327 (Some(Overlay::Choice(_)), _) => EscAction::AnswerChoice,
2328 (Some(Overlay::Palette(_)), _) => EscAction::ClosePalette,
2329 (Some(Overlay::Menu(_)), _) => EscAction::CloseMenu,
2330 (Some(Overlay::Search(_)), _) => EscAction::CloseSearch,
2331 (Some(Overlay::HistorySearch(_)), _) => EscAction::CloseHistorySearch,
2332 (None, Some(_)) => EscAction::Cancel,
2333 (None, None) => EscAction::ClearLine,
2334 }
2335 }
2336
2337 fn press_esc(&mut self) -> Action {
2341 match self.esc_action() {
2342 EscAction::ClosePalette
2343 | EscAction::CloseMenu
2344 | EscAction::CloseSearch
2345 | EscAction::CloseHistorySearch => self.close_overlay(),
2346 EscAction::AnswerChoice => self.answer_choice(None),
2347 EscAction::Cancel => return Action::Cancel,
2348 EscAction::ClearLine => {
2349 let line = std::mem::take(&mut self.input);
2350 self.keep_cleared(line);
2351 self.cursor = 0;
2352 }
2353 }
2354 Action::None
2355 }
2356
2357 #[cfg(test)]
2360 fn status_parts(&self) -> StatusParts {
2361 let search = self.search_view(&self.live_output()).map(|v| v.text);
2362 self.status_parts_with(search)
2363 }
2364
2365 fn status_parts_with(&self, search: Option<String>) -> StatusParts {
2367 let ctx = &self.ctx;
2368 let mut limit = labeled("limit", ctx.limit.to_string());
2369 for (on, flag) in [(ctx.include_tokens, "+tokens"), (ctx.include_txs, "+txs")] {
2370 if on {
2371 limit.extend([sep(), Span::styled(flag, ACCENT)]);
2372 }
2373 }
2374 let mut context = vec![
2376 labeled("chain", ctx.chain.clone()),
2377 labeled("format", format!("{:?}", ctx.format)),
2378 limit,
2379 ];
2380 if let (false, Some(g)) = (self.grid_shown, &self.grid) {
2382 let style = if g.breaches() > 0 { ERR } else { ACCENT };
2383 context.insert(0, vec![Span::styled(g.status_part(), style)]);
2384 }
2385 let scrolled = self.scroll.get().min(self.last_max_top.get());
2387 let scrolled = (scrolled > 0).then(|| {
2388 let mut part = vec![Span::styled(
2389 format!("scrolled up {} lines", scrolled),
2390 ACCENT,
2391 )];
2392 if search.is_none() {
2395 part.extend([sep(), Span::styled("PgDn", ACCENT)]);
2396 }
2397 part
2398 });
2399 let esc = self
2400 .esc_action()
2401 .hint()
2402 .map(|hint| Span::styled(hint, Style::new().add_modifier(Modifier::BOLD)));
2403 let (state, scrolled, command) = match (&self.running, esc) {
2404 (Some(run), esc) => {
2405 let mut state = vec![Span::styled(
2406 format!("running {:.0}s", run.started.elapsed().as_secs_f64()),
2407 WARN,
2408 )];
2409 state.extend(esc.into_iter().flat_map(|esc| [sep(), esc]));
2410 (state, scrolled, Some(run.input.clone()))
2411 }
2412 (None, Some(esc)) => (vec![esc], scrolled, None),
2414 (None, None) => (
2416 scrolled.unwrap_or_else(|| {
2417 vec![
2418 Span::styled("help", ACCENT),
2419 sep(),
2420 Span::styled("exit", ACCENT),
2421 ]
2422 }),
2423 None,
2424 None,
2425 ),
2426 };
2427 StatusParts {
2428 context,
2429 state,
2430 scrolled,
2431 search: search.map(|text| vec![Span::styled(text, ACCENT)]),
2432 command,
2433 }
2434 }
2435}
2436
2437type Spans = Vec<Span<'static>>;
2439
2440fn nav_hints(keys: &[(&'static str, &'static str)], width: usize) -> Line<'static> {
2443 let mut spans = vec![Span::raw(" ")];
2444 let mut used = 1;
2445 for (i, (key, what)) in keys.iter().enumerate() {
2446 let sep = if i == 0 { "" } else { " · " };
2447 let cols = UnicodeWidthStr::width(*key) + 1 + what.len() + sep.len();
2448 if used + cols > width {
2449 break;
2450 }
2451 used += cols;
2452 if !sep.is_empty() {
2453 spans.push(Span::styled(sep, SECONDARY));
2454 }
2455 spans.push(Span::styled(*key, KEY));
2456 spans.push(Span::styled(format!(" {what}"), SECONDARY));
2457 }
2458 Line::from(spans)
2459}
2460
2461struct StatusParts {
2464 context: Vec<Spans>,
2467 state: Spans,
2470 scrolled: Option<Spans>,
2473 search: Option<Spans>,
2476 command: Option<String>,
2479}
2480
2481const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2483
2484const SPINNER_ASCII: [&str; 4] = ["-", "\\", "|", "/"];
2486
2487fn spinner_glyph(elapsed: std::time::Duration, color: bool) -> &'static str {
2489 let step = (elapsed.as_millis() / super::LIVE_REDRAW.as_millis()) as usize;
2490 if color {
2491 SPINNER_FRAMES[step % SPINNER_FRAMES.len()]
2492 } else {
2493 SPINNER_ASCII[step % SPINNER_ASCII.len()]
2494 }
2495}
2496
2497const COMMAND_FRAME_COLS: usize = 5;
2500
2501const MIN_CUT_COMMAND_COLS: usize = 2;
2503
2504const STATUS_SEP: &str = " · ";
2506
2507fn sep() -> Span<'static> {
2509 Span::styled(STATUS_SEP, SECONDARY)
2510}
2511
2512fn labeled(label: &str, value: String) -> Spans {
2514 vec![
2515 Span::styled(format!("{label} "), SECONDARY),
2516 Span::styled(value, ACCENT),
2517 ]
2518}
2519
2520fn spans_cols(spans: &[Span<'static>]) -> usize {
2522 spans.iter().map(|sp| cols(&sp.content)).sum()
2523}
2524
2525fn joined<'a>(parts: impl IntoIterator<Item = &'a Spans>) -> Spans {
2528 let mut out = Spans::new();
2529 for (i, part) in parts.into_iter().enumerate() {
2530 if i > 0 {
2531 out.push(sep());
2532 }
2533 out.extend(part.iter().cloned());
2534 }
2535 out
2536}
2537
2538fn cut_spans(spans: Spans, max: usize) -> Spans {
2540 let full: String = spans.iter().map(|sp| sp.content.as_ref()).collect();
2541 let cut = cut_with_ellipsis(&full, max);
2542 if cut == full {
2543 return spans;
2544 }
2545 let ellipsis = cut.ends_with(ELLIPSIS);
2546 let mut keep = cut.len() - if ellipsis { ELLIPSIS.len() } else { 0 };
2548 let mut out = Spans::new();
2549 for sp in spans {
2550 if keep == 0 {
2551 break;
2552 }
2553 let n = keep.min(sp.content.len());
2554 out.push(Span::styled(sp.content[..n].to_string(), sp.style));
2555 keep -= n;
2556 }
2557 if ellipsis {
2558 out.push(Span::styled(ELLIPSIS, SECONDARY));
2559 }
2560 out
2561}
2562
2563fn fit_status(parts: &StatusParts, width: usize) -> Line<'static> {
2567 if width == 0 {
2568 return Line::default();
2569 }
2570 let lead = || Span::raw(" ");
2571 let more = || Span::styled(format!(" {ELLIPSIS}"), SECONDARY);
2572 let command = |c: &str| vec![sep(), Span::styled(format!("`{c}`"), ACCENT)];
2573 let note = |part: &Option<Spans>| {
2574 part.as_ref()
2575 .map(|p| joined([&Spans::new(), p]))
2576 .unwrap_or_default()
2577 };
2578 let scroll_note = note(&parts.scrolled);
2579 let search_note = note(&parts.search);
2580 let fits = |line: &Spans| spans_cols(line) <= width;
2581 let total = parts.context.len();
2582 for kept in (0..=total).rev() {
2583 let mut line = vec![lead()];
2584 if kept > 0 {
2585 line.extend(joined(&parts.context[..kept]));
2586 line.push(Span::raw(" "));
2587 }
2588 line.extend(parts.state.iter().cloned());
2589 line.extend(scroll_note.iter().cloned());
2590 line.extend(search_note.iter().cloned());
2591 if let Some(c) = &parts.command {
2592 line.extend(command(c));
2593 }
2594 if kept < total {
2595 line.push(more());
2596 }
2597 if fits(&line) {
2598 return Line::from(line);
2599 }
2600 }
2601 let both = [scroll_note.clone(), search_note.clone()].concat();
2604 for note in [both, search_note.clone()] {
2605 let used = 1 + spans_cols(&parts.state) + spans_cols(¬e) + COMMAND_FRAME_COLS;
2606 if let Some(c) = &parts.command
2607 && width >= used + MIN_CUT_COMMAND_COLS
2608 {
2609 let cut = cut_with_ellipsis(c, width - used);
2610 let line = [vec![lead()], parts.state.clone(), note, command(&cut)].concat();
2611 return Line::from(line);
2612 }
2613 }
2614 let head = [vec![lead()], parts.state.clone()].concat();
2616 if parts.search.is_some() {
2617 let line = [head.clone(), search_note, vec![more()]].concat();
2618 if fits(&line) {
2619 return Line::from(line);
2620 }
2621 }
2622 let line = [head.clone(), vec![more()]].concat();
2623 if fits(&line) {
2624 return Line::from(line);
2625 }
2626 Line::from(cut_spans(head, width))
2627}
2628
2629fn entry_starts(lines: &[Line<'static>], width: usize) -> Vec<usize> {
2632 let mut rows = 0;
2633 let mut starts = Vec::with_capacity(lines.len() + 1);
2634 for line in lines {
2635 starts.push(rows);
2636 rows += fit::fit(vec![line.clone()], width).len();
2637 }
2638 starts.push(rows);
2639 starts
2640}
2641
2642fn marked_rows(
2648 scrollback: &[Line<'static>],
2649 extra: Vec<Line<'static>>,
2650 matches: &[usize],
2651 current: Option<usize>,
2652 width: usize,
2653) -> (Vec<Line<'static>>, Vec<usize>) {
2654 let text_width = width.saturating_sub(cols(MATCH_MARK)).max(1);
2655 let gutter = |mut row: Line<'static>, hit: bool, now: bool| {
2656 let mark = if hit { MATCH_MARK } else { MATCH_BLANK };
2657 row.spans.insert(0, Span::raw(mark));
2658 if hit {
2659 let mut style = MATCH;
2660 if now {
2661 style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
2662 }
2663 row = row.patch_style(style);
2664 }
2665 row
2666 };
2667 let mut rows = Vec::new();
2668 let mut starts = Vec::with_capacity(scrollback.len() + 1);
2669 for (i, line) in scrollback.iter().enumerate() {
2670 starts.push(rows.len());
2671 let hit = matches.binary_search(&i).is_ok();
2672 for row in fit::fit(vec![line.clone()], text_width) {
2673 rows.push(gutter(row, hit, current == Some(i)));
2674 }
2675 }
2676 starts.push(rows.len());
2677 for row in fit::fit(extra, text_width) {
2678 rows.push(gutter(row, false, false));
2679 }
2680 (rows, starts)
2681}
2682
2683pub async fn run_invocation(
2686 inv: Invocation,
2687 config: &Config,
2688 clients: &dyn scope::chains::ChainClientFactory,
2689 out: &Output,
2690) -> scope::error::Result<()> {
2691 debug_assert!(!matches!(inv.route, Route::Refuse(_)));
2692 if inv.ai {
2693 let mut config = config.clone();
2694 config.output.format = scope::config::OutputFormat::Markdown;
2695 crate::cli::dispatch::dispatch_with(inv.command, &config, clients, out).await
2696 } else {
2697 crate::cli::dispatch::dispatch_with(inv.command, config, clients, out).await
2698 }
2699}
2700
2701#[cfg(test)]
2706mod tests {
2707 use super::*;
2708 use ratatui::Terminal;
2709 use ratatui::backend::TestBackend;
2710 use ratatui::style::Color;
2711
2712 fn key(code: KeyCode) -> KeyEvent {
2713 KeyEvent::new(code, KeyModifiers::NONE)
2714 }
2715
2716 fn ctrl(c: char) -> KeyEvent {
2717 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
2718 }
2719
2720 fn venues_list() -> crate::cli::Commands {
2721 use clap::Parser;
2722 crate::cli::Cli::try_parse_from(["scope", "venues", "list"])
2723 .unwrap()
2724 .command
2725 }
2726
2727 fn state() -> TuiState {
2728 TuiState::new(
2729 SessionContext::default(),
2730 Journal::default(),
2731 Vocab::default(),
2732 )
2733 }
2734
2735 fn asked(s: &mut TuiState, options: &[&str]) -> tokio::sync::oneshot::Receiver<Option<usize>> {
2736 let (reply, rx) = tokio::sync::oneshot::channel();
2737 *s.asker.lock().unwrap() = Some(crate::cli::output::Ask {
2738 prompt: "Choose one:".into(),
2739 options: options.iter().map(|o| o.to_string()).collect(),
2740 reply,
2741 });
2742 s.poll_ask();
2743 rx
2744 }
2745
2746 #[test]
2747 fn test_the_choice_list_answers_with_the_selected_row() {
2748 let mut s = state();
2749 let mut rx = asked(&mut s, &["ethereum 0xA USDN", "bsc 0xB USDN"]);
2750 assert!(matches!(s.overlay, Some(Overlay::Choice(_))));
2751 s.handle_key(key(KeyCode::Down));
2752 s.handle_key(key(KeyCode::Enter));
2753 assert_eq!(rx.try_recv().unwrap(), Some(1));
2754 assert!(s.overlay.is_none());
2755 }
2756
2757 #[test]
2758 fn test_escape_answers_none_and_does_not_cancel_the_command() {
2759 let mut s = state();
2760 let mut rx = asked(&mut s, &["a", "b"]);
2761 assert!(matches!(s.handle_key(key(KeyCode::Esc)), Action::None));
2762 assert_eq!(rx.try_recv().unwrap(), None);
2763 assert!(s.overlay.is_none());
2764 }
2765
2766 #[test]
2767 fn test_cancelling_the_command_closes_the_choice() {
2768 let mut s = state();
2770 let rx = asked(&mut s, &["a", "b"]);
2771 drop(rx);
2772 s.finish(Ending::Cancelled);
2773 assert!(s.overlay.is_none());
2774 assert!(s.asker.lock().unwrap().is_none());
2775 }
2776
2777 #[test]
2778 fn test_a_long_choice_scrolls_with_the_selection() {
2779 let mut s = state();
2781 let options: Vec<String> = (1..=20).map(|i| format!("chain{i} 0x{i} T")).collect();
2782 let refs: Vec<&str> = options.iter().map(String::as_str).collect();
2783 let _rx = asked(&mut s, &refs);
2784 for _ in 0..14 {
2785 s.handle_key(key(KeyCode::Down));
2786 }
2787 let text = screen(&s, 100, 30);
2788 assert!(text.contains("chain15 0x15 T"), "{text}");
2789 assert!(text.contains("Choose one:"), "{text}");
2790 }
2791
2792 #[test]
2793 fn test_the_choice_keeps_the_selection_in_view_at_the_smallest_size() {
2794 let mut s = state();
2797 let options: Vec<String> = (1..=8).map(|i| format!("opt{i}")).collect();
2798 let refs: Vec<&str> = options.iter().map(String::as_str).collect();
2799 let _rx = asked(&mut s, &refs);
2800 for _ in 0..5 {
2801 s.handle_key(key(KeyCode::Down));
2802 }
2803 let text = screen(&s, MIN_W, MIN_H);
2804 assert!(text.contains("opt6"), "{text}");
2805 }
2806
2807 #[test]
2808 fn test_a_hidden_choice_is_not_answered_by_enter() {
2809 let mut s = state();
2810 let mut rx = asked(&mut s, &["a", "b"]);
2811 let _ = screen(&s, 20, 6); s.handle_key(key(KeyCode::Enter));
2813 assert!(rx.try_recv().is_err(), "no answer yet");
2814 assert!(matches!(s.overlay, Some(Overlay::Choice(_))));
2815 s.handle_key(key(KeyCode::Esc));
2816 assert_eq!(rx.try_recv().unwrap(), None);
2817 }
2818
2819 #[test]
2820 fn test_a_paste_leaves_the_choice_open() {
2821 let mut s = state();
2822 let mut rx = asked(&mut s, &["a", "b"]);
2823 s.paste("USDC");
2824 assert!(matches!(s.overlay, Some(Overlay::Choice(_))));
2825 assert!(rx.try_recv().is_err(), "no answer yet");
2826 assert!(s.input.is_empty());
2827 }
2828
2829 #[test]
2830 fn test_a_question_puts_back_the_history_search_draft() {
2831 let mut s = state();
2832 type_str(&mut s, "draft");
2833 s.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
2834 assert!(matches!(s.overlay, Some(Overlay::HistorySearch(_))));
2835 let _rx = asked(&mut s, &["a", "b"]);
2836 assert!(matches!(s.overlay, Some(Overlay::Choice(_))));
2837 assert_eq!(s.input, "draft");
2838 }
2839
2840 fn type_str(s: &mut TuiState, text: &str) {
2841 for c in text.chars() {
2842 s.handle_key(key(KeyCode::Char(c)));
2843 }
2844 }
2845
2846 fn screen(s: &TuiState, w: u16, h: u16) -> String {
2847 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
2848 term.draw(|f| s.render(f)).unwrap();
2849 let buf = term.backend().buffer().clone();
2850 (0..h)
2851 .map(|y| {
2852 (0..w)
2853 .map(|x| buf[(x, y)].symbol().to_string())
2854 .collect::<String>()
2855 })
2856 .collect::<Vec<_>>()
2857 .join("\n")
2858 }
2859
2860 fn cursor_pos(s: &TuiState, w: u16, h: u16) -> Position {
2861 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
2862 term.draw(|f| s.render(f)).unwrap();
2863 term.get_cursor_position().unwrap()
2864 }
2865
2866 fn vocab_state() -> TuiState {
2867 TuiState::new(
2868 SessionContext::default(),
2869 Journal::default(),
2870 Vocab {
2871 venues: vec!["binance".into(), "bitget".into(), "okx".into()],
2872 ..Vocab::default()
2873 },
2874 )
2875 }
2876
2877 #[test]
2878 fn test_tab_single_candidate_completes_and_adds_space() {
2879 let mut s = vocab_state();
2880 type_str(&mut s, "market su");
2881 s.handle_key(key(KeyCode::Tab));
2882 assert_eq!(s.input, "market summary ");
2883 assert!(s.overlay.is_none());
2884 }
2885
2886 #[test]
2887 fn test_tab_many_candidates_fills_prefix_and_opens_menu() {
2888 let mut s = vocab_state();
2889 type_str(&mut s, "market summary USDC --venue b");
2890 s.handle_key(key(KeyCode::Tab));
2891 assert_eq!(s.input, "market summary USDC --venue bi");
2892 let Some(Overlay::Menu(m)) = &s.overlay else {
2893 panic!("menu expected")
2894 };
2895 assert_eq!(m.candidates, vec!["binance", "bitget"]);
2896 s.handle_key(key(KeyCode::Tab));
2897 s.handle_key(key(KeyCode::Enter));
2898 assert_eq!(s.input, "market summary USDC --venue bitget ");
2899 assert!(
2900 s.overlay.is_none(),
2901 "Enter in the menu accepts; it does not submit"
2902 );
2903 }
2904
2905 #[test]
2906 fn test_tab_mid_line_does_nothing() {
2907 let mut s = vocab_state();
2909 type_str(&mut s, "market su");
2910 s.handle_key(key(KeyCode::Left));
2911 s.handle_key(key(KeyCode::Tab));
2912 assert_eq!(s.input, "market su");
2913 assert!(s.overlay.is_none());
2914 }
2915
2916 #[test]
2917 fn test_menu_escape_and_other_keys_close_it() {
2918 let mut s = vocab_state();
2919 type_str(&mut s, "ad");
2920 s.handle_key(key(KeyCode::Tab));
2921 assert!(matches!(s.overlay, Some(Overlay::Menu(_))));
2922 s.handle_key(key(KeyCode::Esc));
2923 assert!(s.overlay.is_none());
2924 assert_eq!(s.input, "address", "Esc closes the menu, not the line");
2925 s.handle_key(key(KeyCode::Tab));
2926 type_str(&mut s, "-");
2927 assert!(s.overlay.is_none());
2928 assert_eq!(s.input, "address-");
2929 }
2930
2931 #[test]
2932 fn test_tab_works_while_a_command_runs() {
2933 let _lock = super::super::DIAG_LOCK.blocking_lock();
2935 let mut s = vocab_state();
2936 let inv = Invocation {
2937 command: venues_list(),
2938 route: Route::Pane,
2939 ai: false,
2940 argv: vec![],
2941 };
2942 s.start("venues list", &inv);
2943 type_str(&mut s, "market su");
2944 s.handle_key(key(KeyCode::Tab));
2945 assert_eq!(s.input, "market summary ");
2946 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
2947 s.finish(Ending::Cancelled);
2948 }
2949
2950 #[test]
2951 fn test_menu_renders_bounded_and_tiny_terminal_is_safe() {
2952 let mut s = TuiState::new(
2954 SessionContext::default(),
2955 Journal::default(),
2956 Vocab {
2957 venues: (0..40).map(|i| format!("v{:02}", i)).collect(),
2958 ..Vocab::default()
2959 },
2960 );
2961 type_str(&mut s, "market summary X --venue v");
2962 s.handle_key(key(KeyCode::Tab));
2963 for _ in 0..20 {
2964 s.handle_key(key(KeyCode::Tab));
2965 }
2966 let text = screen(&s, 60, 20);
2967 assert!(
2968 text.contains("v20"),
2969 "the selection stays visible: {}",
2970 text
2971 );
2972 assert!(!text.contains("v00"), "at most 8 rows: {}", text);
2973 screen(&s, 10, 5);
2974 }
2975
2976 fn ctrl_k() -> KeyEvent {
2977 KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
2978 }
2979
2980 #[test]
2981 fn test_ctrl_k_opens_palette_and_enter_fills_input_without_running() {
2982 let mut s = state();
2983 s.handle_key(ctrl_k());
2984 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
2985 type_str(&mut s, "msum");
2986 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
2987 assert_eq!(s.input, "market summary ");
2988 assert!(s.overlay.is_none());
2989 }
2990
2991 fn repeating(s: &mut TuiState, n: u64, fail: u64) {
2994 let inv = Invocation {
2995 command: venues_list(),
2996 route: Route::Pane,
2997 ai: false,
2998 argv: vec![],
2999 };
3000 let out = s.start("market summary USDC --every 1s", &inv);
3001 let metrics = scope::metrics::summary_metrics(&scope::market::HealthThresholds::default());
3002 for run in 1..=n {
3003 out.record_run(
3004 &metrics,
3005 crate::cli::output::RunRecord {
3006 run,
3007 at: Utc::now(),
3008 sample: scope::metrics::Sample {
3009 values: vec![Some(1.0); metrics.len()],
3010 healthy: run != fail,
3011 failed_checks: vec![format!("spread {run}%")],
3012 },
3013 snapshot: format!("SNAPSHOT {run}\n"),
3014 view: None,
3015 },
3016 );
3017 }
3018 }
3019
3020 #[test]
3021 fn test_a_one_shot_market_summary_shows_the_compact_view_and_copies_the_text() {
3022 use scope::market::{BookSource, MarketSummary, OrderBook, OrderBookLevel};
3025 let _lock = super::super::DIAG_LOCK.blocking_lock();
3026 let mut s = state();
3027 let inv = Invocation {
3028 command: venues_list(),
3029 route: Route::Pane,
3030 ai: false,
3031 argv: vec![],
3032 };
3033 let out = s.start("market summary USDC --venue binance", &inv);
3034 crate::note!(out, "Using the saved token choice");
3035 crate::out!(
3036 out,
3037 "CLI REPORT
3038Bid Side — 20 levels
3039"
3040 )
3041 .unwrap();
3042 let level = |price: f64| OrderBookLevel {
3043 price,
3044 quantity: 1000.0,
3045 };
3046 let book = OrderBook {
3047 pair: "USDC/USDT".to_string(),
3048 source: BookSource::Exchange,
3049 bids: vec![level(0.9999)],
3050 asks: vec![level(1.0001)],
3051 };
3052 assert!(out.record_view(crate::cli::output::SummaryView {
3053 summary: MarketSummary::from_order_book(
3054 &book,
3055 &scope::market::HealthThresholds::default(),
3056 None
3057 ),
3058 venue: "binance".to_string(),
3059 tokens: Vec::new(),
3060 }));
3061 s.finish(Ending::Finished(Ok(())));
3062 let shown = s.scrollback_text();
3063 assert!(shown.contains("USDC/USDT · binance"), "{shown}");
3064 assert!(!shown.contains("CLI REPORT"), "{shown}");
3065 assert!(shown.contains("Using the saved token choice"), "{shown}");
3066 assert_eq!(
3067 s.last_output.as_deref(),
3068 Some(
3069 "CLI REPORT
3070Bid Side — 20 levels
3071"
3072 )
3073 );
3074 }
3075
3076 #[test]
3077 fn test_a_repeat_run_draws_the_view_not_one_report_per_run() {
3078 let _lock = super::super::DIAG_LOCK.blocking_lock();
3079 let mut s = state();
3080 repeating(&mut s, 3, 2);
3081 let text = screen(&s, 120, 36);
3082 assert!(text.contains("SNAPSHOT 3"), "{text}");
3083 assert!(!text.contains("SNAPSHOT 1"), "{text}");
3084 assert!(text.contains("Trends · run 3"), "{text}");
3085 assert!(text.contains("run 2: spread 2%"), "{text}");
3086 s.finish(Ending::Cancelled);
3087 }
3088
3089 #[test]
3090 fn test_the_view_stays_after_esc_and_the_next_key_leaves_it_once() {
3091 let _lock = super::super::DIAG_LOCK.blocking_lock();
3092 let mut s = state();
3093 repeating(&mut s, 3, 0);
3094 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
3095 s.finish(Ending::Cancelled);
3096 let text = screen(&s, 120, 36);
3097 assert!(text.contains("Cancelled after"), "{text}");
3098 assert!(text.contains(repeat_view::LEAVE_HINT), "{text}");
3099 assert!(!s.scrollback_text().contains("SNAPSHOT"), "not yet pushed");
3100 assert_eq!(s.handle_key(key(KeyCode::Char('x'))), Action::None);
3102 assert_eq!(s.input, "x");
3103 let back = s.scrollback_text();
3104 assert_eq!(back.matches("SNAPSHOT 3").count(), 1, "{back}");
3105 assert!(!back.contains("SNAPSHOT 1"), "{back}");
3106 assert!(back.contains("3 runs · none failed"), "{back}");
3107 assert!(back.contains("Cancelled after"), "{back}");
3108 assert!(!screen(&s, 120, 36).contains("Trends"), "the view is gone");
3109 }
3110
3111 #[test]
3112 fn test_copy_and_save_take_the_final_snapshot_and_the_series() {
3113 let _lock = super::super::DIAG_LOCK.blocking_lock();
3114 let mut s = state();
3115 repeating(&mut s, 2, 0);
3116 s.finish(Ending::Finished(Ok(())));
3117 assert_eq!(s.last_output.as_deref(), Some("SNAPSHOT 2\n"));
3118 let csv = s
3119 .last_series
3120 .as_ref()
3121 .and_then(repeat_view::series_csv)
3122 .unwrap();
3123 assert_eq!(csv.lines().count(), 3, "header and two runs: {csv}");
3124 }
3125
3126 fn alt(c: char) -> KeyEvent {
3127 KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)
3128 }
3129
3130 #[test]
3131 fn test_readline_word_moves_and_kill_to_end() {
3132 let mut s = state();
3133 type_str(&mut s, "market summary USDC");
3134 s.handle_key(alt('b'));
3135 assert_eq!(s.cursor, 15, "Alt-B: start of USDC");
3136 s.handle_key(alt('b'));
3137 assert_eq!(s.cursor, 7, "Alt-B: start of summary");
3138 s.handle_key(alt('f'));
3139 assert_eq!(s.cursor, 14, "Alt-F: end of summary");
3140 s.handle_key(ctrl('b'));
3141 assert_eq!(s.cursor, 13, "Ctrl-B: one left");
3142 s.handle_key(alt('k'));
3143 assert_eq!(s.input, "market summar", "Alt-K: deletes to the end");
3144 }
3145
3146 #[test]
3147 fn test_up_brings_back_a_line_that_esc_cleared() {
3148 let mut s = state();
3149 type_str(&mut s, "market summary USDC --venue binance --every 30s");
3150 s.handle_key(key(KeyCode::Esc));
3151 assert_eq!(s.input, "");
3152 s.handle_key(key(KeyCode::Up));
3153 assert_eq!(s.input, "market summary USDC --venue binance --every 30s");
3154 }
3155
3156 #[test]
3157 fn test_clear_empties_the_pane_and_reset_resets_the_context() {
3158 let mut s = state();
3159 s.push_note("old output");
3160 s.ctx.limit = 7;
3161 s.submit("clear", &Config::default());
3162 assert!(!s.scrollback_text().contains("old output"));
3163 assert_eq!(s.ctx.limit, 7, "clear leaves the context");
3164 s.submit("reset", &Config::default());
3165 assert_eq!(s.ctx.limit, SessionContext::default().limit);
3166 }
3167
3168 #[test]
3169 fn test_the_pane_says_when_it_dropped_old_lines() {
3170 let mut s = state();
3171 let big: String = (0..SCROLLBACK_LIMIT + 7)
3172 .map(|i| format!("l{i}\n"))
3173 .collect();
3174 s.push_ansi(&big);
3175 assert!(s.dropped_lines > 0);
3176 let rows = 200;
3177 s.scroll.set(usize::MAX / 2);
3178 let text = screen(&s, 100, rows);
3179 assert!(text.contains("older lines dropped"), "{text}");
3180 }
3181
3182 #[test]
3183 fn test_ctrl_c_in_palette_still_cancels_a_running_command() {
3184 let _lock = super::super::DIAG_LOCK.blocking_lock();
3186 let mut s = state();
3187 let inv = Invocation {
3188 command: venues_list(),
3189 route: Route::Pane,
3190 ai: false,
3191 argv: vec![],
3192 };
3193 s.start("venues list", &inv);
3194 s.handle_key(ctrl_k());
3195 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
3196 assert!(s.overlay.is_none(), "Ctrl-C also closes the palette");
3197 s.finish(Ending::Cancelled);
3198 }
3199
3200 #[test]
3201 fn test_palette_escape_keeps_the_input() {
3202 let mut s = state();
3203 type_str(&mut s, "draft");
3204 s.handle_key(ctrl_k());
3205 type_str(&mut s, "zzz");
3206 s.handle_key(key(KeyCode::Esc));
3207 assert!(s.overlay.is_none());
3208 assert_eq!(s.input, "draft");
3209 }
3210
3211 #[test]
3212 fn test_palette_with_long_history_renders_bounded() {
3213 let mut s = state();
3215 for i in 0..60 {
3216 type_str(&mut s, &format!("venues list {}", i));
3217 s.handle_key(key(KeyCode::Enter));
3218 }
3219 s.handle_key(ctrl_k());
3220 for _ in 0..30 {
3221 s.handle_key(key(KeyCode::Down));
3222 }
3223 screen(&s, 80, 20);
3224 screen(&s, 10, 5);
3225 }
3226
3227 #[test]
3228 fn test_editing_keys() {
3229 let mut s = state();
3230 type_str(&mut s, "adress");
3231 s.handle_key(key(KeyCode::Left));
3232 s.handle_key(key(KeyCode::Left));
3233 s.handle_key(key(KeyCode::Left));
3234 s.handle_key(key(KeyCode::Left));
3235 type_str(&mut s, "d");
3236 assert_eq!(s.input, "address");
3237 s.handle_key(key(KeyCode::End));
3238 type_str(&mut s, " 0xab cd");
3239 s.handle_key(ctrl('w'));
3240 assert_eq!(s.input, "address 0xab ");
3241 s.handle_key(key(KeyCode::Backspace));
3242 s.handle_key(ctrl('u'));
3243 assert_eq!(s.input, "");
3244 }
3245
3246 #[test]
3247 fn test_cursor_keys_and_idle_escape() {
3248 let mut s = state();
3249 type_str(&mut s, "bc");
3250 s.handle_key(key(KeyCode::Home));
3251 type_str(&mut s, "a");
3252 s.handle_key(ctrl('e'));
3253 type_str(&mut s, "d");
3254 assert_eq!(s.input, "abcd");
3255 s.handle_key(ctrl('a'));
3256 s.handle_key(key(KeyCode::Delete));
3257 assert_eq!(s.input, "bcd");
3258 s.handle_key(key(KeyCode::End));
3259 s.handle_key(key(KeyCode::Delete));
3260 assert_eq!(s.input, "bcd", "Delete at the end does nothing");
3261 s.handle_key(key(KeyCode::Right));
3262 type_str(&mut s, "e");
3263 assert_eq!(s.input, "bcde");
3264 s.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT));
3266 assert_eq!(s.input, "bcde");
3267 s.handle_key(key(KeyCode::Esc));
3268 assert_eq!(s.input, "", "Esc clears the line when idle");
3269 s.handle_key(ctrl('c'));
3271 assert!(s.scrollback_text().contains("Ctrl-D to leave"));
3272 s.handle_key(key(KeyCode::Up));
3274 assert_eq!(s.input, "bcde");
3275 s.cleared = None;
3277 s.set_input(String::new());
3278 s.handle_key(key(KeyCode::Up));
3279 s.handle_key(key(KeyCode::Down));
3280 assert_eq!(s.input, "");
3281 s.handle_key(key(KeyCode::PageDown));
3282 }
3283
3284 #[test]
3285 fn test_multibyte_input_does_not_panic() {
3286 let mut s = state();
3287 type_str(&mut s, "tokens add €UR");
3288 s.handle_key(key(KeyCode::Left));
3289 s.handle_key(key(KeyCode::Left));
3290 s.handle_key(key(KeyCode::Backspace));
3291 assert_eq!(s.input, "tokens add UR");
3292 }
3293
3294 #[test]
3299 fn test_line_feed_from_cooked_mode_submits_like_enter() {
3300 for lf in [ctrl('j'), key(KeyCode::Char('\n'))] {
3301 let mut s = state();
3302 type_str(&mut s, "ctx");
3303 assert_eq!(s.handle_key(lf), Action::Submit("ctx".into()), "{lf:?}");
3304 assert_eq!(s.input, "");
3305 }
3306 }
3307
3308 #[test]
3311 fn test_line_feed_picks_palette_entry_like_enter() {
3312 let mut s = state();
3313 s.handle_key(ctrl('k'));
3314 s.handle_key(ctrl('j'));
3315 assert!(s.overlay.is_none());
3316 assert!(!s.input.contains('\n'));
3317 }
3318
3319 #[test]
3320 fn test_enter_submits_and_records_history() {
3321 let mut s = state();
3322 type_str(&mut s, "venues list");
3323 assert_eq!(
3324 s.handle_key(key(KeyCode::Enter)),
3325 Action::Submit("venues list".into())
3326 );
3327 assert_eq!(s.input, "");
3328 type_str(&mut s, "venues list");
3329 s.handle_key(key(KeyCode::Enter));
3330 assert_eq!(s.history, vec!["venues list"]);
3332 }
3333
3334 #[test]
3335 fn test_history_browsing_restores_draft() {
3336 let mut s = state();
3337 for cmd in ["one", "two"] {
3338 type_str(&mut s, cmd);
3339 s.handle_key(key(KeyCode::Enter));
3340 }
3341 type_str(&mut s, "draft");
3342 s.handle_key(key(KeyCode::Up));
3343 assert_eq!(s.input, "two");
3344 s.handle_key(key(KeyCode::Up));
3345 s.handle_key(key(KeyCode::Up));
3346 assert_eq!(s.input, "one");
3347 s.handle_key(key(KeyCode::Down));
3348 assert_eq!(s.input, "two");
3349 s.handle_key(key(KeyCode::Down));
3350 assert_eq!(s.input, "draft");
3351 }
3352
3353 #[test]
3354 fn test_ctrl_z_suspends_and_keeps_the_draft_and_the_palette() {
3355 let mut s = state();
3358 type_str(&mut s, "draf");
3359 assert_eq!(s.handle_key(ctrl('z')), Action::Suspend);
3360 assert_eq!(s.input, "draf");
3361 s.handle_key(ctrl('k'));
3362 assert_eq!(s.handle_key(ctrl('z')), Action::Suspend);
3363 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
3364 }
3365
3366 #[test]
3367 fn test_ctrl_c_and_esc_cancel_only_when_running() {
3368 let _lock = super::super::DIAG_LOCK.blocking_lock();
3369 let mut s = state();
3370 type_str(&mut s, "abc");
3371 assert_eq!(s.handle_key(ctrl('c')), Action::None);
3372 assert_eq!(s.input, "", "Ctrl-C clears the line when idle");
3373 assert_eq!(s.handle_key(ctrl('d')), Action::Exit);
3374
3375 let inv = Invocation {
3376 command: venues_list(),
3377 route: Route::Pane,
3378 ai: false,
3379 argv: vec![],
3380 };
3381 s.start("venues list", &inv);
3382 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
3383 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
3384 assert_eq!(
3385 s.handle_key(ctrl('d')),
3386 Action::None,
3387 "no exit while running"
3388 );
3389 type_str(&mut s, "x");
3390 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
3391 assert_eq!(s.input, "x", "the next line waits for the command to end");
3392 s.finish(Ending::Cancelled);
3393 assert!(s.scrollback_text().contains("Cancelled after"));
3394 }
3395
3396 #[test]
3397 fn test_session_command_output_reaches_scrollback() {
3398 let mut s = state();
3399 let plan = s.submit("chain base", &Config::default());
3400 assert!(matches!(plan, Plan::Done));
3401 let text = s.scrollback_text();
3402 assert!(text.contains("scope:auto> chain base"));
3403 assert!(text.contains("Chain pinned to: base"));
3404 assert_eq!(s.ctx.chain, "base");
3405 }
3406
3407 #[test]
3408 fn test_ansi_colors_become_styles_not_escape_codes() {
3409 let mut s = state();
3410 s.push_ansi("\x1b[31mred\x1b[0m plain\n");
3411 assert_eq!(s.scrollback_text(), "red plain\n");
3412 let first = &s.scrollback[0].spans[0];
3413 assert_eq!(first.style.fg, Some(Color::Red));
3414 }
3415
3416 #[test]
3417 fn test_scrollback_is_capped() {
3418 let mut s = state();
3419 let big: String = (0..SCROLLBACK_LIMIT + 50)
3420 .map(|i| format!("l{}\n", i))
3421 .collect();
3422 s.push_ansi(&big);
3423 assert_eq!(s.scrollback.len(), SCROLLBACK_LIMIT);
3424 assert!(s.scrollback_text().starts_with("l50\n"));
3425 }
3426
3427 #[test]
3428 fn test_render_shows_latest_output_prompt_and_status() {
3429 let mut s = state();
3430 for i in 0..40 {
3431 s.push_ansi(&format!("line {}\n", i));
3432 }
3433 type_str(&mut s, "venues");
3434 let text = screen(&s, 60, 15);
3435 assert!(text.contains("line 39"), "{}", text);
3436 assert!(!text.contains("line 0 "), "{}", text);
3437 assert!(text.contains("scope:auto>"));
3438 assert!(text.contains("venues"));
3439 assert!(text.contains("chain auto"));
3440
3441 s.handle_key(key(KeyCode::PageUp));
3442 let text = screen(&s, 60, 15);
3443 assert!(!text.contains("line 39"), "{}", text);
3444 assert!(text.contains("scrolled up 7 lines"), "{}", text);
3446 }
3447
3448 fn status_row(s: &TuiState, w: u16) -> String {
3451 let line = fit_status(&s.status_parts(), w as usize);
3452 let text: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
3453 format!("{:<width$}", text, width = w as usize)
3454 }
3455
3456 fn running_state(input: &str) -> (TuiState, Output) {
3457 let mut s = state();
3458 let inv = Invocation {
3459 command: venues_list(),
3460 route: Route::Pane,
3461 ai: false,
3462 argv: vec![],
3463 };
3464 let out = s.start(input, &inv);
3465 (s, out)
3466 }
3467
3468 fn status_text(s: &TuiState) -> String {
3470 let line = fit_status(&s.status_parts(), 160);
3471 line.spans.iter().map(|sp| sp.content.as_ref()).collect()
3472 }
3473
3474 #[test]
3475 fn test_esc_hint_says_what_esc_does_with_each_overlay_open() {
3476 let _lock = super::super::DIAG_LOCK.blocking_lock();
3479 type Open = fn(&mut TuiState);
3480 let opens: [(&str, Open); 4] = [
3481 ("Esc closes palette", |s| {
3482 s.handle_key(ctrl('k'));
3483 }),
3484 ("Esc closes menu", |s| {
3485 type_str(s, "ad");
3486 s.handle_key(key(KeyCode::Tab));
3487 }),
3488 ("Esc closes search", |s| {
3489 s.handle_key(ctrl('f'));
3490 }),
3491 ("Esc closes history search", |s| {
3492 s.handle_key(ctrl('r'));
3493 }),
3494 ];
3495 for (hint, open) in opens {
3496 let mut s = vocab_state();
3497 let inv = Invocation {
3498 command: venues_list(),
3499 route: Route::Pane,
3500 ai: false,
3501 argv: vec![],
3502 };
3503 let _out = s.start("venues list", &inv);
3504 screen(&s, 80, 24);
3505 open(&mut s);
3506 assert!(s.overlay.is_some(), "{hint}: no overlay");
3507 let bar = status_text(&s);
3508 assert!(bar.contains(hint), "{hint}: {bar:?}");
3509 assert!(!bar.contains("Esc cancels"), "{hint}: {bar:?}");
3510 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::None, "{hint}");
3513 assert!(s.overlay.is_none(), "{hint}");
3514 assert!(status_text(&s).contains("Esc cancels"), "{hint}");
3515 }
3516 }
3517
3518 #[test]
3519 fn test_idle_esc_hint_shows_only_while_an_overlay_is_open() {
3520 let mut s = state();
3521 assert!(!status_text(&s).contains("Esc"), "{}", status_text(&s));
3522 screen(&s, 80, 24);
3523 s.handle_key(ctrl('k'));
3524 assert!(status_text(&s).contains("Esc closes palette"));
3525 s.handle_key(key(KeyCode::Esc));
3526 assert!(status_text(&s).contains("help"), "{}", status_text(&s));
3527 }
3528
3529 #[test]
3530 fn test_trimmed_outside_output_is_not_kept_for_copy_or_save() {
3531 let mut s = state();
3534 let (o, done) = Output::capture_merged();
3535 crate::outln!(o, "earlier data").unwrap();
3536 s.show_outside(&done, &Ending::Finished(Ok(())));
3537 assert_eq!(s.last_output.as_deref(), Some("earlier data\n"));
3538 let (terminal, _screen) = Output::capture();
3539 let (o, long) = terminal.teed(64);
3540 for i in 0..100 {
3541 crate::outln!(o, "row {i}").unwrap();
3542 }
3543 assert!(long.trimmed());
3544 s.show_outside(&long, &Ending::Finished(Ok(())));
3545 assert_eq!(s.last_output, None, "neither the part nor the old copy");
3546 assert!(
3547 s.scrollback_text()
3548 .contains("only the end is kept here, and Ctrl-Y and save do not have it."),
3549 "{}",
3550 s.scrollback_text()
3551 );
3552 assert!(matches!(s.handle_key(ctrl('y')), Action::None));
3553 assert!(s.scrollback_text().contains("nothing to copy yet"));
3554 }
3555
3556 #[test]
3557 fn test_an_outside_run_that_prompted_keeps_only_its_data_for_copy() {
3558 let mut s = state();
3561 let (terminal, _screen) = Output::capture();
3562 let (o, copy) = terminal.teed(1 << 20);
3563 let pick = crate::cli::prompt::pick(
3564 &mut std::io::Cursor::new(b"1\n".to_vec()),
3565 &o,
3566 "1 USDC\n",
3567 "token",
3568 1,
3569 );
3570 assert_eq!(pick.unwrap(), 0);
3571 crate::cli::prompt::ask(
3572 &mut std::io::Cursor::new(b"n\n".to_vec()),
3573 &o,
3574 "Save this token for future use? [y/N]: ",
3575 )
3576 .unwrap();
3577 crate::outln!(o, "report").unwrap();
3578 s.show_outside(©, &Ending::Finished(Ok(())));
3579 assert_eq!(s.last_output.as_deref(), Some("report\n"));
3580 let shown = s.scrollback_text();
3581 assert!(
3582 shown.contains("Select token (1-1)"),
3583 "the user still sees it"
3584 );
3585 }
3586
3587 #[test]
3588 fn test_a_failed_outside_command_sets_the_failed_pane_state() {
3589 let mut s = state();
3591 let err = Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into())));
3592 s.record_suspended(
3593 "crawl USDC",
3594 &[],
3595 std::time::Duration::from_secs(1),
3596 String::new(),
3597 &err,
3598 );
3599 assert_eq!(s.pane_state(), PaneState::Failed);
3600 for ok in [Ending::Finished(Ok(())), Ending::Cancelled] {
3601 s.submit("crawl USDC", &Config::default());
3602 s.record_suspended(
3603 "crawl USDC",
3604 &[],
3605 std::time::Duration::from_secs(1),
3606 String::new(),
3607 &ok,
3608 );
3609 assert_eq!(s.pane_state(), PaneState::Idle);
3610 }
3611 }
3612
3613 #[test]
3614 fn test_status_bar_keeps_the_running_state_at_every_width() {
3615 let _lock = super::super::DIAG_LOCK.blocking_lock();
3618 let (s, _out) = running_state("market summary USDC --venue binance --every 2s");
3619 for w in [120, 60, 40, 30] {
3620 let row = status_row(&s, w);
3621 assert!(row.contains("running "), "{w}: {row:?}");
3622 assert!(row.contains("Esc cancels"), "{w}: {row:?}");
3623 }
3624 let wide = status_row(&s, 120);
3625 assert!(
3626 wide.contains("limit") && wide.contains("format"),
3627 "{wide:?}"
3628 );
3629 assert!(wide.contains("market summary USDC"), "{wide:?}");
3630 }
3631
3632 #[test]
3633 fn test_status_bar_drops_limit_and_format_before_the_cancel_hint() {
3634 let _lock = super::super::DIAG_LOCK.blocking_lock();
3635 let (s, _out) = running_state("market summary USDC --venue binance --every 2s");
3636 let row = status_row(&s, 40);
3637 assert!(!row.contains("limit"), "{row:?}");
3638 assert!(!row.contains("format"), "{row:?}");
3639 assert!(row.contains('…'), "{row:?}");
3641 }
3642
3643 #[test]
3644 fn test_idle_status_bar_keeps_help_at_a_narrow_width() {
3645 let s = state();
3646 let row = status_row(&s, 30);
3647 assert!(row.contains("help"), "{row:?}");
3648 assert!(!row.contains("limit"), "{row:?}");
3649 }
3650
3651 #[test]
3652 fn test_scrolled_status_bar_keeps_the_scroll_hint_at_a_narrow_width() {
3653 let mut s = state();
3654 for i in 0..40 {
3655 s.push_ansi(&format!("line {}\n", i));
3656 }
3657 screen(&s, 40, 12);
3658 s.handle_key(key(KeyCode::PageUp));
3659 let row = status_row(&s, 40);
3660 assert!(row.contains("scrolled up 4 lines"), "{row:?}");
3661 }
3662
3663 fn scrollable() -> TuiState {
3665 let mut s = state();
3666 for i in 0..30 {
3667 s.push_ansi(&format!("line {}\n", i));
3668 }
3669 screen(&s, 60, 15);
3670 s
3671 }
3672
3673 #[test]
3674 fn test_page_up_stops_at_the_top_so_page_down_always_moves() {
3675 let mut s = scrollable();
3676 for _ in 0..50 {
3677 s.handle_key(key(KeyCode::PageUp));
3678 }
3679 let top = screen(&s, 60, 15);
3680 assert!(top.contains("scrolled up 22 lines"), "{top}");
3681 s.handle_key(key(KeyCode::PageDown));
3682 let after = screen(&s, 60, 15);
3683 assert_ne!(top, after, "PageDown must change the view");
3684 assert!(after.contains("scrolled up 15 lines"), "{after}");
3685 }
3686
3687 #[test]
3688 fn test_ctrl_home_and_ctrl_end_jump_to_the_top_and_the_bottom() {
3689 let mut s = scrollable();
3690 s.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL));
3691 assert!(screen(&s, 60, 15).contains("line 0"));
3692 assert!(screen(&s, 60, 15).contains("scrolled up 22 lines"));
3693 s.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL));
3694 let text = screen(&s, 60, 15);
3695 assert!(text.contains("line 29"), "{text}");
3696 assert!(!text.contains("scrolled up"), "{text}");
3697 }
3698
3699 #[test]
3700 fn test_shift_arrows_scroll_one_line_and_plain_arrows_keep_the_history() {
3701 let mut s = scrollable();
3702 s.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT));
3703 s.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT));
3704 assert!(screen(&s, 60, 15).contains("scrolled up 2 lines"));
3705 s.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
3706 assert!(screen(&s, 60, 15).contains("scrolled up 1 lines"));
3707 s.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
3709 s.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
3710 assert!(!screen(&s, 60, 15).contains("scrolled up"));
3711 s.history.push("venues list".into());
3713 s.handle_key(key(KeyCode::Up));
3714 assert_eq!(s.input, "venues list");
3715 }
3716
3717 #[test]
3718 fn test_the_wheel_scrolls_three_lines_and_stops_at_the_top() {
3719 let mut s = scrollable();
3720 s.wheel(3);
3721 assert!(screen(&s, 60, 15).contains("scrolled up 3 lines"));
3722 for _ in 0..20 {
3723 s.wheel(3);
3724 }
3725 assert!(screen(&s, 60, 15).contains("scrolled up 22 lines"));
3726 s.wheel(-3);
3727 assert!(screen(&s, 60, 15).contains("scrolled up 19 lines"));
3728 s.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
3730 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
3731 s.wheel(3);
3732 assert_eq!(s.scroll.get(), 19);
3733 }
3734
3735 #[test]
3736 fn test_help_opens_at_its_first_line() {
3737 let mut s = scrollable();
3738 let config = scope::config::Config::default();
3739 let _ = s.submit("help", &config);
3740 let text = screen(&s, 60, 15);
3741 assert!(text.contains("Session commands:"), "{text}");
3742 assert!(text.contains("scrolled up"), "the rest is below: {text}");
3743 s.handle_key(key(KeyCode::PageDown));
3745 assert!(!screen(&s, 60, 15).contains("Session commands:"));
3746 }
3747
3748 #[test]
3749 fn test_the_hint_row_shows_the_navigation_keys_that_fit() {
3750 let mut s = scrollable();
3751 let text = screen(&s, 120, 15);
3752 assert!(text.contains("PgUp/wheel/Shift-↑ scroll back"), "{text}");
3753 assert!(text.contains("↑↓ history"), "{text}");
3754 assert!(text.contains("Ctrl-K commands"), "{text}");
3755 s.handle_key(key(KeyCode::PageUp));
3757 let text = screen(&s, 120, 15);
3758 assert!(text.contains("PgDn/wheel/Shift-↓ down"), "{text}");
3759 assert!(text.contains("Ctrl-End bottom"), "{text}");
3760 let line = nav_hints(&s.nav_keys(), 40);
3762 let shown: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
3763 assert!(UnicodeWidthStr::width(shown.as_str()) <= 40, "{shown:?}");
3764 assert!(
3765 shown.ends_with("down") || shown.ends_with("bottom"),
3766 "{shown:?}"
3767 );
3768 type_str(&mut s, "market summary");
3770 assert!(!screen(&s, 120, 15).contains("↑↓ history"));
3771 }
3772
3773 #[test]
3774 fn test_end_on_an_empty_input_jumps_to_the_bottom_else_moves_the_cursor() {
3775 let mut s = scrollable();
3776 s.handle_key(key(KeyCode::PageUp));
3777 s.handle_key(key(KeyCode::End));
3778 assert_eq!(s.scroll.get(), 0);
3779 type_str(&mut s, "abc");
3780 s.handle_key(key(KeyCode::Home));
3781 s.handle_key(key(KeyCode::PageUp));
3782 s.handle_key(key(KeyCode::End));
3783 assert_eq!(s.cursor, 3, "End moves the cursor on a non-empty input");
3784 assert_eq!(s.scroll.get(), 7, "and leaves the view alone");
3785 }
3786
3787 #[test]
3788 fn test_keys_that_edit_the_input_snap_the_view_to_the_bottom() {
3789 for edit in ["char", "backspace", "delete", "paste"] {
3790 let mut s = scrollable();
3791 type_str(&mut s, "ab");
3792 s.handle_key(key(KeyCode::Left));
3793 s.handle_key(key(KeyCode::PageUp));
3794 assert!(s.scroll.get() > 0, "{edit}");
3795 match edit {
3796 "char" => drop(s.handle_key(key(KeyCode::Char('a')))),
3797 "backspace" => drop(s.handle_key(key(KeyCode::Backspace))),
3798 "delete" => drop(s.handle_key(key(KeyCode::Delete))),
3799 _ => s.paste("x"),
3800 }
3801 assert_eq!(s.scroll.get(), 0, "{edit}");
3802 }
3803 }
3804
3805 #[test]
3806 fn test_navigation_keys_and_modifiers_alone_do_not_snap_the_view() {
3807 let mut s = scrollable();
3808 s.handle_key(key(KeyCode::PageUp));
3809 for k in [
3810 key(KeyCode::Left),
3811 key(KeyCode::Right),
3812 key(KeyCode::Home),
3813 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
3814 key(KeyCode::Tab),
3815 ] {
3816 s.handle_key(k);
3817 assert_eq!(s.scroll.get(), 7, "{k:?}");
3818 }
3819 }
3820
3821 #[test]
3822 fn test_status_shows_the_running_state_and_the_scroll_state_together() {
3823 let _lock = super::super::DIAG_LOCK.blocking_lock();
3824 let (mut s, _out) = running_state("market summary USDC --every 2s");
3825 for i in 0..40 {
3826 s.push_ansi(&format!("line {}\n", i));
3827 }
3828 screen(&s, 120, 12);
3829 s.handle_key(key(KeyCode::PageUp));
3830 let row = status_row(&s, 120);
3831 assert!(row.contains("running "), "{row:?}");
3832 assert!(row.contains("scrolled up 4 lines"), "{row:?}");
3833 let row = status_row(&s, 40);
3835 assert!(row.contains("Esc cancels"), "{row:?}");
3836 }
3837
3838 const TOO_SMALL: &str = "Terminal too small: need at least 40×10";
3839
3840 #[test]
3841 fn test_the_too_small_text_is_the_spec_text() {
3842 assert_eq!(too_small_note(), TOO_SMALL);
3843 }
3844
3845 #[test]
3846 fn test_below_the_minimum_size_only_the_too_small_line_is_drawn() {
3847 let mut s = state();
3848 s.push_ansi("some output\n");
3849 type_str(&mut s, "draft");
3850 let text = screen(&s, 39, 9);
3852 assert!(text.contains(TOO_SMALL), "{text}");
3853 let drawn: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
3854 assert_eq!(drawn.len(), 1, "only the message: {text}");
3855 assert!(!text.contains("draft"), "{text}");
3856 assert_eq!(s.input, "draft", "state is kept");
3857 let text = screen(&s, 40, 10);
3858 assert!(!text.contains(TOO_SMALL), "{text}");
3859 assert!(text.contains("draft"), "{text}");
3860 assert!(screen(&s, 40, 9).contains(TOO_SMALL));
3861 assert!(screen(&s, 39, 10).contains(TOO_SMALL));
3862 }
3863
3864 #[test]
3865 fn test_the_too_small_message_wraps_on_words_and_is_centered() {
3866 let s = state();
3867 let text = screen(&s, 30, 8);
3868 let rows: Vec<&str> = text.lines().collect();
3869 let at = rows
3870 .iter()
3871 .position(|r| r.contains("Terminal too small: need at"))
3872 .expect(&text);
3873 assert!(rows[at + 1].contains("least 40×10"), "{text}");
3874 assert_eq!(at, 3, "centered vertically: {text}");
3875 assert!(rows[at].starts_with(' '), "centered horizontally: {text}");
3876 assert!(screen(&s, 30, 1).contains("Terminal too small"));
3878 for w in 0..12 {
3879 for h in 0..4 {
3880 screen(&s, w, h);
3881 }
3882 }
3883 }
3884
3885 #[test]
3886 fn test_too_small_clears_the_pane_width_so_no_override_is_set() {
3887 let s = state();
3888 screen(&s, 80, 24);
3889 assert!(s.pane_width().is_some());
3890 screen(&s, 30, 8);
3891 assert_eq!(s.pane_width(), None);
3892 }
3893
3894 #[test]
3895 fn test_overlays_do_not_act_while_the_terminal_is_too_small() {
3896 let mut s = state();
3897 type_str(&mut s, "dra");
3898 s.handle_key(ctrl('k'));
3899 assert!(s.overlay.is_some());
3900 screen(&s, 30, 8);
3901 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3902 assert!(s.overlay.is_none(), "the overlay is closed");
3903 assert_eq!(s.input, "dra", "the palette did not fill the input");
3904 screen(&s, 80, 24);
3907 s.handle_key(ctrl('k'));
3908 screen(&s, 30, 8);
3909 s.handle_key(key(KeyCode::Char('x')));
3910 assert!(s.overlay.is_none());
3911 assert_eq!(s.input, "dra");
3912 screen(&s, 80, 24);
3914 s.handle_key(ctrl('k'));
3915 screen(&s, 30, 8);
3916 s.handle_key(key(KeyCode::Esc));
3917 assert!(s.overlay.is_none());
3918 assert_eq!(s.input, "dra");
3919 }
3920
3921 #[test]
3922 fn test_enter_does_nothing_while_too_small_and_a_command_runs() {
3923 let _lock = super::super::DIAG_LOCK.blocking_lock();
3924 let (mut s, _out) = running_state("market summary USDC --every 2s");
3925 type_str(&mut s, "ctx");
3926 screen(&s, 30, 8);
3927 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3928 assert_eq!(s.input, "ctx");
3929 assert!(s.running.is_some());
3930 }
3931
3932 #[test]
3933 fn test_enter_does_nothing_while_the_terminal_is_too_small() {
3934 let mut s = state();
3935 type_str(&mut s, "help");
3936 screen(&s, 30, 8);
3937 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3938 assert_eq!(s.input, "help", "the draft is not lost or run");
3939 s.handle_key(key(KeyCode::Char('x')));
3941 assert_eq!(s.input, "helpx");
3942 screen(&s, 80, 24);
3944 assert!(matches!(
3945 s.handle_key(key(KeyCode::Enter)),
3946 Action::Submit(_)
3947 ));
3948 }
3949
3950 #[test]
3951 fn test_fit_status_never_panics_and_never_exceeds_the_width() {
3952 let text = |t: &str| vec![Span::styled(t.to_string(), ACCENT)];
3955 let mut parts = StatusParts {
3956 context: vec![
3957 labeled("chain", "auto".into()),
3958 labeled("format", "Table".into()),
3959 labeled("limit", "100".into()),
3960 ],
3961 state: text("running 12s · Esc cancels"),
3962 scrolled: Some(text("scrolled up 7 lines · PgDn")),
3963 search: None,
3964 command: Some("market summary ✓✓ --every 2s".into()),
3965 };
3966 for search in [None, Some(text("search: alpha ✓ 1/2"))] {
3967 parts.search = search;
3968 for w in 0..=130 {
3969 let line = fit_status(&parts, w);
3970 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
3971 assert!(cols(&text) <= w, "{w}: {text:?}");
3972 }
3973 }
3974 parts.search = None;
3975 let text: String = fit_status(&parts, 0)
3976 .spans
3977 .iter()
3978 .map(|s| s.content.as_ref())
3979 .collect();
3980 assert!(text.is_empty());
3981 }
3982
3983 use crate::cli::output::RunProgress;
3984 use std::time::Duration;
3985
3986 #[test]
3987 fn test_running_pane_shows_a_spinner_and_the_repeat_status() {
3988 let _lock = super::super::DIAG_LOCK.blocking_lock();
3989 let (s, out) = running_state("market summary USDC --every 2s");
3990 let hook = out.progress_hook().expect("the pane output has a hook");
3991 *hook.lock().unwrap() = RunProgress {
3992 run: 2,
3993 total: Some(4),
3994 next_at: Some(Instant::now() + Duration::from_secs(3)),
3995 };
3996 let text = screen(&s, 80, 12);
3997 assert!(text.contains("run 2 of 4 · next in 3s"), "{text}");
3998 assert!(
3999 text.contains("running market summary USDC --every 2s…"),
4000 "{text}"
4001 );
4002 let row = text.lines().find(|l| l.contains("running market")).unwrap();
4004 let glyph = row.trim_start().chars().next().unwrap().to_string();
4005 assert!(
4006 SPINNER_FRAMES.contains(&glyph.as_str()) || SPINNER_ASCII.contains(&glyph.as_str()),
4007 "{row:?}"
4008 );
4009 }
4010
4011 #[test]
4012 fn test_spinner_uses_ascii_without_color_and_advances_with_time() {
4013 assert_eq!(spinner_glyph(Duration::from_millis(0), false), "-");
4014 assert_eq!(spinner_glyph(Duration::from_millis(100), false), "\\");
4015 assert_eq!(spinner_glyph(Duration::from_millis(200), false), "|");
4016 assert_eq!(spinner_glyph(Duration::from_millis(300), false), "/");
4017 assert_eq!(spinner_glyph(Duration::from_millis(400), false), "-");
4018 assert_ne!(
4019 spinner_glyph(Duration::from_millis(0), true),
4020 spinner_glyph(Duration::from_millis(100), true)
4021 );
4022 }
4023
4024 #[test]
4025 fn test_spinner_and_run_status_never_enter_the_record() {
4026 let _lock = super::super::DIAG_LOCK.blocking_lock();
4029 let dir = tempfile::tempdir().unwrap();
4030 let journal = Journal::at(dir.path().join("history"), dir.path().join("events"));
4031 let mut s = TuiState::new(SessionContext::default(), journal, Vocab::default());
4032 let inv = Invocation {
4033 command: venues_list(),
4034 route: Route::Pane,
4035 ai: false,
4036 argv: vec![],
4037 };
4038 let out = s.start("market summary USDC --every 2s", &inv);
4039 crate::outln!(out, "data line").unwrap();
4040 *out.progress_hook().unwrap().lock().unwrap() = RunProgress {
4041 run: 2,
4042 total: Some(4),
4043 next_at: Some(Instant::now() + Duration::from_secs(3)),
4044 };
4045 let drawn = screen(&s, 80, 12);
4046 assert!(drawn.contains("run 2 of 4"), "{drawn}");
4047 s.finish(Ending::Finished(Ok(())));
4048 let kept = format!(
4049 "{}\n{}",
4050 s.scrollback_text(),
4051 s.last_output.clone().unwrap_or_default()
4052 );
4053 assert!(kept.contains("data line"));
4054 for gone in [
4055 "run 2 of",
4056 "next in",
4057 "running market",
4058 "⠋",
4059 "Running market",
4060 ] {
4061 assert!(!kept.contains(gone), "{gone:?} leaked into: {kept}");
4062 }
4063 let events = std::fs::read_to_string(dir.path().join("events")).unwrap();
4064 assert!(
4065 !events.contains("run 2 of") && !events.contains("next in"),
4066 "{events}"
4067 );
4068 let after = screen(&s, 80, 12);
4070 assert!(!after.contains("run 2 of"), "{after}");
4071 }
4072
4073 #[test]
4074 fn test_render_shows_live_output_of_a_running_command() {
4075 let _lock = super::super::DIAG_LOCK.blocking_lock();
4077 let mut s = state();
4078 let inv = Invocation {
4079 command: venues_list(),
4080 route: Route::Pane,
4081 ai: false,
4082 argv: vec![],
4083 };
4084 let out = s.start("market summary USDC --every 30s", &inv);
4085 crate::outln!(out, "\x1b[32mrun 1: HEALTHY\x1b[0m").unwrap();
4086 let text = screen(&s, 80, 15);
4087 assert!(text.contains("run 1: HEALTHY"), "{}", text);
4088 assert!(text.contains("running 0s · Esc cancels"), "{}", text);
4089 s.finish(Ending::Finished(Ok(())));
4090 assert!(s.scrollback_text().contains("run 1: HEALTHY"));
4091 }
4092
4093 #[test]
4094 fn test_newest_line_is_on_the_last_pane_row() {
4095 let mut s = state();
4098 for i in 0..40 {
4099 s.push_ansi(&format!("line {}\n", i));
4100 }
4101 let rows: Vec<String> = screen(&s, 40, 15).lines().map(str::to_string).collect();
4102 assert!(rows[8].contains("line 39"), "{:#?}", rows);
4104 assert!(rows[9].starts_with('─'), "{:#?}", rows);
4105 }
4106
4107 #[test]
4108 fn test_hint_row_shows_usage_while_typing() {
4109 let mut s = state();
4110 type_str(&mut s, "market summary");
4111 let text = screen(&s, 100, 15);
4112 assert!(text.contains("scope market summary"), "{}", text);
4113 assert!(text.contains("next: <SYMBOL>"), "{}", text);
4114 }
4115
4116 #[test]
4117 fn test_render_tiny_terminal_does_not_panic() {
4118 let mut s = state();
4119 s.push_ansi("x\n");
4120 screen(&s, 10, 5);
4121 screen(&s, 1, 1);
4122 }
4123
4124 #[tokio::test]
4125 async fn test_finish_with_error_shows_cli_error_text_and_logs() {
4126 let _lock = super::super::DIAG_LOCK.lock().await;
4127 let dir = tempfile::tempdir().unwrap();
4128 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
4129 let mut s = TuiState::new(SessionContext::default(), journal, Vocab::default());
4130 let inv = Invocation {
4131 command: venues_list(),
4132 route: Route::Pane,
4133 ai: false,
4134 argv: vec!["scope".into(), "venues".into(), "list".into()],
4135 };
4136 s.start("venues list", &inv);
4137 s.finish(Ending::Finished(Err(
4138 scope::error::ScopeError::InvalidAddress("0xbad".into()),
4139 )));
4140 let text = s.scrollback_text();
4141 assert!(text.contains("Invalid address format: 0xbad"), "{}", text);
4142 assert!(text.contains("EVM: 0x followed by 40 hex characters"));
4143 let log = std::fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
4144 let ev: serde_json::Value = serde_json::from_str(log.lines().last().unwrap()).unwrap();
4145 assert_eq!(ev["outcome"], "error");
4146 assert_eq!(ev["route"], "pane");
4147 assert_eq!(ev["argv"][1], "venues");
4148 }
4149
4150 fn palette_selected(s: &TuiState) -> usize {
4151 let Some(Overlay::Palette(p)) = &s.overlay else {
4152 panic!("palette expected")
4153 };
4154 p.selected
4155 }
4156
4157 #[test]
4158 fn test_palette_selection_wraps_and_a_new_query_resets_it() {
4159 let mut s = state();
4162 s.handle_key(ctrl_k());
4163 let count = match &s.overlay {
4164 Some(Overlay::Palette(p)) => p.matches().len(),
4165 _ => panic!("palette expected"),
4166 };
4167 assert!(count > 2);
4168 s.handle_key(key(KeyCode::Up));
4169 assert_eq!(palette_selected(&s), count - 1);
4170 s.handle_key(key(KeyCode::Down));
4171 assert_eq!(palette_selected(&s), 0);
4172 s.handle_key(key(KeyCode::Tab));
4173 s.handle_key(key(KeyCode::BackTab));
4174 assert_eq!(palette_selected(&s), 0);
4175 s.handle_key(key(KeyCode::Down));
4176 s.handle_key(key(KeyCode::Backspace));
4177 assert_eq!(palette_selected(&s), 0);
4178 s.handle_key(key(KeyCode::Down));
4179 type_str(&mut s, "m");
4180 assert_eq!(palette_selected(&s), 0);
4181 }
4182
4183 #[test]
4184 fn test_palette_esc_closes_and_enter_without_a_match_keeps_the_draft() {
4185 let mut s = state();
4186 type_str(&mut s, "draft");
4187 s.handle_key(ctrl_k());
4188 s.handle_key(key(KeyCode::Esc));
4189 assert!(s.overlay.is_none());
4190 s.handle_key(ctrl_k());
4191 type_str(&mut s, "zzzzqqqq");
4192 s.handle_key(key(KeyCode::Down));
4193 s.handle_key(key(KeyCode::Enter));
4194 assert!(s.overlay.is_none());
4195 assert_eq!(s.input, "draft", "no match: nothing is filled in");
4196 }
4197
4198 #[test]
4199 fn test_menu_up_wraps_to_the_last_candidate() {
4200 let mut s = vocab_state();
4201 type_str(&mut s, "market summary USDC --venue b");
4202 s.handle_key(key(KeyCode::Tab));
4203 s.handle_key(key(KeyCode::Up));
4204 s.handle_key(key(KeyCode::Enter));
4205 assert_eq!(s.input, "market summary USDC --venue bitget ");
4206 s.set_input("market summary USDC --venue b".to_string());
4207 s.handle_key(key(KeyCode::Tab));
4208 s.handle_key(key(KeyCode::Down));
4209 s.handle_key(key(KeyCode::BackTab));
4210 s.handle_key(key(KeyCode::Enter));
4211 assert_eq!(s.input, "market summary USDC --venue binance ");
4212 }
4213
4214 #[test]
4215 fn test_record_signal_writes_the_signal_outcome_to_the_journal() {
4216 let dir = tempfile::tempdir().unwrap();
4219 let events = dir.path().join("e.jsonl");
4220 let mut s = TuiState::new(
4221 SessionContext::default(),
4222 Journal::at(dir.path().join("h"), events.clone()),
4223 Vocab::default(),
4224 );
4225 s.record_signal("HUP", "suspend", "setup", &["scope".into(), "setup".into()]);
4226 let row: serde_json::Value =
4227 serde_json::from_str(std::fs::read_to_string(events).unwrap().trim()).unwrap();
4228 assert_eq!(row["outcome"], "signal:HUP");
4229 assert_eq!(row["route"], "suspend");
4230 assert_eq!(row["input"], "setup");
4231 assert_eq!(row["argv"][1], "setup");
4232 }
4233
4234 #[test]
4235 fn test_failed_suspended_command_is_journaled_as_error_with_its_text() {
4236 let dir = tempfile::tempdir().unwrap();
4237 let events = dir.path().join("e.jsonl");
4238 let mut s = TuiState::new(
4239 SessionContext::default(),
4240 Journal::at(dir.path().join("h"), events.clone()),
4241 Vocab::default(),
4242 );
4243 let failed = Err(scope::error::ScopeError::Other("setup broke".into()));
4244 s.record_suspended(
4245 "setup",
4246 &["scope".into(), "setup".into()],
4247 std::time::Duration::from_millis(5),
4248 "2026-01-01T00:00:00Z".into(),
4249 &Ending::Finished(failed),
4250 );
4251 let row: serde_json::Value =
4252 serde_json::from_str(std::fs::read_to_string(events).unwrap().trim()).unwrap();
4253 assert_eq!(row["outcome"], "error");
4254 assert_eq!(row["route"], "suspend");
4255 assert!(row["error"].as_str().unwrap().contains("setup broke"));
4256 }
4257
4258 #[test]
4259 fn test_unwritable_event_log_is_reported_in_the_pane() {
4260 let dir = tempfile::tempdir().unwrap();
4263 let mut s = TuiState::new(
4265 SessionContext::default(),
4266 Journal::at(dir.path().join("h"), dir.path().to_path_buf()),
4267 Vocab::default(),
4268 );
4269 s.record_signal("TERM", "session", "", &[]);
4270 assert!(
4271 s.scrollback_text()
4272 .contains("Could not write the TUI event log"),
4273 "{}",
4274 s.scrollback_text()
4275 );
4276 }
4277
4278 #[test]
4279 fn test_paste_joins_lines_and_never_runs() {
4280 let mut s = state();
4281 s.paste("venues list\nmarket summary USDC\n");
4282 assert_eq!(s.input, "venues list market summary USDC");
4283 assert!(s.scrollback_text().contains("pasted 2 lines as one line"));
4284 }
4285
4286 #[test]
4287 fn test_paste_one_line_adds_no_note_and_crlf_counts_once() {
4288 let mut s = state();
4289 s.paste("help\r\n");
4290 assert_eq!(s.input, "help");
4291 assert!(!s.scrollback_text().contains("pasted"));
4292 s.set_input(String::new());
4293 s.paste("a\r\nb\rc");
4294 assert_eq!(s.input, "a b c");
4295 assert!(s.scrollback_text().contains("pasted 3 lines as one line"));
4296 }
4297
4298 #[test]
4299 fn test_paste_inserts_at_the_cursor() {
4300 let mut s = state();
4301 type_str(&mut s, "ac");
4302 s.handle_key(key(KeyCode::Left));
4303 s.paste("b");
4304 assert_eq!(s.input, "abc");
4305 type_str(&mut s, "!");
4306 assert_eq!(s.input, "ab!c", "the cursor sits after the paste");
4307 }
4308
4309 #[test]
4310 fn test_paste_cannot_inject_control_characters() {
4311 let mut s = state();
4312 s.paste("a\x1b[2Jb\tc\x07\x7fd\u{9b}e");
4313 assert_eq!(s.input, "a[2Jb cde");
4314 }
4315
4316 #[test]
4317 fn test_paste_closes_the_completion_menu() {
4318 let mut s = vocab_state();
4319 type_str(&mut s, "market summary X --venue b");
4320 s.handle_key(key(KeyCode::Tab));
4321 assert!(s.overlay.is_some());
4322 s.paste("z");
4323 assert!(s.overlay.is_none());
4324 }
4325
4326 #[test]
4327 fn test_paste_while_a_command_runs_goes_to_the_input() {
4328 let _lock = super::super::DIAG_LOCK.blocking_lock();
4329 let mut s = state();
4330 let inv = Invocation {
4331 command: venues_list(),
4332 route: Route::Pane,
4333 ai: false,
4334 argv: vec![],
4335 };
4336 s.start("venues list", &inv);
4337 s.paste("market summary USDC\n");
4338 assert_eq!(s.input, "market summary USDC");
4339 assert!(s.running.is_some(), "the paste does not run or cancel");
4340 s.finish(Ending::Cancelled);
4341 }
4342
4343 #[test]
4344 fn test_long_input_keeps_cursor_visible() {
4345 let mut s = state();
4346 s.paste(&"x".repeat(300));
4347 assert_eq!(cursor_pos(&s, 60, 12).x, 58);
4349 let sc = screen(&s, 60, 12);
4350 let row = sc.lines().find(|l| l.contains("xxxx")).unwrap();
4351 let cells: Vec<char> = row.chars().collect();
4352 assert_eq!(cells[0], '│');
4353 assert!(
4354 cells[1..58].iter().all(|&c| c == 'x'),
4355 "text ends at the cursor"
4356 );
4357 assert_eq!(cells[58], ' ', "the cursor cell is empty");
4358 assert_eq!(cells[59], '│', "the border is not overwritten");
4359 }
4360
4361 #[test]
4362 fn test_cursor_scrolls_back_with_home() {
4363 let mut s = state();
4364 s.paste(&"x".repeat(100));
4365 s.handle_key(key(KeyCode::Home));
4366 assert_eq!(
4367 cursor_pos(&s, 60, 12).x,
4368 1,
4369 "Home shows the start of the line"
4370 );
4371 s.handle_key(key(KeyCode::End));
4372 assert_eq!(cursor_pos(&s, 60, 12).x, 58);
4373 }
4374
4375 #[test]
4376 fn test_cursor_follows_display_width_of_cjk() {
4377 let mut s = state();
4379 type_str(&mut s, "日本 ok");
4380 s.handle_key(key(KeyCode::End));
4381 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 7);
4382 s.handle_key(key(KeyCode::Left));
4383 s.handle_key(key(KeyCode::Left));
4384 s.handle_key(key(KeyCode::Left));
4385 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 4, "after 日本");
4386 }
4387
4388 #[test]
4389 fn test_cursor_follows_display_width_of_emoji() {
4390 let mut s = state();
4391 type_str(&mut s, "😀x");
4392 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 3);
4393 }
4394
4395 #[test]
4396 fn test_wide_input_scrolls_by_columns_not_chars() {
4397 let mut s = state();
4399 s.paste(&"日".repeat(40));
4400 assert_eq!(cursor_pos(&s, 60, 12).x, 57);
4402 }
4403
4404 #[test]
4405 fn test_menu_width_uses_display_width() {
4406 let wide_a = "日本日本日本a";
4407 let wide_b = "日本日本日本b";
4408 let mut s = TuiState::new(
4409 SessionContext::default(),
4410 Journal::default(),
4411 Vocab {
4412 venues: vec![wide_a.into(), wide_b.into()],
4413 ..Vocab::default()
4414 },
4415 );
4416 type_str(&mut s, "market summary X --venue ");
4417 s.handle_key(key(KeyCode::Tab));
4418 assert!(matches!(s.overlay, Some(Overlay::Menu(_))));
4419 let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap();
4420 term.draw(|f| s.render(f)).unwrap();
4421 let buf = term.backend().buffer().clone();
4422 let row = (0..14)
4425 .find(|&y| (0..60).any(|x| buf[(x, y)].symbol() == "a"))
4426 .unwrap();
4427 assert_eq!(buf[(16, row)].symbol(), "│");
4428 }
4429
4430 fn start_pane_command(s: &mut TuiState) {
4431 let inv = Invocation {
4432 command: venues_list(),
4433 route: Route::Pane,
4434 ai: false,
4435 argv: vec![],
4436 };
4437 s.start("venues list", &inv);
4438 }
4439
4440 #[test]
4441 fn test_ctrl_d_while_running_explains_and_does_not_exit() {
4442 let _lock = super::super::DIAG_LOCK.blocking_lock();
4443 let mut s = state();
4444 start_pane_command(&mut s);
4445 assert_eq!(s.handle_key(ctrl('d')), Action::None);
4446 assert!(s.scrollback_text().contains(BUSY_NOTE));
4447 s.finish(Ending::Cancelled);
4448 }
4449
4450 #[test]
4451 fn test_exit_typed_while_running_explains_and_keeps_the_line() {
4452 let _lock = super::super::DIAG_LOCK.blocking_lock();
4453 for word in ["exit", "quit", "q", "EXIT"] {
4454 let mut s = state();
4455 start_pane_command(&mut s);
4456 type_str(&mut s, word);
4457 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None, "{word}");
4458 assert!(s.scrollback_text().contains(BUSY_NOTE), "{word}");
4459 assert_eq!(s.input, word, "the line stays");
4460 s.finish(Ending::Cancelled);
4461 }
4462 }
4463
4464 #[test]
4465 fn test_any_line_while_running_shows_the_hint_and_keeps_the_input() {
4466 let _lock = super::super::DIAG_LOCK.blocking_lock();
4467 let mut s = state();
4468 start_pane_command(&mut s);
4469 type_str(&mut s, "ctx");
4470 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
4471 assert_eq!(s.input, "ctx");
4472 assert!(screen(&s, 100, 14).contains(BUSY_NOTE), "hint row");
4473 assert!(
4474 !s.scrollback_text().contains(BUSY_NOTE),
4475 "no scrollback note"
4476 );
4477 s.finish(Ending::Cancelled);
4478 }
4479
4480 #[test]
4481 fn test_empty_enter_while_running_shows_no_hint() {
4482 let _lock = super::super::DIAG_LOCK.blocking_lock();
4483 let mut s = state();
4484 start_pane_command(&mut s);
4485 s.handle_key(key(KeyCode::Enter));
4486 assert!(!screen(&s, 100, 14).contains(BUSY_NOTE));
4487 s.finish(Ending::Cancelled);
4488 }
4489
4490 #[test]
4491 fn test_busy_hint_clears_on_the_next_key() {
4492 let _lock = super::super::DIAG_LOCK.blocking_lock();
4493 let mut s = state();
4494 start_pane_command(&mut s);
4495 s.handle_key(ctrl('d'));
4496 let hint_row = screen(&s, 100, 14).lines().nth(12).unwrap().to_string();
4497 assert!(hint_row.contains(BUSY_NOTE));
4498 s.handle_key(key(KeyCode::Char('x')));
4499 let hint_row = screen(&s, 100, 14).lines().nth(12).unwrap().to_string();
4500 assert!(!hint_row.contains(BUSY_NOTE));
4501 s.finish(Ending::Cancelled);
4502 }
4503
4504 #[test]
4505 fn test_busy_note_is_not_repeated_in_a_row() {
4506 let _lock = super::super::DIAG_LOCK.blocking_lock();
4507 let mut s = state();
4508 start_pane_command(&mut s);
4509 s.handle_key(ctrl('d'));
4510 s.handle_key(ctrl('d'));
4511 assert_eq!(s.scrollback_text().matches(BUSY_NOTE).count(), 1);
4512 s.finish(Ending::Cancelled);
4513 }
4514
4515 #[test]
4516 fn test_ctrl_d_exits_once_the_command_ended() {
4517 let _lock = super::super::DIAG_LOCK.blocking_lock();
4518 let mut s = state();
4519 start_pane_command(&mut s);
4520 s.handle_key(ctrl('d'));
4521 s.finish(Ending::Cancelled);
4522 assert_eq!(s.handle_key(ctrl('d')), Action::Exit);
4523 }
4524
4525 #[test]
4526 fn test_ctrl_d_with_text_and_no_command_does_nothing() {
4527 let mut s = state();
4528 type_str(&mut s, "ab");
4529 assert_eq!(s.handle_key(ctrl('d')), Action::None);
4530 assert!(!s.scrollback_text().contains(BUSY_NOTE));
4531 }
4532
4533 #[test]
4534 fn test_paste_drops_bidi_controls() {
4535 let mut s = state();
4536 s.paste("a\u{202E}b\u{2066}c\u{2069}d\u{202A}");
4537 assert_eq!(s.input, "abcd");
4538 }
4539
4540 #[test]
4541 fn test_paste_is_capped_with_a_note() {
4542 let mut s = state();
4543 s.paste(&"x".repeat(PASTE_LIMIT + 50));
4544 assert_eq!(s.input.chars().count(), PASTE_LIMIT);
4545 assert!(
4546 s.scrollback_text()
4547 .contains("paste truncated to 10000 characters")
4548 );
4549 let mut t = state();
4550 t.paste(&"x".repeat(PASTE_LIMIT));
4551 assert!(
4552 !t.scrollback_text().contains("truncated"),
4553 "exact limit is not truncated"
4554 );
4555 }
4556
4557 #[test]
4558 fn test_paste_goes_into_the_open_palette_query() {
4559 let mut s = state();
4560 s.handle_key(ctrl('k'));
4561 s.paste("ven\nues");
4562 match &s.overlay {
4563 Some(Overlay::Palette(p)) => assert_eq!(p.query, "ven ues"),
4564 _ => panic!("the palette stays open"),
4565 }
4566 assert_eq!(s.input, "", "the input line is untouched");
4567 }
4568
4569 fn row_cells(s: &TuiState, w: u16, h: u16, needle: &str) -> (Vec<String>, u16) {
4570 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
4571 term.draw(|f| s.render(f)).unwrap();
4572 let buf = term.backend().buffer().clone();
4573 let y = (0..h)
4574 .find(|&y| (0..w).any(|x| buf[(x, y)].symbol() == needle))
4575 .unwrap();
4576 (
4577 (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect(),
4578 y,
4579 )
4580 }
4581
4582 #[test]
4583 fn test_left_mid_line_keeps_the_text_right_of_the_cursor_visible() {
4584 let mut s = state();
4585 s.paste(&format!("{}{}", "a".repeat(100), "b".repeat(5)));
4586 let _ = cursor_pos(&s, 60, 12);
4587 for _ in 0..3 {
4588 s.handle_key(key(KeyCode::Left));
4589 }
4590 let (cells, _) = row_cells(&s, 60, 12, "b");
4591 assert_eq!(cursor_pos(&s, 60, 12).x, 55, "the window did not move");
4592 assert_eq!(cells[55], "b", "text under and right of the cursor stays");
4593 assert_eq!(cells[57], "b", "the last char is still shown");
4594 }
4595
4596 #[test]
4597 fn test_window_moves_back_when_the_cursor_leaves_on_the_left() {
4598 let mut s = state();
4599 s.paste(&"x".repeat(100));
4600 let _ = cursor_pos(&s, 60, 12);
4601 for _ in 0..80 {
4602 s.handle_key(key(KeyCode::Left));
4603 }
4604 assert_eq!(cursor_pos(&s, 60, 12).x, 1, "cursor at the left edge");
4605 }
4606
4607 #[test]
4608 fn test_wide_chars_are_never_cut_at_the_left_edge() {
4609 let mut s = state();
4612 s.paste(&"日".repeat(40));
4613 let (cells, _) = row_cells(&s, 60, 12, "日");
4614 assert_eq!(cells[1], "日", "the first shown cell holds a whole char");
4615 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 80 - 24);
4616 assert_eq!(cells[59], "│", "border intact");
4617 }
4618
4619 #[test]
4620 fn test_cursor_on_a_wide_char_is_never_half_visible() {
4621 let mut s = state();
4622 s.paste(&"x".repeat(56));
4623 s.paste("日日");
4624 s.handle_key(key(KeyCode::Left));
4625 let p = cursor_pos(&s, 60, 12);
4627 assert!(
4628 p.x + 2 <= 59,
4629 "wide char at the cursor fits inside the border"
4630 );
4631 }
4632
4633 fn run_pane(s: &mut TuiState, text: &str, ending: Ending) {
4635 let inv = Invocation {
4636 command: venues_list(),
4637 route: Route::Pane,
4638 ai: false,
4639 argv: vec![],
4640 };
4641 let out = s.start("venues list", &inv);
4642 out.write_out(format_args!("{}", text)).unwrap();
4643 s.finish(ending);
4644 }
4645
4646 #[test]
4647 fn test_ctrl_y_copies_the_plain_output_of_the_last_success() {
4648 let mut s = state();
4649 assert_eq!(s.handle_key(ctrl('y')), Action::None);
4650 assert!(s.scrollback_text().contains("nothing to copy yet"));
4651 run_pane(&mut s, "\x1b[1;36mA\x1b[0m\nb\n", Ending::Finished(Ok(())));
4652 assert_eq!(s.handle_key(ctrl('y')), Action::Copy("A\nb\n".into()));
4653 }
4654
4655 #[test]
4656 fn test_failed_or_cancelled_run_keeps_the_earlier_copy() {
4657 let mut s = state();
4658 run_pane(&mut s, "good\n", Ending::Finished(Ok(())));
4659 run_pane(
4660 &mut s,
4661 "partial\n",
4662 Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into()))),
4663 );
4664 run_pane(&mut s, "partial\n", Ending::Cancelled);
4665 assert_eq!(s.handle_key(ctrl('y')), Action::Copy("good\n".into()));
4666 }
4667
4668 #[test]
4669 fn test_save_without_output_says_so_and_escape_is_refused() {
4670 let mut s = state();
4671 s.submit("save out.json", &Config::default());
4672 assert!(s.scrollback_text().contains("nothing to save yet"));
4673 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4674 s.submit("save ../escape.json", &Config::default());
4675 assert!(
4676 s.scrollback_text()
4677 .contains("save writes inside the current directory or to an absolute path")
4678 );
4679 s.submit("save", &Config::default());
4680 assert!(s.scrollback_text().contains("Usage: save <file> [--force]"));
4681 }
4682
4683 #[test]
4684 fn test_pane_has_no_side_borders() {
4685 let mut s = state();
4686 s.push_note("hello");
4687 let text = screen(&s, 40, 12);
4688 let row = text.lines().find(|l| l.contains("hello")).unwrap();
4689 assert!(row.starts_with("ℹ hello"), "{row:?}");
4690 }
4691
4692 fn last_line(s: &TuiState) -> &Line<'static> {
4693 s.scrollback.last().unwrap()
4694 }
4695
4696 #[test]
4697 fn test_ok_run_ends_with_done_footer() {
4698 let mut s = state();
4699 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4700 let text = last_line(&s).to_string();
4701 assert!(
4702 regex::Regex::new(r"^✓ Done in \d+\.\ds\.$")
4703 .unwrap()
4704 .is_match(&text),
4705 "{text}"
4706 );
4707 assert_eq!(last_line(&s).style, OK);
4708 }
4709
4710 #[test]
4711 fn test_failed_run_ends_with_a_red_failed_footer() {
4712 let mut s = state();
4713 let err = Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into())));
4714 s.start("x", &pane_invocation());
4715 s.finish_with(err);
4716 let line = last_line(&s);
4717 let text = line.to_string();
4718 assert!(
4719 regex::Regex::new(r"^✗ Failed in \d+\.\ds\.$")
4720 .unwrap()
4721 .is_match(&text),
4722 "{text}"
4723 );
4724 assert_eq!(line.style, ERR.add_modifier(Modifier::BOLD));
4725 }
4726
4727 #[test]
4728 fn test_cancelled_run_ends_with_cancelled_after_footer() {
4729 let mut s = state();
4730 run_pane(&mut s, "x\n", Ending::Cancelled);
4731 let text = last_line(&s).to_string();
4732 assert!(
4733 regex::Regex::new(r"^Cancelled after \d+\.\ds\.$")
4734 .unwrap()
4735 .is_match(&text),
4736 "{text}"
4737 );
4738 assert_eq!(last_line(&s).style, WARN);
4739 }
4740
4741 fn pane_invocation() -> Invocation {
4742 Invocation {
4743 command: venues_list(),
4744 route: Route::Pane,
4745 ai: false,
4746 argv: vec![],
4747 }
4748 }
4749
4750 fn warned_json_run(s: &mut TuiState) {
4751 let inv = pane_invocation();
4752 let out = s.start("address 0x1 --format json", &inv);
4753 crate::errln!(out, "\u{26a0} Token balances unavailable").unwrap();
4754 crate::outln!(out, "{{\"a\": 1}}").unwrap();
4755 s.finish(Ending::Finished(Ok(())));
4756 }
4757
4758 #[test]
4759 fn test_last_output_excludes_warnings_the_pane_still_shows() {
4760 let mut s = state();
4761 warned_json_run(&mut s);
4762 let Action::Copy(text) = s.handle_key(ctrl('y')) else {
4763 panic!("expected a copy");
4764 };
4765 serde_json::from_str::<serde_json::Value>(&text).unwrap();
4766 assert!(!text.contains("Token balances"));
4767 assert!(s.scrollback_text().contains("Token balances unavailable"));
4768 }
4769
4770 #[test]
4771 fn test_a_success_with_no_output_clears_the_copy() {
4772 let mut s = state();
4773 run_pane(&mut s, "data\n", Ending::Finished(Ok(())));
4774 run_pane(&mut s, "", Ending::Finished(Ok(())));
4775 assert_eq!(s.handle_key(ctrl('y')), Action::None);
4776 s.submit("save out.json", &Config::default());
4777 assert!(s.scrollback_text().contains("nothing to save yet"));
4778 }
4779
4780 #[test]
4781 fn test_ctrl_y_refuses_output_over_the_osc52_cap() {
4782 let mut s = state();
4783 let big = "x".repeat(clip::OSC52_MAX_PAYLOAD);
4784 run_pane(&mut s, &format!("{big}\n"), Ending::Finished(Ok(())));
4785 assert_eq!(s.handle_key(ctrl('y')), Action::None);
4786 assert!(
4787 s.scrollback_text()
4788 .contains("too large to copy (1 lines); use save <file>")
4789 );
4790 }
4791
4792 #[test]
4793 fn test_save_with_unbalanced_quotes_gets_the_save_usage() {
4794 let mut s = state();
4795 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4796 s.submit("save \"unclosed", &Config::default());
4797 let text = s.scrollback_text();
4798 assert!(
4799 text.contains("Unbalanced quotes. Usage: save <file> [--force]"),
4800 "{text}"
4801 );
4802 }
4803
4804 fn pane_rows(s: &TuiState, w: u16) -> Vec<String> {
4806 let h = 200;
4807 let rows: Vec<String> = screen(s, w, h).lines().map(str::to_string).collect();
4808 rows[1..(h as usize - 6)].to_vec()
4810 }
4811
4812 #[tokio::test]
4813 async fn test_venues_list_rules_never_wrap_in_a_narrow_pane() {
4814 let _lock = super::super::DIAG_LOCK.lock().await;
4819 let mut s = state();
4820 let inv = pane_invocation();
4821 let out = s.start("venues list", &inv);
4822 let factory = scope::chains::mocks::MockClientFactory::new();
4823 let res = run_invocation(inv, &Config::default(), &factory, &out).await;
4824 s.finish(Ending::Finished(res));
4825 let rows = pane_rows(&s, 40);
4826 for pair in rows.windows(2) {
4827 let continued = pair[0].trim_end().ends_with('─') && pair[1].starts_with('─');
4828 assert!(!continued, "a rule wrapped:\n{}", rows.join("\n"));
4829 }
4830 let first = rows
4831 .iter()
4832 .position(|r| r.starts_with("┌─ Available Venues"));
4833 let last = rows.iter().position(|r| r.starts_with('└'));
4834 let (Some(first), Some(last)) = (first, last) else {
4835 panic!("no section:\n{}", rows.join("\n"));
4836 };
4837 for row in &rows[first + 1..last] {
4838 assert!(
4839 row.starts_with('│') || row.starts_with('├'),
4840 "row lost its gutter: {row:?}\n{}",
4841 rows.join("\n")
4842 );
4843 }
4844 for row in &rows {
4845 assert!(row.width() <= 40, "{row:?}");
4846 }
4847 }
4848
4849 #[test]
4850 fn test_pane_wraps_never_reach_the_copy() {
4851 let mut s = state();
4854 let long = format!("│ {}", "word ".repeat(30).trim_end());
4855 run_pane(&mut s, &format!("{long}\n"), Ending::Finished(Ok(())));
4856 let rows = pane_rows(&s, 40);
4857 let wrapped: Vec<_> = rows.iter().filter(|r| r.contains("word")).collect();
4858 assert!(wrapped.len() > 1, "{rows:#?}");
4859 assert!(
4860 wrapped.iter().all(|r| r.starts_with("│ word")),
4861 "{rows:#?}"
4862 );
4863 let Action::Copy(text) = s.handle_key(ctrl('y')) else {
4864 panic!("expected a copy");
4865 };
4866 assert_eq!(text, format!("{long}\n"));
4867 }
4868
4869 #[test]
4870 fn test_render_records_the_pane_width() {
4871 let s = state();
4874 assert_eq!(s.pane_width(), None, "nothing drawn yet");
4875 screen(&s, 40, 20);
4876 assert_eq!(s.pane_width(), Some(40));
4877 }
4878
4879 fn alpha_state() -> TuiState {
4882 let mut s = state();
4883 s.push_ansi("alpha\nbeta\nalpha2\n");
4884 screen(&s, 80, 20);
4885 s
4886 }
4887
4888 fn open_search(s: &mut TuiState, query: &str) {
4889 s.handle_key(ctrl('f'));
4890 type_str(s, query);
4891 screen(s, 80, 20);
4892 }
4893
4894 #[test]
4895 fn test_search_goes_to_the_newest_match_and_n_and_shift_n_walk_the_matches() {
4896 let mut s = alpha_state();
4897 open_search(&mut s, "alpha");
4898 assert!(
4899 status_row(&s, 80).contains("search: alpha 1/2"),
4900 "newest first"
4901 );
4902 assert_eq!(s.input, "", "typing goes to the query, not the input");
4903 s.handle_key(key(KeyCode::Enter));
4904 s.handle_key(key(KeyCode::Char('n')));
4905 assert!(status_row(&s, 80).contains("search: alpha 2/2"));
4906 s.handle_key(key(KeyCode::Char('n')));
4907 assert!(status_row(&s, 80).contains("1/2"), "n wraps to the newest");
4908 s.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
4909 assert!(status_row(&s, 80).contains("2/2"), "N wraps to the oldest");
4910 s.handle_key(key(KeyCode::Esc));
4911 assert!(s.overlay.is_none());
4912 assert!(!status_row(&s, 80).contains("search:"));
4913 }
4914
4915 #[test]
4916 fn test_n_is_a_query_character_while_editing() {
4917 let mut s = alpha_state();
4918 open_search(&mut s, "an");
4919 assert!(status_row(&s, 80).contains("search: an"));
4920 assert!(status_row(&s, 80).contains("no match"));
4921 }
4922
4923 #[test]
4924 fn test_slash_opens_search_only_on_an_empty_input() {
4925 let mut s = alpha_state();
4926 s.handle_key(key(KeyCode::Char('/')));
4927 assert!(matches!(s.overlay, Some(Overlay::Search(_))));
4928 assert_eq!(s.input, "");
4929 s.handle_key(key(KeyCode::Esc));
4930 type_str(&mut s, "a/b");
4931 assert!(s.overlay.is_none());
4932 assert_eq!(s.input, "a/b", "a slash inside text is text");
4933 }
4934
4935 #[test]
4936 fn test_search_is_case_insensitive_and_marks_matched_rows() {
4937 let mut s = alpha_state();
4938 open_search(&mut s, "ALPHA");
4939 let text = screen(&s, 80, 20);
4940 assert!(status_row(&s, 80).contains("1/2"));
4941 assert_eq!(text.matches('▶').count(), 2, "{text}");
4942 }
4943
4944 #[test]
4945 fn test_no_match_says_so_and_n_does_nothing() {
4946 let mut s = alpha_state();
4947 open_search(&mut s, "zzz");
4948 assert!(status_row(&s, 80).contains("no match"));
4949 s.handle_key(key(KeyCode::Enter));
4950 s.handle_key(key(KeyCode::Char('n')));
4951 s.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
4952 assert!(status_row(&s, 80).contains("no match"));
4953 assert!(!screen(&s, 80, 20).contains('▶'));
4954 }
4955
4956 fn needle_state() -> TuiState {
4957 let mut s = state();
4958 s.push_ansi("needle first\n");
4959 for i in 0..40 {
4960 s.push_ansi(&format!("line {}\n", i));
4961 }
4962 screen(&s, 60, 15);
4963 s
4964 }
4965
4966 #[test]
4967 fn test_search_scrolls_to_an_old_match_and_esc_restores_the_view() {
4968 let mut s = needle_state();
4969 s.handle_key(key(KeyCode::PageUp));
4970 let before = screen(&s, 60, 15);
4971 assert!(!before.contains("needle"));
4972 s.handle_key(ctrl('f'));
4973 type_str(&mut s, "needle");
4974 let during = screen(&s, 60, 15);
4975 assert!(during.contains("needle first"), "{during}");
4976 assert!(during.contains('▶'));
4977 s.handle_key(key(KeyCode::Esc));
4978 assert_eq!(screen(&s, 60, 15), before, "the view the user had");
4979 }
4980
4981 fn long_match_state() -> TuiState {
4982 let mut s = state();
4983 s.push_ansi(&format!("needle {}\n", "word ".repeat(40)));
4984 for i in 0..30 {
4985 s.push_ansi(&format!("line {}\n", i));
4986 }
4987 screen(&s, 60, 15);
4988 s
4989 }
4990
4991 #[test]
4992 fn test_a_long_match_highlights_every_row_it_spans() {
4993 let mut s = long_match_state();
4994 s.handle_key(ctrl('f'));
4995 type_str(&mut s, "needle");
4996 let text = screen(&s, 60, 15);
4997 assert!(text.matches('▶').count() >= 2, "{text}");
4998 let mut term = Terminal::new(TestBackend::new(60, 15)).unwrap();
5000 term.draw(|f| s.render(f)).unwrap();
5001 let buf = term.backend().buffer().clone();
5002 let reversed = (0..15)
5003 .filter(|&y| buf[(5, y)].modifier.contains(Modifier::REVERSED))
5004 .count();
5005 assert!(reversed >= 2, "{text}");
5006 }
5007
5008 #[test]
5009 fn test_search_follows_a_resize() {
5010 let mut s = long_match_state();
5011 s.handle_key(ctrl('f'));
5012 type_str(&mut s, "needle");
5013 assert!(screen(&s, 60, 15).contains("needle"));
5014 assert!(
5015 screen(&s, 100, 20).contains("needle"),
5016 "visible after a resize"
5017 );
5018 assert!(screen(&s, 45, 12).contains("needle"), "and again");
5019 }
5020
5021 #[test]
5022 fn test_a_paste_goes_into_the_search_query() {
5023 let mut s = alpha_state();
5024 s.handle_key(ctrl('f'));
5025 s.paste("alpha\n");
5026 screen(&s, 80, 20);
5027 assert_eq!(s.input, "");
5028 assert!(status_row(&s, 80).contains("search: alpha 1/2"));
5029 }
5030
5031 #[test]
5032 fn test_esc_closes_the_search_before_it_cancels_a_running_command() {
5033 let _lock = super::super::DIAG_LOCK.blocking_lock();
5034 let (mut s, _out) = running_state("market summary USDC --every 2s");
5035 s.handle_key(ctrl('f'));
5036 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::None);
5037 assert!(s.overlay.is_none());
5038 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
5039 }
5040
5041 #[test]
5042 fn test_ctrl_c_still_cancels_a_running_command_with_a_search_open() {
5043 let _lock = super::super::DIAG_LOCK.blocking_lock();
5044 let (mut s, _out) = running_state("market summary USDC --every 2s");
5045 s.handle_key(ctrl('f'));
5046 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
5047 assert!(s.overlay.is_none());
5048 }
5049
5050 #[test]
5051 fn test_status_keeps_search_before_the_command_text_and_always_the_cancel_hint() {
5052 let _lock = super::super::DIAG_LOCK.blocking_lock();
5053 let (mut s, _out) = running_state("market summary USDC --every 2s");
5054 for i in 0..5 {
5055 s.push_ansi(&format!("alpha {}\n", i));
5056 }
5057 screen(&s, 140, 12);
5058 s.handle_key(ctrl('f'));
5059 type_str(&mut s, "alpha");
5060 screen(&s, 140, 12);
5061 let wide = status_row(&s, 140);
5062 assert!(
5063 wide.contains("search: alpha 1/5") && wide.contains("market summary"),
5064 "{wide}"
5065 );
5066 for w in 32..140 {
5068 let row = status_row(&s, w);
5069 assert!(row.contains("Esc closes search"), "{w}: {row:?}");
5070 if row.contains("market") {
5071 assert!(
5072 row.contains("search:"),
5073 "{w}: command without search: {row:?}"
5074 );
5075 }
5076 }
5077 let search_without_command = (26..140).any(|w| {
5078 let row = status_row(&s, w);
5079 row.contains("search:") && !row.contains("market")
5080 });
5081 assert!(search_without_command, "the command goes first");
5082 }
5083
5084 #[test]
5085 fn test_search_closes_when_the_terminal_is_too_small_and_restores_the_view() {
5086 let mut s = needle_state();
5087 s.handle_key(key(KeyCode::PageUp));
5088 let before = screen(&s, 60, 15);
5089 s.handle_key(ctrl('f'));
5090 type_str(&mut s, "needle");
5091 screen(&s, 30, 8);
5092 assert_eq!(s.handle_key(key(KeyCode::Char('x'))), Action::None);
5093 assert!(s.overlay.is_none());
5094 assert_eq!(s.input, "");
5095 assert_eq!(screen(&s, 60, 15), before);
5096 }
5097
5098 fn history_state() -> TuiState {
5099 let mut s = state();
5100 s.history = vec![
5101 "venues list".into(),
5102 "ctx".into(),
5103 "market summary USDC".into(),
5104 ];
5105 s
5106 }
5107
5108 #[test]
5109 fn test_ctrl_r_filters_the_history_and_enter_accepts_without_running() {
5110 let mut s = history_state();
5111 s.handle_key(ctrl('r'));
5112 type_str(&mut s, "ven");
5113 assert_eq!(s.input, "venues list");
5114 screen(&s, 80, 20);
5115 assert!(status_row(&s, 80).contains("1/1"), "{}", status_row(&s, 80));
5116 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
5117 assert!(s.overlay.is_none());
5118 assert_eq!(s.input, "venues list", "accepted into the input, not run");
5119 }
5120
5121 #[test]
5122 fn test_ctrl_r_again_cycles_to_older_matches() {
5123 let mut s = history_state();
5124 s.history.push("venues x".into());
5125 s.handle_key(ctrl('r'));
5126 type_str(&mut s, "ven");
5127 assert_eq!(s.input, "venues x", "most recent first");
5128 s.handle_key(ctrl('r'));
5129 assert_eq!(s.input, "venues list");
5130 s.handle_key(ctrl('r'));
5131 assert_eq!(s.input, "venues x", "wraps");
5132 }
5133
5134 #[test]
5135 fn test_esc_in_history_search_restores_the_draft() {
5136 let mut s = history_state();
5137 type_str(&mut s, "dra");
5138 s.handle_key(ctrl('r'));
5139 type_str(&mut s, "ven");
5140 assert_eq!(s.input, "venues list");
5141 s.handle_key(key(KeyCode::Esc));
5142 assert_eq!(s.input, "dra");
5143 assert!(s.overlay.is_none());
5144 }
5145
5146 #[test]
5147 fn test_history_search_with_no_match_says_so_and_keeps_the_draft() {
5148 let mut s = history_state();
5149 type_str(&mut s, "dra");
5150 s.handle_key(ctrl('r'));
5151 type_str(&mut s, "zzz");
5152 screen(&s, 80, 20);
5153 assert!(status_row(&s, 80).contains("no match"));
5154 assert_eq!(s.input, "dra");
5155 for _ in 0..3 {
5156 s.handle_key(key(KeyCode::Backspace));
5157 }
5158 assert_eq!(s.input, "dra", "an empty query matches nothing");
5159 }
5160
5161 #[test]
5162 fn test_a_paste_goes_into_the_history_query() {
5163 let mut s = history_state();
5164 s.handle_key(ctrl('r'));
5165 s.paste("market");
5166 assert_eq!(s.input, "market summary USDC");
5167 }
5168
5169 #[test]
5170 fn test_n_and_shift_n_go_in_opposite_directions_over_three_matches() {
5171 let mut s = state();
5172 s.push_ansi("alpha\nalpha2\nbeta\nalpha3\n");
5173 screen(&s, 80, 20);
5174 open_search(&mut s, "alpha");
5175 let at = |s: &TuiState| status_row(s, 80);
5176 assert!(at(&s).contains("1/3"));
5177 s.handle_key(key(KeyCode::Enter));
5178 let shift_n = KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT);
5179 s.handle_key(shift_n);
5180 assert!(
5181 at(&s).contains("3/3"),
5182 "N wraps from the newest to the oldest"
5183 );
5184 s.handle_key(key(KeyCode::Char('n')));
5185 assert!(
5186 at(&s).contains("1/3"),
5187 "n wraps from the oldest to the newest"
5188 );
5189 s.handle_key(key(KeyCode::Char('n')));
5190 assert!(at(&s).contains("2/3"));
5191 s.handle_key(shift_n);
5192 assert!(at(&s).contains("1/3"), "N from the middle goes newer");
5193 s.handle_key(key(KeyCode::Char('n')));
5194 s.handle_key(key(KeyCode::Char('n')));
5195 assert!(at(&s).contains("3/3"));
5196 s.handle_key(key(KeyCode::Char('n')));
5197 assert!(at(&s).contains("1/3"));
5198 }
5199
5200 fn fill_to_limit(s: &mut TuiState, filler_first: usize) {
5201 for i in 0..filler_first {
5202 s.push_ansi(&format!("filler {i}\n"));
5203 }
5204 s.push_ansi("needle one\n");
5205 for i in 0..100 {
5206 s.push_ansi(&format!("more {i}\n"));
5207 }
5208 s.push_ansi("needle two\n");
5209 while s.scrollback.len() < SCROLLBACK_LIMIT {
5210 s.push_ansi("tail\n");
5211 }
5212 }
5213
5214 #[test]
5215 fn test_search_keeps_its_match_when_the_scrollback_drops_old_lines() {
5216 let mut s = state();
5217 fill_to_limit(&mut s, 100);
5218 assert_eq!(s.scrollback.len(), SCROLLBACK_LIMIT);
5219 screen(&s, 80, 20);
5220 open_search(&mut s, "needle");
5221 s.handle_key(key(KeyCode::Enter));
5222 s.handle_key(key(KeyCode::Char('n')));
5223 assert!(status_row(&s, 80).contains("2/2"), "on the older match");
5224 for i in 0..50 {
5225 s.push_ansi(&format!("new {i}\n"));
5226 }
5227 let row = screen_status(&s);
5228 assert!(
5229 row.contains("search: needle 2/2"),
5230 "still the same match: {row}"
5231 );
5232 s.handle_key(key(KeyCode::Char('n')));
5233 assert!(status_row(&s, 80).contains("1/2"));
5234 s.handle_key(key(KeyCode::Char('n')));
5235 for i in 0..100 {
5236 s.push_ansi(&format!("newer {i}\n"));
5237 }
5238 let row = screen_status(&s);
5240 assert!(row.contains("needle 1/1"), "{row}");
5241 }
5242
5243 fn screen_status(s: &TuiState) -> String {
5244 screen(s, 80, 20);
5245 status_row(s, 80)
5246 }
5247
5248 #[test]
5249 fn test_search_finds_the_live_output_of_a_running_command() {
5250 let _lock = super::super::DIAG_LOCK.blocking_lock();
5251 let (mut s, out) = running_state("market summary USDC --every 2s");
5252 s.push_ansi("needle done\n");
5253 out.write_out(format_args!("needle live\nother\n")).unwrap();
5254 screen(&s, 80, 20);
5255 open_search(&mut s, "needle");
5256 let row = status_row(&s, 140);
5257 assert!(row.contains("search: needle 1/2"), "{row}");
5258 assert!(screen(&s, 80, 20).matches('▶').count() == 2);
5259 }
5260
5261 #[test]
5262 fn test_opening_a_search_keeps_the_top_of_the_view_where_it_was() {
5263 let mut s = state();
5266 for i in 0..40 {
5267 s.push_ansi(&format!("r{i:02} {}\n", "x".repeat(55)));
5268 }
5269 screen(&s, 60, 15);
5270 s.handle_key(key(KeyCode::PageUp));
5271 s.handle_key(key(KeyCode::PageUp));
5272 let top_label = |text: &str| text.lines().nth(1).unwrap()[..3].trim().to_string();
5273 let before = top_label(&screen(&s, 60, 15));
5274 s.handle_key(ctrl('f'));
5275 let during = screen(&s, 60, 15);
5276 assert!(
5277 during.lines().nth(1).unwrap().contains(&before),
5278 "{before} stays at the top: {during}"
5279 );
5280 s.handle_key(key(KeyCode::Esc));
5281 assert_eq!(top_label(&screen(&s, 60, 15)), before);
5282 }
5283
5284 #[test]
5285 fn test_the_status_does_not_offer_pgdn_while_a_search_is_open() {
5286 let mut s = scrollable();
5287 s.handle_key(key(KeyCode::PageUp));
5288 assert!(status_row(&s, 80).contains("PgDn"));
5289 s.handle_key(ctrl('f'));
5290 screen(&s, 60, 15);
5291 let row = status_row(&s, 80);
5292 assert!(row.contains("scrolled up"), "{row}");
5293 assert!(!row.contains("PgDn"), "{row}");
5294 }
5295
5296 #[test]
5297 fn test_search_matches_the_text_of_colored_output_not_its_codes() {
5298 let mut s = state();
5299 s.push_ansi("al\x1b[31mpha\x1b[0m\n");
5300 screen(&s, 80, 20);
5301 open_search(&mut s, "alpha");
5302 assert!(status_row(&s, 80).contains("1/1"));
5303 s.handle_key(key(KeyCode::Esc));
5304 open_search(&mut s, "31m");
5305 assert!(status_row(&s, 80).contains("no match"));
5306 }
5307
5308 fn buffer(s: &TuiState, w: u16, h: u16) -> ratatui::buffer::Buffer {
5311 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
5312 term.draw(|f| s.render(f)).unwrap();
5313 term.backend().buffer().clone()
5314 }
5315
5316 fn row_text(buf: &ratatui::buffer::Buffer, y: u16) -> String {
5317 (0..buf.area.width)
5318 .map(|x| buf[(x, y)].symbol().to_string())
5319 .collect()
5320 }
5321
5322 fn rule_fg(buf: &ratatui::buffer::Buffer) -> Color {
5324 let x = (0..buf.area.width)
5325 .find(|&x| buf[(x, 0)].symbol() == "─")
5326 .expect("a rule cell");
5327 buf[(x, 0)].fg
5328 }
5329
5330 fn failed_state() -> TuiState {
5331 let mut s = state();
5332 s.start("x", &pane_invocation());
5333 s.finish(Ending::Finished(Err(scope::error::ScopeError::Chain(
5334 "boom".into(),
5335 ))));
5336 s
5337 }
5338
5339 #[test]
5340 fn test_pane_rule_is_accent_idle_warn_running_and_err_after_a_failure() {
5341 let idle = state();
5343 assert_eq!(rule_fg(&buffer(&idle, 60, 14)), Color::Cyan);
5344 let (running, _out) = running_state("venues list");
5345 assert_eq!(rule_fg(&buffer(&running, 60, 14)), Color::Yellow);
5346 let failed = failed_state();
5347 assert_eq!(rule_fg(&buffer(&failed, 60, 14)), Color::Red);
5348 }
5349
5350 #[test]
5351 fn test_failed_rule_lasts_until_the_next_command_starts() {
5352 let mut s = failed_state();
5353 assert_eq!(s.pane_state(), PaneState::Failed);
5354 s.start("venues list", &pane_invocation());
5355 assert_eq!(s.pane_state(), PaneState::Running);
5356 s.finish(Ending::Finished(Ok(())));
5357 assert_eq!(s.pane_state(), PaneState::Idle);
5358 }
5359
5360 #[test]
5361 fn test_cancelled_run_leaves_the_rule_idle() {
5362 let mut s = state();
5363 run_pane(&mut s, "x\n", Ending::Cancelled);
5364 assert_eq!(s.pane_state(), PaneState::Idle);
5365 }
5366
5367 #[test]
5368 fn test_state_word_is_at_the_right_end_of_the_top_rule_and_title_stays_left() {
5369 let buf = buffer(&state(), 60, 14);
5370 let top = row_text(&buf, 0);
5371 let title = format!(" scope v{} ", scope::VERSION);
5372 assert!(top.starts_with(&title), "{top:?}");
5373 assert!(top.trim_end().ends_with(" idle"), "{top:?}");
5374 assert!(buf[(1, 0)].modifier.contains(Modifier::BOLD), "title bold");
5375 assert_eq!(buf[(1, 0)].fg, Color::Cyan);
5376 }
5377
5378 #[test]
5379 fn test_prompt_spans_scope_accent_chain_key_rest_default() {
5380 let mut s = state();
5381 s.ctx.chain = "base".into();
5382 let prompt = s.prompt();
5383 assert_eq!(prompt.to_string(), "scope:base> ");
5384 assert_eq!(prompt.spans[0].style, ACCENT);
5385 assert_eq!(prompt.spans[1].style, KEY);
5386 assert_eq!(prompt.spans[2].style, Style::new());
5387 s.push_echo("venues list");
5388 let echo = last_line(&s);
5389 assert_eq!(echo.spans[0].style, ACCENT);
5390 assert_eq!(echo.spans[1].style, KEY);
5391 assert_eq!(echo.spans[3].content, "venues list");
5392 assert_eq!(echo.spans[3].style, Style::new());
5393 }
5394
5395 #[test]
5396 fn test_notes_carry_an_info_glyph_and_dim_text() {
5397 let mut s = state();
5398 s.push_note("hello");
5399 let note = last_line(&s);
5400 assert_eq!(note.to_string(), "ℹ hello");
5401 assert_eq!(note.spans[0].style, INFO);
5402 assert_eq!(note.spans[1].style, SECONDARY);
5403 }
5404
5405 #[test]
5406 fn test_selected_palette_row_is_reversed_cyan() {
5407 let mut s = state();
5408 s.handle_key(ctrl_k());
5409 let buf = buffer(&s, 100, 24);
5410 let selected = buf.content.iter().any(|c| {
5411 c.fg == Color::Cyan && c.modifier.contains(Modifier::REVERSED) && c.symbol() != " "
5412 });
5413 assert!(selected, "a reversed cyan palette row");
5414 let names = buf
5416 .content
5417 .iter()
5418 .any(|c| c.fg == Color::Cyan && !c.modifier.contains(Modifier::REVERSED));
5419 assert!(names);
5420 }
5421
5422 #[test]
5423 fn test_search_match_is_reversed_yellow() {
5424 let mut s = alpha_state();
5425 open_search(&mut s, "alpha");
5426 let buf = buffer(&s, 80, 20);
5427 let hits = buf
5428 .content
5429 .iter()
5430 .filter(|c| c.fg == Color::Yellow && c.modifier.contains(Modifier::REVERSED))
5431 .count();
5432 assert!(hits >= 2 * "alpha".len(), "{hits} matched cells");
5433 }
5434
5435 #[test]
5436 fn test_status_values_are_accent_and_labels_secondary() {
5437 let line = fit_status(&state().status_parts(), 100);
5438 let find = |text: &str| {
5439 line.spans
5440 .iter()
5441 .find(|sp| sp.content.contains(text))
5442 .unwrap_or_else(|| panic!("no span with {text:?}"))
5443 };
5444 assert_eq!(find("chain").style, SECONDARY);
5445 assert_eq!(find("auto").style, ACCENT);
5446 assert_eq!(find("format").style, SECONDARY);
5447 assert_eq!(find("help").style, ACCENT);
5448 }
5449
5450 #[test]
5451 fn test_running_status_is_warn_and_esc_cancels_is_bold() {
5452 let (s, _out) = running_state("venues list");
5453 let line = fit_status(&s.status_parts(), 120);
5454 let find = |text: &str| {
5455 line.spans
5456 .iter()
5457 .find(|sp| sp.content.contains(text))
5458 .unwrap_or_else(|| panic!("no span with {text:?}"))
5459 };
5460 assert_eq!(find("running").style, WARN);
5461 assert_eq!(
5462 find("Esc cancels").style,
5463 Style::new().add_modifier(Modifier::BOLD)
5464 );
5465 assert_eq!(find("`venues list`").style, ACCENT);
5466 }
5467
5468 #[test]
5469 fn test_hint_next_argument_is_a_key_and_busy_note_is_warn() {
5470 let mut s = state();
5471 type_str(&mut s, "address");
5472 let buf = buffer(&s, 100, 14);
5473 let hint_row = 14 - 2;
5474 let next = (0..100u16)
5475 .rfind(|&x| buf[(x, hint_row)].symbol() == "<")
5476 .unwrap();
5477 assert_eq!(buf[(next, hint_row)].fg, Color::Magenta);
5478 assert!(buf[(next, hint_row)].modifier.contains(Modifier::BOLD));
5479 let (mut busy, _out) = running_state("venues list");
5480 busy.busy_hint = true;
5481 let buf = buffer(&busy, 100, 14);
5482 let x = (0..100u16)
5483 .find(|&x| buf[(x, hint_row)].symbol() == "A")
5484 .unwrap();
5485 assert_eq!(buf[(x, hint_row)].fg, Color::Yellow);
5486 }
5487
5488 #[test]
5489 fn test_with_color_off_every_state_still_differs_by_text() {
5490 let strip = |s: &TuiState| {
5492 let mut buf = buffer(s, 60, 14);
5493 crate::cli::plain::strip_color(&mut buf);
5494 assert!(buf.content.iter().all(|c| c.fg == Color::Reset));
5495 buf
5496 };
5497 let idle = row_text(&strip(&state()), 0);
5498 let (running, _out) = running_state("venues list");
5499 let running = row_text(&strip(&running), 0);
5500 let failed = row_text(&strip(&failed_state()), 0);
5501 assert!(idle.trim_end().ends_with(" idle"), "{idle:?}");
5502 assert!(running.trim_end().ends_with(" running"), "{running:?}");
5503 assert!(failed.trim_end().ends_with(" failed"), "{failed:?}");
5504 let mut s = state();
5506 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
5507 assert!(last_line(&s).to_string().starts_with("✓ Done in "));
5508 let failed = failed_state();
5509 assert!(last_line(&failed).to_string().starts_with("✗ Failed in "));
5510 assert!(
5512 last_line(&failed)
5513 .style
5514 .add_modifier
5515 .contains(Modifier::BOLD)
5516 );
5517 let buf = strip(&failed);
5518 assert!(
5519 buf.content
5520 .iter()
5521 .any(|c| c.symbol() == "✗" && c.modifier.contains(Modifier::BOLD))
5522 );
5523 }
5524
5525 #[test]
5526 fn test_ctrl_k_and_tab_open_no_overlay_while_the_terminal_is_too_small() {
5527 let mut s = state();
5529 type_str(&mut s, "m");
5530 screen(&s, 80, 24);
5531 s.handle_key(key(KeyCode::Tab));
5532 assert!(
5533 matches!(s.overlay, Some(Overlay::Menu(_))),
5534 "Tab offers a menu at a normal size"
5535 );
5536 s.handle_key(key(KeyCode::Esc));
5537 screen(&s, 30, 8);
5538 s.handle_key(key(KeyCode::Tab));
5539 assert!(s.overlay.is_none());
5540 assert_eq!(s.input, "m", "Tab did not complete unseen");
5541 s.handle_key(ctrl('k'));
5542 assert!(s.overlay.is_none());
5543 }
5544
5545 #[test]
5546 fn test_status_styles_come_from_the_parts_not_from_their_text() {
5547 let mut s = alpha_state();
5550 s.push_ansi("x · running\n");
5551 open_search(&mut s, "x · running");
5552 let line = fit_status(&s.status_parts(), 120);
5553 let at = |text: &str| {
5554 line.spans
5555 .iter()
5556 .position(|sp| sp.content.contains(text))
5557 .unwrap_or_else(|| panic!("no {text:?} in {line:?}"))
5558 };
5559 for sp in &line.spans[at("search:")..=at("1/1")] {
5560 assert_eq!(sp.style, ACCENT, "{sp:?} in {line:?}");
5561 }
5562 let (run, _out) = running_state("venues list");
5565 let line = fit_status(&run.status_parts(), 120);
5566 let style_of = |text: &str| {
5567 line.spans
5568 .iter()
5569 .find(|sp| sp.content == text)
5570 .unwrap_or_else(|| panic!("no span {text:?} in {line:?}"))
5571 .style
5572 };
5573 assert_eq!(
5574 style_of("Esc cancels"),
5575 Style::new().add_modifier(Modifier::BOLD)
5576 );
5577 assert_eq!(style_of(STATUS_SEP), SECONDARY);
5578 assert_eq!(style_of("`venues list`"), ACCENT);
5579 }
5580
5581 #[test]
5582 fn test_help_shows_key_names_in_the_key_style() {
5583 let mut s = state();
5584 assert!(matches!(s.submit("help", &Config::default()), Plan::Done));
5585 let span = s
5586 .scrollback
5587 .iter()
5588 .flat_map(|l| l.spans.iter())
5589 .find(|sp| sp.content == "Ctrl-K")
5590 .expect("help names Ctrl-K");
5591 assert_eq!(span.style.fg, Some(Color::Magenta));
5592 assert!(span.style.add_modifier.contains(Modifier::BOLD));
5593 assert_eq!(span.style, Style::reset().patch(KEY));
5596 let what = s
5597 .scrollback
5598 .iter()
5599 .flat_map(|l| l.spans.iter())
5600 .find(|sp| sp.content == "palette")
5601 .expect("help says what Ctrl-K does");
5602 assert_eq!(what.style, Style::reset().patch(SECONDARY));
5603 }
5604
5605 #[test]
5606 fn test_any_accepted_line_clears_the_failed_state() {
5607 for line in ["ctx", "format json", "setup"] {
5610 let mut s = failed_state();
5611 assert_eq!(s.pane_state(), PaneState::Failed);
5612 s.submit(line, &Config::default());
5613 assert_eq!(s.pane_state(), PaneState::Idle, "{line}");
5614 }
5615 let mut s = failed_state();
5617 s.submit(" ", &Config::default());
5618 assert_eq!(s.pane_state(), PaneState::Failed);
5619 }
5620}