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 size = market_view::Size::for_width(self.pane_width().unwrap_or(80));
1848 self.push_lines(market_view::lines(&view, size));
1849 self.push_ansi(¬es);
1850 }
1851 _ => self.push_ansi(&merged),
1852 }
1853 }
1854
1855 fn leave_repeat_view(&mut self, view: RepeatView) {
1859 if let Some(snapshot) = repeat_view::final_snapshot(&view.series) {
1860 self.push_ansi(&snapshot);
1861 }
1862 self.push_ansi(&view.pending);
1863 self.push_note(&repeat_view::summary(&view.series));
1864 if let Some(ended) = view.ended {
1865 self.push_styled_note(&ended.text, ended.style);
1866 }
1867 }
1868
1869 fn active_view(&self) -> Option<RepeatView> {
1872 if let Some(run) = &self.running {
1873 let series = run.series.as_ref().filter(|s| repeat_view::has_runs(s))?;
1874 return Some(RepeatView {
1875 series: series.clone(),
1876 ended: None,
1877 pending: String::new(),
1878 });
1879 }
1880 self.repeat_view.clone()
1881 }
1882
1883 fn keep_copy(&mut self, captured: &Captured, ending: &Ending) {
1888 if matches!(ending, Ending::Finished(Ok(()))) {
1889 let data = crate::cli::ansi::strip(&captured.data());
1890 self.last_output = (!captured.trimmed() && !data.trim().is_empty()).then_some(data);
1891 }
1892 }
1893
1894 pub fn show_outside(&mut self, copy: &Captured, ending: &Ending) {
1899 if copy.trimmed() {
1900 self.push_note(
1901 "Its output was long: only the end is kept here, and Ctrl-Y and save do not have it.",
1902 );
1903 }
1904 self.keep_copy(copy, ending);
1905 self.push_ansi(©.out());
1906 }
1907
1908 pub fn record_suspended(
1911 &mut self,
1912 input: &str,
1913 inv_argv: &[String],
1914 duration: std::time::Duration,
1915 started_at: String,
1916 ending: &Ending,
1917 ) {
1918 let (outcome, error) = ending.outcome();
1919 self.failed = matches!(ending, Ending::Finished(Err(_)));
1922 let word = if outcome == "error" {
1924 "failed"
1925 } else {
1926 outcome
1927 };
1928 self.push_note(&format!(
1929 "Ran outside the TUI: {} ({}, {:.1}s).",
1930 input,
1931 word,
1932 duration.as_secs_f64()
1933 ));
1934 self.log(Event {
1935 started: started_at,
1936 duration_ms: duration.as_millis(),
1937 input,
1938 argv: inv_argv,
1939 route: "suspend",
1940 outcome: outcome.into(),
1941 error,
1942 });
1943 }
1944
1945 pub fn record_signal(&mut self, name: &str, route: &'static str, input: &str, argv: &[String]) {
1949 self.log(Event {
1950 started: Utc::now().to_rfc3339(),
1951 duration_ms: 0,
1952 input,
1953 argv,
1954 route,
1955 outcome: format!("signal:{}", name).into(),
1956 error: None,
1957 });
1958 }
1959
1960 fn log(&mut self, event: Event<'_>) {
1961 if let Err(e) = self.journal.record(&event) {
1962 self.push_note(&format!("Could not write the TUI event log: {}", e));
1963 }
1964 }
1965
1966 pub fn pane_width(&self) -> Option<usize> {
1971 match self.pane_width.get() {
1972 0 => None,
1973 w => Some(w as usize),
1974 }
1975 }
1976
1977 pub fn render(&self, f: &mut Frame) {
1979 let area = f.area();
1980 let too_small = area.width < MIN_W || area.height < MIN_H;
1981 self.too_small.set(too_small);
1982 if too_small {
1983 self.pane_width.set(0);
1987 self.last_pane_height.set(0);
1988 draw_note_alone(f, &too_small_note());
1989 return;
1990 }
1991 let [pane, input, hint_row, status] = Layout::vertical([
1992 Constraint::Min(3),
1993 Constraint::Length(3),
1994 Constraint::Length(1),
1995 Constraint::Length(1),
1996 ])
1997 .areas(f.area());
1998
1999 let live_out = self.live_output();
2001 let liveness = match &self.running {
2004 Some(run) => self.liveness_lines(run, color_enabled()),
2005 None => Vec::new(),
2006 };
2007 let state = self.pane_state();
2009 let block = Block::new()
2010 .borders(Borders::TOP | Borders::BOTTOM)
2011 .border_style(state.style())
2012 .title(Line::styled(
2013 format!(" scope v{} ", scope::VERSION),
2014 state.style().add_modifier(Modifier::BOLD),
2015 ))
2016 .title(Line::styled(format!(" {} ", state.word()), state.style()).right_aligned());
2017 let inner = block.inner(pane);
2018 self.pane_width.set(inner.width);
2019 let view = self.search_view(&live_out);
2021 if let Some(w) = &self.wizard {
2022 wizard::draw(f, pane, w);
2023 self.last_max_top.set(0);
2024 self.last_pane_height.set(inner.height as usize);
2025 } else if let (true, Some(g)) = (self.grid_shown, &self.grid) {
2026 grid::draw(f, pane, g);
2028 self.last_max_top.set(0);
2029 self.last_pane_height.set(inner.height as usize);
2030 } else if let Some(repeating) = self.active_view() {
2031 let status = if self.running.is_some() {
2033 liveness
2034 } else {
2035 Vec::new()
2036 };
2037 f.render_widget(block, pane);
2038 repeat_view::draw(f, inner, &repeating, status);
2039 self.last_max_top.set(0);
2040 self.last_pane_height.set(inner.height as usize);
2041 } else {
2042 let width = inner.width as usize;
2046 let in_pane_search = matches!(self.overlay, Some(Overlay::Search(_)));
2049 let (rows, starts) = match (&view, in_pane_search) {
2050 (Some(v), true) => {
2051 let entries: Vec<_> =
2052 self.scrollback.iter().chain(&live_out).cloned().collect();
2053 marked_rows(&entries, liveness.clone(), &v.matches, v.current, width)
2054 }
2055 _ => {
2056 let mut lines = Vec::new();
2057 if self.dropped_lines > 0 {
2058 lines.push(Line::styled(
2060 format!(
2061 "… {} older lines dropped (the pane keeps the last {SCROLLBACK_LIMIT})",
2062 self.dropped_lines
2063 ),
2064 SECONDARY,
2065 ));
2066 }
2067 lines.extend(self.scrollback.iter().cloned());
2068 lines.extend(live_out.iter().cloned());
2069 lines.extend(liveness.iter().cloned());
2070 (fit::fit(lines, width), Vec::new())
2071 }
2072 };
2073 let para = Paragraph::new(Text::from(rows));
2074 let total = para.line_count(inner.width);
2075 let max_top = total.saturating_sub(inner.height as usize);
2076 self.last_max_top.set(max_top);
2077 self.last_pane_height.set(inner.height as usize);
2078 if let Some(line) = self.pin_top.take() {
2079 let mut above = Vec::new();
2081 if self.dropped_lines > 0 {
2082 above.push(Line::raw(""));
2083 }
2084 above.extend(self.scrollback.iter().take(line).cloned());
2085 let row = fit::fit(above, width).len();
2086 self.scroll.set(max_top.saturating_sub(row));
2087 }
2088 if let (Some(v), true) = (&view, in_pane_search) {
2089 let before = self.search_width.replace(width);
2090 let height = inner.height as usize;
2091 if self.reveal.replace(false) {
2092 self.reveal_match(&starts, v.current, height, max_top);
2093 } else if before == 0 && self.scroll.get() > 0 {
2094 let lines: Vec<_> = self.scrollback.iter().chain(&live_out).cloned().collect();
2097 let old = entry_starts(&lines, width);
2098 let total =
2099 old.last().copied().unwrap_or(0) + fit::fit(liveness.clone(), width).len();
2100 let old_max = total.saturating_sub(height);
2101 let old_top = old_max - self.scroll.get().min(old_max);
2102 let entry = old.partition_point(|&s| s <= old_top).saturating_sub(1);
2103 let top = starts.get(entry).copied().unwrap_or(max_top).min(max_top);
2104 self.scroll.set(max_top - top);
2105 } else if before != width {
2106 self.reveal_match(&starts, v.current, height, max_top);
2107 }
2108 } else {
2109 self.search_width.set(0);
2110 }
2111 let top = max_top.saturating_sub(self.scroll.get().min(max_top));
2112 f.render_widget(para.block(block).scroll((top as u16, 0)), pane);
2113 }
2114
2115 let block = Block::bordered().title(self.prompt());
2117 let inner_width = input.width.saturating_sub(2).max(1) as usize;
2119 let col = self.cursor_column();
2120 let scroll_x = self.input_scroll(inner_width);
2121 f.render_widget(
2122 Paragraph::new(self.input.as_str())
2123 .block(block)
2124 .scroll((0, scroll_x.min(u16::MAX as usize) as u16)),
2125 input,
2126 );
2127 let cursor_x = input.x + 1 + (col - scroll_x).min(u16::MAX as usize) as u16;
2128 f.set_cursor_position(Position::new(
2129 cursor_x.min(input.right().saturating_sub(2)),
2130 input.y + 1,
2131 ));
2132
2133 if let Some(Overlay::Menu(m)) = &self.overlay {
2134 let rows = m.candidates.len().min(MENU_ROWS);
2135 let first = m.selected.saturating_sub(rows - 1);
2136 let height = (rows as u16 + 2).min(input.y);
2137 let width = m
2138 .candidates
2139 .iter()
2140 .map(|c| UnicodeWidthStr::width(c.as_str()))
2141 .max()
2142 .unwrap_or(0) as u16
2143 + 4;
2144 let area = ratatui::layout::Rect {
2145 x: input.x,
2146 y: input.y.saturating_sub(height),
2147 width: width.min(input.width),
2148 height,
2149 };
2150 let lines: Vec<Line> = m.candidates[first..first + rows]
2151 .iter()
2152 .enumerate()
2153 .map(|(i, c)| {
2154 let style = if first + i == m.selected {
2155 SELECTED
2156 } else {
2157 Style::new()
2158 };
2159 Line::styled(c.clone(), style)
2160 })
2161 .collect();
2162 f.render_widget(ratatui::widgets::Clear, area);
2163 f.render_widget(Paragraph::new(lines).block(Block::bordered()), area);
2164 }
2165
2166 if let Some(Overlay::Choice(ch)) = &self.overlay {
2167 let frame = f.area();
2168 let fit = usize::from(input.y.saturating_sub(2)).max(1);
2171 let rows = ch.options.len().clamp(1, MENU_ROWS).min(fit);
2172 let first = ch.selected.saturating_sub(rows - 1);
2173 let height = (rows as u16 + 2).min(input.y);
2174 const NUMBER_COLS: usize = 4;
2176 let widest = ch
2177 .options
2178 .iter()
2179 .map(|o| UnicodeWidthStr::width(o.as_str()) + NUMBER_COLS)
2180 .chain([UnicodeWidthStr::width(ch.prompt.as_str())])
2181 .max()
2182 .unwrap_or(0) as u16;
2183 let area = ratatui::layout::Rect {
2184 x: input.x,
2185 y: input.y.saturating_sub(height),
2186 width: (widest + 2).min(frame.width.saturating_sub(input.x)),
2187 height,
2188 };
2189 let shown = ch.options.iter().enumerate().skip(first).take(rows);
2190 let lines: Vec<Line> = shown
2191 .map(|(i, o)| {
2192 let style = if i == ch.selected {
2193 SELECTED
2194 } else {
2195 Style::new()
2196 };
2197 Line::styled(format!("{:>2}. {o}", i + 1), style)
2198 })
2199 .collect();
2200 f.render_widget(ratatui::widgets::Clear, area);
2201 f.render_widget(
2202 Paragraph::new(lines).block(Block::bordered().title(ch.prompt.as_str())),
2203 area,
2204 );
2205 }
2206
2207 if self.busy_hint {
2208 f.render_widget(
2209 Paragraph::new(Line::styled(format!(" {}", BUSY_NOTE), WARN)),
2210 hint_row,
2211 );
2212 } else if let Some(h) = super::hint::hint(&self.input) {
2213 let mut spans = vec![Span::styled(format!(" {}", h.text), SECONDARY)];
2214 if let Some(next) = h.next {
2215 spans.push(Span::styled(" next: ", SECONDARY));
2216 spans.push(Span::styled(next, KEY));
2217 }
2218 f.render_widget(Paragraph::new(Line::from(spans)), hint_row);
2219 } else if self.overlay.is_none() {
2220 f.render_widget(
2222 Paragraph::new(nav_hints(&self.nav_keys(), hint_row.width as usize)),
2223 hint_row,
2224 );
2225 }
2226 if let Some(Overlay::Palette(p)) = &self.overlay {
2227 let area = f.area();
2228 let width = area.width.saturating_sub(4).min(80);
2229 let height = area.height.saturating_sub(4).min(16);
2230 let rect = ratatui::layout::Rect {
2231 x: area.x + (area.width.saturating_sub(width)) / 2,
2232 y: area.y + 1,
2233 width,
2234 height,
2235 };
2236 let rows = height.saturating_sub(4) as usize;
2238 let matches = p.matches();
2239 let first = p.selected.saturating_sub(rows.saturating_sub(1));
2240 let mut lines = vec![Line::from(format!("> {}", p.query))];
2241 for (n, &i) in matches.iter().enumerate().skip(first).take(rows) {
2242 let it = &p.items[i];
2243 let (marker, style) = if n == p.selected {
2245 (PALETTE_MARKER, SELECTED)
2246 } else {
2247 (" ", ACCENT)
2248 };
2249 lines.push(Line::from(vec![
2250 Span::styled(marker, KEY),
2251 Span::styled(format!("{:<28}", it.label), style),
2252 Span::styled(format!(" {}", it.description), SECONDARY),
2253 ]));
2254 }
2255 lines.push(Line::styled(PALETTE_LEGEND, SECONDARY));
2256 f.render_widget(ratatui::widgets::Clear, rect);
2257 f.render_widget(
2258 Paragraph::new(lines).block(Block::bordered().title(" Commands (Ctrl-K) ")),
2259 rect,
2260 );
2261 }
2262 f.render_widget(
2263 Paragraph::new(fit_status(
2264 &self.status_parts_with(view.map(|v| v.text)),
2265 status.width as usize,
2266 )),
2267 status,
2268 );
2269 }
2270
2271 fn reveal_match(
2275 &self,
2276 starts: &[usize],
2277 current: Option<usize>,
2278 height: usize,
2279 max_top: usize,
2280 ) {
2281 let Some(at) = current.filter(|&c| c + 1 < starts.len()) else {
2282 return;
2283 };
2284 let (first, rows) = (starts[at], starts[at + 1] - starts[at]);
2285 let top = first
2286 .saturating_sub(height.saturating_sub(rows) / 2)
2287 .min(max_top);
2288 self.scroll.set(max_top - top);
2289 }
2290
2291 fn liveness_lines(&self, run: &Running, color: bool) -> Vec<Line<'static>> {
2293 let dim = SECONDARY;
2294 let elapsed = run.started.elapsed();
2295 let mut lines = vec![Line::from(vec![
2296 Span::styled(spinner_glyph(elapsed, color), WARN),
2297 Span::styled(
2298 format!(" running {}… {}s", run.input, elapsed.as_secs()),
2299 dim,
2300 ),
2301 ])];
2302 if let Some(hook) = run.out.progress_hook() {
2303 let p = hook.lock().unwrap_or_else(|e| e.into_inner()).clone();
2305 if p.run > 0 {
2306 let mut text = match p.total {
2307 Some(total) => format!("run {} of {}", p.run, total),
2308 None => format!("run {}", p.run),
2309 };
2310 if let Some(at) = p.next_at {
2311 let wait = at.saturating_duration_since(Instant::now());
2312 text.push_str(&format!(" · next in {}s", wait.as_secs_f64().ceil() as u64));
2313 }
2314 lines.push(Line::styled(text, dim));
2315 }
2316 }
2317 lines
2318 }
2319
2320 fn esc_action(&self) -> EscAction {
2324 match (&self.overlay, &self.running) {
2325 (Some(Overlay::Choice(_)), _) => EscAction::AnswerChoice,
2326 (Some(Overlay::Palette(_)), _) => EscAction::ClosePalette,
2327 (Some(Overlay::Menu(_)), _) => EscAction::CloseMenu,
2328 (Some(Overlay::Search(_)), _) => EscAction::CloseSearch,
2329 (Some(Overlay::HistorySearch(_)), _) => EscAction::CloseHistorySearch,
2330 (None, Some(_)) => EscAction::Cancel,
2331 (None, None) => EscAction::ClearLine,
2332 }
2333 }
2334
2335 fn press_esc(&mut self) -> Action {
2339 match self.esc_action() {
2340 EscAction::ClosePalette
2341 | EscAction::CloseMenu
2342 | EscAction::CloseSearch
2343 | EscAction::CloseHistorySearch => self.close_overlay(),
2344 EscAction::AnswerChoice => self.answer_choice(None),
2345 EscAction::Cancel => return Action::Cancel,
2346 EscAction::ClearLine => {
2347 let line = std::mem::take(&mut self.input);
2348 self.keep_cleared(line);
2349 self.cursor = 0;
2350 }
2351 }
2352 Action::None
2353 }
2354
2355 #[cfg(test)]
2358 fn status_parts(&self) -> StatusParts {
2359 let search = self.search_view(&self.live_output()).map(|v| v.text);
2360 self.status_parts_with(search)
2361 }
2362
2363 fn status_parts_with(&self, search: Option<String>) -> StatusParts {
2365 let ctx = &self.ctx;
2366 let mut limit = labeled("limit", ctx.limit.to_string());
2367 for (on, flag) in [(ctx.include_tokens, "+tokens"), (ctx.include_txs, "+txs")] {
2368 if on {
2369 limit.extend([sep(), Span::styled(flag, ACCENT)]);
2370 }
2371 }
2372 let mut context = vec![
2374 labeled("chain", ctx.chain.clone()),
2375 labeled("format", format!("{:?}", ctx.format)),
2376 limit,
2377 ];
2378 if let (false, Some(g)) = (self.grid_shown, &self.grid) {
2380 let style = if g.breaches() > 0 { ERR } else { ACCENT };
2381 context.insert(0, vec![Span::styled(g.status_part(), style)]);
2382 }
2383 let scrolled = self.scroll.get().min(self.last_max_top.get());
2385 let scrolled = (scrolled > 0).then(|| {
2386 let mut part = vec![Span::styled(
2387 format!("scrolled up {} lines", scrolled),
2388 ACCENT,
2389 )];
2390 if search.is_none() {
2393 part.extend([sep(), Span::styled("PgDn", ACCENT)]);
2394 }
2395 part
2396 });
2397 let esc = self
2398 .esc_action()
2399 .hint()
2400 .map(|hint| Span::styled(hint, Style::new().add_modifier(Modifier::BOLD)));
2401 let (state, scrolled, command) = match (&self.running, esc) {
2402 (Some(run), esc) => {
2403 let mut state = vec![Span::styled(
2404 format!("running {:.0}s", run.started.elapsed().as_secs_f64()),
2405 WARN,
2406 )];
2407 state.extend(esc.into_iter().flat_map(|esc| [sep(), esc]));
2408 (state, scrolled, Some(run.input.clone()))
2409 }
2410 (None, Some(esc)) => (vec![esc], scrolled, None),
2412 (None, None) => (
2414 scrolled.unwrap_or_else(|| {
2415 vec![
2416 Span::styled("help", ACCENT),
2417 sep(),
2418 Span::styled("exit", ACCENT),
2419 ]
2420 }),
2421 None,
2422 None,
2423 ),
2424 };
2425 StatusParts {
2426 context,
2427 state,
2428 scrolled,
2429 search: search.map(|text| vec![Span::styled(text, ACCENT)]),
2430 command,
2431 }
2432 }
2433}
2434
2435type Spans = Vec<Span<'static>>;
2437
2438fn nav_hints(keys: &[(&'static str, &'static str)], width: usize) -> Line<'static> {
2441 let mut spans = vec![Span::raw(" ")];
2442 let mut used = 1;
2443 for (i, (key, what)) in keys.iter().enumerate() {
2444 let sep = if i == 0 { "" } else { " · " };
2445 let cols = UnicodeWidthStr::width(*key) + 1 + what.len() + sep.len();
2446 if used + cols > width {
2447 break;
2448 }
2449 used += cols;
2450 if !sep.is_empty() {
2451 spans.push(Span::styled(sep, SECONDARY));
2452 }
2453 spans.push(Span::styled(*key, KEY));
2454 spans.push(Span::styled(format!(" {what}"), SECONDARY));
2455 }
2456 Line::from(spans)
2457}
2458
2459struct StatusParts {
2462 context: Vec<Spans>,
2465 state: Spans,
2468 scrolled: Option<Spans>,
2471 search: Option<Spans>,
2474 command: Option<String>,
2477}
2478
2479const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2481
2482const SPINNER_ASCII: [&str; 4] = ["-", "\\", "|", "/"];
2484
2485fn spinner_glyph(elapsed: std::time::Duration, color: bool) -> &'static str {
2487 let step = (elapsed.as_millis() / super::LIVE_REDRAW.as_millis()) as usize;
2488 if color {
2489 SPINNER_FRAMES[step % SPINNER_FRAMES.len()]
2490 } else {
2491 SPINNER_ASCII[step % SPINNER_ASCII.len()]
2492 }
2493}
2494
2495const COMMAND_FRAME_COLS: usize = 5;
2498
2499const MIN_CUT_COMMAND_COLS: usize = 2;
2501
2502const STATUS_SEP: &str = " · ";
2504
2505fn sep() -> Span<'static> {
2507 Span::styled(STATUS_SEP, SECONDARY)
2508}
2509
2510fn labeled(label: &str, value: String) -> Spans {
2512 vec![
2513 Span::styled(format!("{label} "), SECONDARY),
2514 Span::styled(value, ACCENT),
2515 ]
2516}
2517
2518fn spans_cols(spans: &[Span<'static>]) -> usize {
2520 spans.iter().map(|sp| cols(&sp.content)).sum()
2521}
2522
2523fn joined<'a>(parts: impl IntoIterator<Item = &'a Spans>) -> Spans {
2526 let mut out = Spans::new();
2527 for (i, part) in parts.into_iter().enumerate() {
2528 if i > 0 {
2529 out.push(sep());
2530 }
2531 out.extend(part.iter().cloned());
2532 }
2533 out
2534}
2535
2536fn cut_spans(spans: Spans, max: usize) -> Spans {
2538 let full: String = spans.iter().map(|sp| sp.content.as_ref()).collect();
2539 let cut = cut_with_ellipsis(&full, max);
2540 if cut == full {
2541 return spans;
2542 }
2543 let ellipsis = cut.ends_with(ELLIPSIS);
2544 let mut keep = cut.len() - if ellipsis { ELLIPSIS.len() } else { 0 };
2546 let mut out = Spans::new();
2547 for sp in spans {
2548 if keep == 0 {
2549 break;
2550 }
2551 let n = keep.min(sp.content.len());
2552 out.push(Span::styled(sp.content[..n].to_string(), sp.style));
2553 keep -= n;
2554 }
2555 if ellipsis {
2556 out.push(Span::styled(ELLIPSIS, SECONDARY));
2557 }
2558 out
2559}
2560
2561fn fit_status(parts: &StatusParts, width: usize) -> Line<'static> {
2565 if width == 0 {
2566 return Line::default();
2567 }
2568 let lead = || Span::raw(" ");
2569 let more = || Span::styled(format!(" {ELLIPSIS}"), SECONDARY);
2570 let command = |c: &str| vec![sep(), Span::styled(format!("`{c}`"), ACCENT)];
2571 let note = |part: &Option<Spans>| {
2572 part.as_ref()
2573 .map(|p| joined([&Spans::new(), p]))
2574 .unwrap_or_default()
2575 };
2576 let scroll_note = note(&parts.scrolled);
2577 let search_note = note(&parts.search);
2578 let fits = |line: &Spans| spans_cols(line) <= width;
2579 let total = parts.context.len();
2580 for kept in (0..=total).rev() {
2581 let mut line = vec![lead()];
2582 if kept > 0 {
2583 line.extend(joined(&parts.context[..kept]));
2584 line.push(Span::raw(" "));
2585 }
2586 line.extend(parts.state.iter().cloned());
2587 line.extend(scroll_note.iter().cloned());
2588 line.extend(search_note.iter().cloned());
2589 if let Some(c) = &parts.command {
2590 line.extend(command(c));
2591 }
2592 if kept < total {
2593 line.push(more());
2594 }
2595 if fits(&line) {
2596 return Line::from(line);
2597 }
2598 }
2599 let both = [scroll_note.clone(), search_note.clone()].concat();
2602 for note in [both, search_note.clone()] {
2603 let used = 1 + spans_cols(&parts.state) + spans_cols(¬e) + COMMAND_FRAME_COLS;
2604 if let Some(c) = &parts.command
2605 && width >= used + MIN_CUT_COMMAND_COLS
2606 {
2607 let cut = cut_with_ellipsis(c, width - used);
2608 let line = [vec![lead()], parts.state.clone(), note, command(&cut)].concat();
2609 return Line::from(line);
2610 }
2611 }
2612 let head = [vec![lead()], parts.state.clone()].concat();
2614 if parts.search.is_some() {
2615 let line = [head.clone(), search_note, vec![more()]].concat();
2616 if fits(&line) {
2617 return Line::from(line);
2618 }
2619 }
2620 let line = [head.clone(), vec![more()]].concat();
2621 if fits(&line) {
2622 return Line::from(line);
2623 }
2624 Line::from(cut_spans(head, width))
2625}
2626
2627fn entry_starts(lines: &[Line<'static>], width: usize) -> Vec<usize> {
2630 let mut rows = 0;
2631 let mut starts = Vec::with_capacity(lines.len() + 1);
2632 for line in lines {
2633 starts.push(rows);
2634 rows += fit::fit(vec![line.clone()], width).len();
2635 }
2636 starts.push(rows);
2637 starts
2638}
2639
2640fn marked_rows(
2646 scrollback: &[Line<'static>],
2647 extra: Vec<Line<'static>>,
2648 matches: &[usize],
2649 current: Option<usize>,
2650 width: usize,
2651) -> (Vec<Line<'static>>, Vec<usize>) {
2652 let text_width = width.saturating_sub(cols(MATCH_MARK)).max(1);
2653 let gutter = |mut row: Line<'static>, hit: bool, now: bool| {
2654 let mark = if hit { MATCH_MARK } else { MATCH_BLANK };
2655 row.spans.insert(0, Span::raw(mark));
2656 if hit {
2657 let mut style = MATCH;
2658 if now {
2659 style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
2660 }
2661 row = row.patch_style(style);
2662 }
2663 row
2664 };
2665 let mut rows = Vec::new();
2666 let mut starts = Vec::with_capacity(scrollback.len() + 1);
2667 for (i, line) in scrollback.iter().enumerate() {
2668 starts.push(rows.len());
2669 let hit = matches.binary_search(&i).is_ok();
2670 for row in fit::fit(vec![line.clone()], text_width) {
2671 rows.push(gutter(row, hit, current == Some(i)));
2672 }
2673 }
2674 starts.push(rows.len());
2675 for row in fit::fit(extra, text_width) {
2676 rows.push(gutter(row, false, false));
2677 }
2678 (rows, starts)
2679}
2680
2681pub async fn run_invocation(
2684 inv: Invocation,
2685 config: &Config,
2686 clients: &dyn scope::chains::ChainClientFactory,
2687 out: &Output,
2688) -> scope::error::Result<()> {
2689 debug_assert!(!matches!(inv.route, Route::Refuse(_)));
2690 if inv.ai {
2691 let mut config = config.clone();
2692 config.output.format = scope::config::OutputFormat::Markdown;
2693 crate::cli::dispatch::dispatch_with(inv.command, &config, clients, out).await
2694 } else {
2695 crate::cli::dispatch::dispatch_with(inv.command, config, clients, out).await
2696 }
2697}
2698
2699#[cfg(test)]
2704mod tests {
2705 use super::*;
2706 use ratatui::Terminal;
2707 use ratatui::backend::TestBackend;
2708 use ratatui::style::Color;
2709
2710 fn key(code: KeyCode) -> KeyEvent {
2711 KeyEvent::new(code, KeyModifiers::NONE)
2712 }
2713
2714 fn ctrl(c: char) -> KeyEvent {
2715 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
2716 }
2717
2718 fn venues_list() -> crate::cli::Commands {
2719 use clap::Parser;
2720 crate::cli::Cli::try_parse_from(["scope", "venues", "list"])
2721 .unwrap()
2722 .command
2723 }
2724
2725 fn state() -> TuiState {
2726 TuiState::new(
2727 SessionContext::default(),
2728 Journal::default(),
2729 Vocab::default(),
2730 )
2731 }
2732
2733 fn asked(s: &mut TuiState, options: &[&str]) -> tokio::sync::oneshot::Receiver<Option<usize>> {
2734 let (reply, rx) = tokio::sync::oneshot::channel();
2735 *s.asker.lock().unwrap() = Some(crate::cli::output::Ask {
2736 prompt: "Choose one:".into(),
2737 options: options.iter().map(|o| o.to_string()).collect(),
2738 reply,
2739 });
2740 s.poll_ask();
2741 rx
2742 }
2743
2744 #[test]
2745 fn test_the_choice_list_answers_with_the_selected_row() {
2746 let mut s = state();
2747 let mut rx = asked(&mut s, &["ethereum 0xA USDN", "bsc 0xB USDN"]);
2748 assert!(matches!(s.overlay, Some(Overlay::Choice(_))));
2749 s.handle_key(key(KeyCode::Down));
2750 s.handle_key(key(KeyCode::Enter));
2751 assert_eq!(rx.try_recv().unwrap(), Some(1));
2752 assert!(s.overlay.is_none());
2753 }
2754
2755 #[test]
2756 fn test_escape_answers_none_and_does_not_cancel_the_command() {
2757 let mut s = state();
2758 let mut rx = asked(&mut s, &["a", "b"]);
2759 assert!(matches!(s.handle_key(key(KeyCode::Esc)), Action::None));
2760 assert_eq!(rx.try_recv().unwrap(), None);
2761 assert!(s.overlay.is_none());
2762 }
2763
2764 #[test]
2765 fn test_cancelling_the_command_closes_the_choice() {
2766 let mut s = state();
2768 let rx = asked(&mut s, &["a", "b"]);
2769 drop(rx);
2770 s.finish(Ending::Cancelled);
2771 assert!(s.overlay.is_none());
2772 assert!(s.asker.lock().unwrap().is_none());
2773 }
2774
2775 #[test]
2776 fn test_a_long_choice_scrolls_with_the_selection() {
2777 let mut s = state();
2779 let options: Vec<String> = (1..=20).map(|i| format!("chain{i} 0x{i} T")).collect();
2780 let refs: Vec<&str> = options.iter().map(String::as_str).collect();
2781 let _rx = asked(&mut s, &refs);
2782 for _ in 0..14 {
2783 s.handle_key(key(KeyCode::Down));
2784 }
2785 let text = screen(&s, 100, 30);
2786 assert!(text.contains("chain15 0x15 T"), "{text}");
2787 assert!(text.contains("Choose one:"), "{text}");
2788 }
2789
2790 #[test]
2791 fn test_the_choice_keeps_the_selection_in_view_at_the_smallest_size() {
2792 let mut s = state();
2795 let options: Vec<String> = (1..=8).map(|i| format!("opt{i}")).collect();
2796 let refs: Vec<&str> = options.iter().map(String::as_str).collect();
2797 let _rx = asked(&mut s, &refs);
2798 for _ in 0..5 {
2799 s.handle_key(key(KeyCode::Down));
2800 }
2801 let text = screen(&s, MIN_W, MIN_H);
2802 assert!(text.contains("opt6"), "{text}");
2803 }
2804
2805 #[test]
2806 fn test_a_hidden_choice_is_not_answered_by_enter() {
2807 let mut s = state();
2808 let mut rx = asked(&mut s, &["a", "b"]);
2809 let _ = screen(&s, 20, 6); s.handle_key(key(KeyCode::Enter));
2811 assert!(rx.try_recv().is_err(), "no answer yet");
2812 assert!(matches!(s.overlay, Some(Overlay::Choice(_))));
2813 s.handle_key(key(KeyCode::Esc));
2814 assert_eq!(rx.try_recv().unwrap(), None);
2815 }
2816
2817 #[test]
2818 fn test_a_paste_leaves_the_choice_open() {
2819 let mut s = state();
2820 let mut rx = asked(&mut s, &["a", "b"]);
2821 s.paste("USDC");
2822 assert!(matches!(s.overlay, Some(Overlay::Choice(_))));
2823 assert!(rx.try_recv().is_err(), "no answer yet");
2824 assert!(s.input.is_empty());
2825 }
2826
2827 #[test]
2828 fn test_a_question_puts_back_the_history_search_draft() {
2829 let mut s = state();
2830 type_str(&mut s, "draft");
2831 s.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
2832 assert!(matches!(s.overlay, Some(Overlay::HistorySearch(_))));
2833 let _rx = asked(&mut s, &["a", "b"]);
2834 assert!(matches!(s.overlay, Some(Overlay::Choice(_))));
2835 assert_eq!(s.input, "draft");
2836 }
2837
2838 fn type_str(s: &mut TuiState, text: &str) {
2839 for c in text.chars() {
2840 s.handle_key(key(KeyCode::Char(c)));
2841 }
2842 }
2843
2844 fn screen(s: &TuiState, w: u16, h: u16) -> String {
2845 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
2846 term.draw(|f| s.render(f)).unwrap();
2847 let buf = term.backend().buffer().clone();
2848 (0..h)
2849 .map(|y| {
2850 (0..w)
2851 .map(|x| buf[(x, y)].symbol().to_string())
2852 .collect::<String>()
2853 })
2854 .collect::<Vec<_>>()
2855 .join("\n")
2856 }
2857
2858 fn cursor_pos(s: &TuiState, w: u16, h: u16) -> Position {
2859 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
2860 term.draw(|f| s.render(f)).unwrap();
2861 term.get_cursor_position().unwrap()
2862 }
2863
2864 fn vocab_state() -> TuiState {
2865 TuiState::new(
2866 SessionContext::default(),
2867 Journal::default(),
2868 Vocab {
2869 venues: vec!["binance".into(), "bitget".into(), "okx".into()],
2870 ..Vocab::default()
2871 },
2872 )
2873 }
2874
2875 #[test]
2876 fn test_tab_single_candidate_completes_and_adds_space() {
2877 let mut s = vocab_state();
2878 type_str(&mut s, "market su");
2879 s.handle_key(key(KeyCode::Tab));
2880 assert_eq!(s.input, "market summary ");
2881 assert!(s.overlay.is_none());
2882 }
2883
2884 #[test]
2885 fn test_tab_many_candidates_fills_prefix_and_opens_menu() {
2886 let mut s = vocab_state();
2887 type_str(&mut s, "market summary USDC --venue b");
2888 s.handle_key(key(KeyCode::Tab));
2889 assert_eq!(s.input, "market summary USDC --venue bi");
2890 let Some(Overlay::Menu(m)) = &s.overlay else {
2891 panic!("menu expected")
2892 };
2893 assert_eq!(m.candidates, vec!["binance", "bitget"]);
2894 s.handle_key(key(KeyCode::Tab));
2895 s.handle_key(key(KeyCode::Enter));
2896 assert_eq!(s.input, "market summary USDC --venue bitget ");
2897 assert!(
2898 s.overlay.is_none(),
2899 "Enter in the menu accepts; it does not submit"
2900 );
2901 }
2902
2903 #[test]
2904 fn test_tab_mid_line_does_nothing() {
2905 let mut s = vocab_state();
2907 type_str(&mut s, "market su");
2908 s.handle_key(key(KeyCode::Left));
2909 s.handle_key(key(KeyCode::Tab));
2910 assert_eq!(s.input, "market su");
2911 assert!(s.overlay.is_none());
2912 }
2913
2914 #[test]
2915 fn test_menu_escape_and_other_keys_close_it() {
2916 let mut s = vocab_state();
2917 type_str(&mut s, "ad");
2918 s.handle_key(key(KeyCode::Tab));
2919 assert!(matches!(s.overlay, Some(Overlay::Menu(_))));
2920 s.handle_key(key(KeyCode::Esc));
2921 assert!(s.overlay.is_none());
2922 assert_eq!(s.input, "address", "Esc closes the menu, not the line");
2923 s.handle_key(key(KeyCode::Tab));
2924 type_str(&mut s, "-");
2925 assert!(s.overlay.is_none());
2926 assert_eq!(s.input, "address-");
2927 }
2928
2929 #[test]
2930 fn test_tab_works_while_a_command_runs() {
2931 let _lock = super::super::DIAG_LOCK.blocking_lock();
2933 let mut s = vocab_state();
2934 let inv = Invocation {
2935 command: venues_list(),
2936 route: Route::Pane,
2937 ai: false,
2938 argv: vec![],
2939 };
2940 s.start("venues list", &inv);
2941 type_str(&mut s, "market su");
2942 s.handle_key(key(KeyCode::Tab));
2943 assert_eq!(s.input, "market summary ");
2944 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
2945 s.finish(Ending::Cancelled);
2946 }
2947
2948 #[test]
2949 fn test_menu_renders_bounded_and_tiny_terminal_is_safe() {
2950 let mut s = TuiState::new(
2952 SessionContext::default(),
2953 Journal::default(),
2954 Vocab {
2955 venues: (0..40).map(|i| format!("v{:02}", i)).collect(),
2956 ..Vocab::default()
2957 },
2958 );
2959 type_str(&mut s, "market summary X --venue v");
2960 s.handle_key(key(KeyCode::Tab));
2961 for _ in 0..20 {
2962 s.handle_key(key(KeyCode::Tab));
2963 }
2964 let text = screen(&s, 60, 20);
2965 assert!(
2966 text.contains("v20"),
2967 "the selection stays visible: {}",
2968 text
2969 );
2970 assert!(!text.contains("v00"), "at most 8 rows: {}", text);
2971 screen(&s, 10, 5);
2972 }
2973
2974 fn ctrl_k() -> KeyEvent {
2975 KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
2976 }
2977
2978 #[test]
2979 fn test_ctrl_k_opens_palette_and_enter_fills_input_without_running() {
2980 let mut s = state();
2981 s.handle_key(ctrl_k());
2982 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
2983 type_str(&mut s, "msum");
2984 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
2985 assert_eq!(s.input, "market summary ");
2986 assert!(s.overlay.is_none());
2987 }
2988
2989 fn repeating(s: &mut TuiState, n: u64, fail: u64) {
2992 let inv = Invocation {
2993 command: venues_list(),
2994 route: Route::Pane,
2995 ai: false,
2996 argv: vec![],
2997 };
2998 let out = s.start("market summary USDC --every 1s", &inv);
2999 let metrics = scope::metrics::summary_metrics(&scope::market::HealthThresholds::default());
3000 for run in 1..=n {
3001 out.record_run(
3002 &metrics,
3003 crate::cli::output::RunRecord {
3004 run,
3005 at: Utc::now(),
3006 sample: scope::metrics::Sample {
3007 values: vec![Some(1.0); metrics.len()],
3008 healthy: run != fail,
3009 failed_checks: vec![format!("spread {run}%")],
3010 },
3011 snapshot: format!("SNAPSHOT {run}\n"),
3012 view: None,
3013 },
3014 );
3015 }
3016 }
3017
3018 #[test]
3019 fn test_a_one_shot_market_summary_shows_the_compact_view_and_copies_the_text() {
3020 use scope::market::{BookSource, MarketSummary, OrderBook, OrderBookLevel};
3023 let _lock = super::super::DIAG_LOCK.blocking_lock();
3024 let mut s = state();
3025 let inv = Invocation {
3026 command: venues_list(),
3027 route: Route::Pane,
3028 ai: false,
3029 argv: vec![],
3030 };
3031 let out = s.start("market summary USDC --venue binance", &inv);
3032 crate::note!(out, "Using the saved token choice");
3033 crate::out!(
3034 out,
3035 "CLI REPORT
3036Bid Side — 20 levels
3037"
3038 )
3039 .unwrap();
3040 let level = |price: f64| OrderBookLevel {
3041 price,
3042 quantity: 1000.0,
3043 };
3044 let book = OrderBook {
3045 pair: "USDC/USDT".to_string(),
3046 source: BookSource::Exchange,
3047 bids: vec![level(0.9999)],
3048 asks: vec![level(1.0001)],
3049 };
3050 assert!(out.record_view(crate::cli::output::SummaryView {
3051 summary: MarketSummary::from_order_book(
3052 &book,
3053 &scope::market::HealthThresholds::default(),
3054 None
3055 ),
3056 venue: "binance".to_string(),
3057 }));
3058 s.finish(Ending::Finished(Ok(())));
3059 let shown = s.scrollback_text();
3060 assert!(shown.contains("USDC/USDT · binance"), "{shown}");
3061 assert!(!shown.contains("CLI REPORT"), "{shown}");
3062 assert!(shown.contains("Using the saved token choice"), "{shown}");
3063 assert_eq!(
3064 s.last_output.as_deref(),
3065 Some(
3066 "CLI REPORT
3067Bid Side — 20 levels
3068"
3069 )
3070 );
3071 }
3072
3073 #[test]
3074 fn test_a_repeat_run_draws_the_view_not_one_report_per_run() {
3075 let _lock = super::super::DIAG_LOCK.blocking_lock();
3076 let mut s = state();
3077 repeating(&mut s, 3, 2);
3078 let text = screen(&s, 120, 36);
3079 assert!(text.contains("SNAPSHOT 3"), "{text}");
3080 assert!(!text.contains("SNAPSHOT 1"), "{text}");
3081 assert!(text.contains("Trends · run 3"), "{text}");
3082 assert!(text.contains("run 2: spread 2%"), "{text}");
3083 s.finish(Ending::Cancelled);
3084 }
3085
3086 #[test]
3087 fn test_the_view_stays_after_esc_and_the_next_key_leaves_it_once() {
3088 let _lock = super::super::DIAG_LOCK.blocking_lock();
3089 let mut s = state();
3090 repeating(&mut s, 3, 0);
3091 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
3092 s.finish(Ending::Cancelled);
3093 let text = screen(&s, 120, 36);
3094 assert!(text.contains("Cancelled after"), "{text}");
3095 assert!(text.contains(repeat_view::LEAVE_HINT), "{text}");
3096 assert!(!s.scrollback_text().contains("SNAPSHOT"), "not yet pushed");
3097 assert_eq!(s.handle_key(key(KeyCode::Char('x'))), Action::None);
3099 assert_eq!(s.input, "x");
3100 let back = s.scrollback_text();
3101 assert_eq!(back.matches("SNAPSHOT 3").count(), 1, "{back}");
3102 assert!(!back.contains("SNAPSHOT 1"), "{back}");
3103 assert!(back.contains("3 runs · none failed"), "{back}");
3104 assert!(back.contains("Cancelled after"), "{back}");
3105 assert!(!screen(&s, 120, 36).contains("Trends"), "the view is gone");
3106 }
3107
3108 #[test]
3109 fn test_copy_and_save_take_the_final_snapshot_and_the_series() {
3110 let _lock = super::super::DIAG_LOCK.blocking_lock();
3111 let mut s = state();
3112 repeating(&mut s, 2, 0);
3113 s.finish(Ending::Finished(Ok(())));
3114 assert_eq!(s.last_output.as_deref(), Some("SNAPSHOT 2\n"));
3115 let csv = s
3116 .last_series
3117 .as_ref()
3118 .and_then(repeat_view::series_csv)
3119 .unwrap();
3120 assert_eq!(csv.lines().count(), 3, "header and two runs: {csv}");
3121 }
3122
3123 fn alt(c: char) -> KeyEvent {
3124 KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)
3125 }
3126
3127 #[test]
3128 fn test_readline_word_moves_and_kill_to_end() {
3129 let mut s = state();
3130 type_str(&mut s, "market summary USDC");
3131 s.handle_key(alt('b'));
3132 assert_eq!(s.cursor, 15, "Alt-B: start of USDC");
3133 s.handle_key(alt('b'));
3134 assert_eq!(s.cursor, 7, "Alt-B: start of summary");
3135 s.handle_key(alt('f'));
3136 assert_eq!(s.cursor, 14, "Alt-F: end of summary");
3137 s.handle_key(ctrl('b'));
3138 assert_eq!(s.cursor, 13, "Ctrl-B: one left");
3139 s.handle_key(alt('k'));
3140 assert_eq!(s.input, "market summar", "Alt-K: deletes to the end");
3141 }
3142
3143 #[test]
3144 fn test_up_brings_back_a_line_that_esc_cleared() {
3145 let mut s = state();
3146 type_str(&mut s, "market summary USDC --venue binance --every 30s");
3147 s.handle_key(key(KeyCode::Esc));
3148 assert_eq!(s.input, "");
3149 s.handle_key(key(KeyCode::Up));
3150 assert_eq!(s.input, "market summary USDC --venue binance --every 30s");
3151 }
3152
3153 #[test]
3154 fn test_clear_empties_the_pane_and_reset_resets_the_context() {
3155 let mut s = state();
3156 s.push_note("old output");
3157 s.ctx.limit = 7;
3158 s.submit("clear", &Config::default());
3159 assert!(!s.scrollback_text().contains("old output"));
3160 assert_eq!(s.ctx.limit, 7, "clear leaves the context");
3161 s.submit("reset", &Config::default());
3162 assert_eq!(s.ctx.limit, SessionContext::default().limit);
3163 }
3164
3165 #[test]
3166 fn test_the_pane_says_when_it_dropped_old_lines() {
3167 let mut s = state();
3168 let big: String = (0..SCROLLBACK_LIMIT + 7)
3169 .map(|i| format!("l{i}\n"))
3170 .collect();
3171 s.push_ansi(&big);
3172 assert!(s.dropped_lines > 0);
3173 let rows = 200;
3174 s.scroll.set(usize::MAX / 2);
3175 let text = screen(&s, 100, rows);
3176 assert!(text.contains("older lines dropped"), "{text}");
3177 }
3178
3179 #[test]
3180 fn test_ctrl_c_in_palette_still_cancels_a_running_command() {
3181 let _lock = super::super::DIAG_LOCK.blocking_lock();
3183 let mut s = state();
3184 let inv = Invocation {
3185 command: venues_list(),
3186 route: Route::Pane,
3187 ai: false,
3188 argv: vec![],
3189 };
3190 s.start("venues list", &inv);
3191 s.handle_key(ctrl_k());
3192 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
3193 assert!(s.overlay.is_none(), "Ctrl-C also closes the palette");
3194 s.finish(Ending::Cancelled);
3195 }
3196
3197 #[test]
3198 fn test_palette_escape_keeps_the_input() {
3199 let mut s = state();
3200 type_str(&mut s, "draft");
3201 s.handle_key(ctrl_k());
3202 type_str(&mut s, "zzz");
3203 s.handle_key(key(KeyCode::Esc));
3204 assert!(s.overlay.is_none());
3205 assert_eq!(s.input, "draft");
3206 }
3207
3208 #[test]
3209 fn test_palette_with_long_history_renders_bounded() {
3210 let mut s = state();
3212 for i in 0..60 {
3213 type_str(&mut s, &format!("venues list {}", i));
3214 s.handle_key(key(KeyCode::Enter));
3215 }
3216 s.handle_key(ctrl_k());
3217 for _ in 0..30 {
3218 s.handle_key(key(KeyCode::Down));
3219 }
3220 screen(&s, 80, 20);
3221 screen(&s, 10, 5);
3222 }
3223
3224 #[test]
3225 fn test_editing_keys() {
3226 let mut s = state();
3227 type_str(&mut s, "adress");
3228 s.handle_key(key(KeyCode::Left));
3229 s.handle_key(key(KeyCode::Left));
3230 s.handle_key(key(KeyCode::Left));
3231 s.handle_key(key(KeyCode::Left));
3232 type_str(&mut s, "d");
3233 assert_eq!(s.input, "address");
3234 s.handle_key(key(KeyCode::End));
3235 type_str(&mut s, " 0xab cd");
3236 s.handle_key(ctrl('w'));
3237 assert_eq!(s.input, "address 0xab ");
3238 s.handle_key(key(KeyCode::Backspace));
3239 s.handle_key(ctrl('u'));
3240 assert_eq!(s.input, "");
3241 }
3242
3243 #[test]
3244 fn test_cursor_keys_and_idle_escape() {
3245 let mut s = state();
3246 type_str(&mut s, "bc");
3247 s.handle_key(key(KeyCode::Home));
3248 type_str(&mut s, "a");
3249 s.handle_key(ctrl('e'));
3250 type_str(&mut s, "d");
3251 assert_eq!(s.input, "abcd");
3252 s.handle_key(ctrl('a'));
3253 s.handle_key(key(KeyCode::Delete));
3254 assert_eq!(s.input, "bcd");
3255 s.handle_key(key(KeyCode::End));
3256 s.handle_key(key(KeyCode::Delete));
3257 assert_eq!(s.input, "bcd", "Delete at the end does nothing");
3258 s.handle_key(key(KeyCode::Right));
3259 type_str(&mut s, "e");
3260 assert_eq!(s.input, "bcde");
3261 s.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT));
3263 assert_eq!(s.input, "bcde");
3264 s.handle_key(key(KeyCode::Esc));
3265 assert_eq!(s.input, "", "Esc clears the line when idle");
3266 s.handle_key(ctrl('c'));
3268 assert!(s.scrollback_text().contains("Ctrl-D to leave"));
3269 s.handle_key(key(KeyCode::Up));
3271 assert_eq!(s.input, "bcde");
3272 s.cleared = None;
3274 s.set_input(String::new());
3275 s.handle_key(key(KeyCode::Up));
3276 s.handle_key(key(KeyCode::Down));
3277 assert_eq!(s.input, "");
3278 s.handle_key(key(KeyCode::PageDown));
3279 }
3280
3281 #[test]
3282 fn test_multibyte_input_does_not_panic() {
3283 let mut s = state();
3284 type_str(&mut s, "tokens add €UR");
3285 s.handle_key(key(KeyCode::Left));
3286 s.handle_key(key(KeyCode::Left));
3287 s.handle_key(key(KeyCode::Backspace));
3288 assert_eq!(s.input, "tokens add UR");
3289 }
3290
3291 #[test]
3296 fn test_line_feed_from_cooked_mode_submits_like_enter() {
3297 for lf in [ctrl('j'), key(KeyCode::Char('\n'))] {
3298 let mut s = state();
3299 type_str(&mut s, "ctx");
3300 assert_eq!(s.handle_key(lf), Action::Submit("ctx".into()), "{lf:?}");
3301 assert_eq!(s.input, "");
3302 }
3303 }
3304
3305 #[test]
3308 fn test_line_feed_picks_palette_entry_like_enter() {
3309 let mut s = state();
3310 s.handle_key(ctrl('k'));
3311 s.handle_key(ctrl('j'));
3312 assert!(s.overlay.is_none());
3313 assert!(!s.input.contains('\n'));
3314 }
3315
3316 #[test]
3317 fn test_enter_submits_and_records_history() {
3318 let mut s = state();
3319 type_str(&mut s, "venues list");
3320 assert_eq!(
3321 s.handle_key(key(KeyCode::Enter)),
3322 Action::Submit("venues list".into())
3323 );
3324 assert_eq!(s.input, "");
3325 type_str(&mut s, "venues list");
3326 s.handle_key(key(KeyCode::Enter));
3327 assert_eq!(s.history, vec!["venues list"]);
3329 }
3330
3331 #[test]
3332 fn test_history_browsing_restores_draft() {
3333 let mut s = state();
3334 for cmd in ["one", "two"] {
3335 type_str(&mut s, cmd);
3336 s.handle_key(key(KeyCode::Enter));
3337 }
3338 type_str(&mut s, "draft");
3339 s.handle_key(key(KeyCode::Up));
3340 assert_eq!(s.input, "two");
3341 s.handle_key(key(KeyCode::Up));
3342 s.handle_key(key(KeyCode::Up));
3343 assert_eq!(s.input, "one");
3344 s.handle_key(key(KeyCode::Down));
3345 assert_eq!(s.input, "two");
3346 s.handle_key(key(KeyCode::Down));
3347 assert_eq!(s.input, "draft");
3348 }
3349
3350 #[test]
3351 fn test_ctrl_z_suspends_and_keeps_the_draft_and_the_palette() {
3352 let mut s = state();
3355 type_str(&mut s, "draf");
3356 assert_eq!(s.handle_key(ctrl('z')), Action::Suspend);
3357 assert_eq!(s.input, "draf");
3358 s.handle_key(ctrl('k'));
3359 assert_eq!(s.handle_key(ctrl('z')), Action::Suspend);
3360 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
3361 }
3362
3363 #[test]
3364 fn test_ctrl_c_and_esc_cancel_only_when_running() {
3365 let _lock = super::super::DIAG_LOCK.blocking_lock();
3366 let mut s = state();
3367 type_str(&mut s, "abc");
3368 assert_eq!(s.handle_key(ctrl('c')), Action::None);
3369 assert_eq!(s.input, "", "Ctrl-C clears the line when idle");
3370 assert_eq!(s.handle_key(ctrl('d')), Action::Exit);
3371
3372 let inv = Invocation {
3373 command: venues_list(),
3374 route: Route::Pane,
3375 ai: false,
3376 argv: vec![],
3377 };
3378 s.start("venues list", &inv);
3379 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
3380 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
3381 assert_eq!(
3382 s.handle_key(ctrl('d')),
3383 Action::None,
3384 "no exit while running"
3385 );
3386 type_str(&mut s, "x");
3387 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
3388 assert_eq!(s.input, "x", "the next line waits for the command to end");
3389 s.finish(Ending::Cancelled);
3390 assert!(s.scrollback_text().contains("Cancelled after"));
3391 }
3392
3393 #[test]
3394 fn test_session_command_output_reaches_scrollback() {
3395 let mut s = state();
3396 let plan = s.submit("chain base", &Config::default());
3397 assert!(matches!(plan, Plan::Done));
3398 let text = s.scrollback_text();
3399 assert!(text.contains("scope:auto> chain base"));
3400 assert!(text.contains("Chain pinned to: base"));
3401 assert_eq!(s.ctx.chain, "base");
3402 }
3403
3404 #[test]
3405 fn test_ansi_colors_become_styles_not_escape_codes() {
3406 let mut s = state();
3407 s.push_ansi("\x1b[31mred\x1b[0m plain\n");
3408 assert_eq!(s.scrollback_text(), "red plain\n");
3409 let first = &s.scrollback[0].spans[0];
3410 assert_eq!(first.style.fg, Some(Color::Red));
3411 }
3412
3413 #[test]
3414 fn test_scrollback_is_capped() {
3415 let mut s = state();
3416 let big: String = (0..SCROLLBACK_LIMIT + 50)
3417 .map(|i| format!("l{}\n", i))
3418 .collect();
3419 s.push_ansi(&big);
3420 assert_eq!(s.scrollback.len(), SCROLLBACK_LIMIT);
3421 assert!(s.scrollback_text().starts_with("l50\n"));
3422 }
3423
3424 #[test]
3425 fn test_render_shows_latest_output_prompt_and_status() {
3426 let mut s = state();
3427 for i in 0..40 {
3428 s.push_ansi(&format!("line {}\n", i));
3429 }
3430 type_str(&mut s, "venues");
3431 let text = screen(&s, 60, 15);
3432 assert!(text.contains("line 39"), "{}", text);
3433 assert!(!text.contains("line 0 "), "{}", text);
3434 assert!(text.contains("scope:auto>"));
3435 assert!(text.contains("venues"));
3436 assert!(text.contains("chain auto"));
3437
3438 s.handle_key(key(KeyCode::PageUp));
3439 let text = screen(&s, 60, 15);
3440 assert!(!text.contains("line 39"), "{}", text);
3441 assert!(text.contains("scrolled up 7 lines"), "{}", text);
3443 }
3444
3445 fn status_row(s: &TuiState, w: u16) -> String {
3448 let line = fit_status(&s.status_parts(), w as usize);
3449 let text: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
3450 format!("{:<width$}", text, width = w as usize)
3451 }
3452
3453 fn running_state(input: &str) -> (TuiState, Output) {
3454 let mut s = state();
3455 let inv = Invocation {
3456 command: venues_list(),
3457 route: Route::Pane,
3458 ai: false,
3459 argv: vec![],
3460 };
3461 let out = s.start(input, &inv);
3462 (s, out)
3463 }
3464
3465 fn status_text(s: &TuiState) -> String {
3467 let line = fit_status(&s.status_parts(), 160);
3468 line.spans.iter().map(|sp| sp.content.as_ref()).collect()
3469 }
3470
3471 #[test]
3472 fn test_esc_hint_says_what_esc_does_with_each_overlay_open() {
3473 let _lock = super::super::DIAG_LOCK.blocking_lock();
3476 type Open = fn(&mut TuiState);
3477 let opens: [(&str, Open); 4] = [
3478 ("Esc closes palette", |s| {
3479 s.handle_key(ctrl('k'));
3480 }),
3481 ("Esc closes menu", |s| {
3482 type_str(s, "ad");
3483 s.handle_key(key(KeyCode::Tab));
3484 }),
3485 ("Esc closes search", |s| {
3486 s.handle_key(ctrl('f'));
3487 }),
3488 ("Esc closes history search", |s| {
3489 s.handle_key(ctrl('r'));
3490 }),
3491 ];
3492 for (hint, open) in opens {
3493 let mut s = vocab_state();
3494 let inv = Invocation {
3495 command: venues_list(),
3496 route: Route::Pane,
3497 ai: false,
3498 argv: vec![],
3499 };
3500 let _out = s.start("venues list", &inv);
3501 screen(&s, 80, 24);
3502 open(&mut s);
3503 assert!(s.overlay.is_some(), "{hint}: no overlay");
3504 let bar = status_text(&s);
3505 assert!(bar.contains(hint), "{hint}: {bar:?}");
3506 assert!(!bar.contains("Esc cancels"), "{hint}: {bar:?}");
3507 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::None, "{hint}");
3510 assert!(s.overlay.is_none(), "{hint}");
3511 assert!(status_text(&s).contains("Esc cancels"), "{hint}");
3512 }
3513 }
3514
3515 #[test]
3516 fn test_idle_esc_hint_shows_only_while_an_overlay_is_open() {
3517 let mut s = state();
3518 assert!(!status_text(&s).contains("Esc"), "{}", status_text(&s));
3519 screen(&s, 80, 24);
3520 s.handle_key(ctrl('k'));
3521 assert!(status_text(&s).contains("Esc closes palette"));
3522 s.handle_key(key(KeyCode::Esc));
3523 assert!(status_text(&s).contains("help"), "{}", status_text(&s));
3524 }
3525
3526 #[test]
3527 fn test_trimmed_outside_output_is_not_kept_for_copy_or_save() {
3528 let mut s = state();
3531 let (o, done) = Output::capture_merged();
3532 crate::outln!(o, "earlier data").unwrap();
3533 s.show_outside(&done, &Ending::Finished(Ok(())));
3534 assert_eq!(s.last_output.as_deref(), Some("earlier data\n"));
3535 let (terminal, _screen) = Output::capture();
3536 let (o, long) = terminal.teed(64);
3537 for i in 0..100 {
3538 crate::outln!(o, "row {i}").unwrap();
3539 }
3540 assert!(long.trimmed());
3541 s.show_outside(&long, &Ending::Finished(Ok(())));
3542 assert_eq!(s.last_output, None, "neither the part nor the old copy");
3543 assert!(
3544 s.scrollback_text()
3545 .contains("only the end is kept here, and Ctrl-Y and save do not have it."),
3546 "{}",
3547 s.scrollback_text()
3548 );
3549 assert!(matches!(s.handle_key(ctrl('y')), Action::None));
3550 assert!(s.scrollback_text().contains("nothing to copy yet"));
3551 }
3552
3553 #[test]
3554 fn test_an_outside_run_that_prompted_keeps_only_its_data_for_copy() {
3555 let mut s = state();
3558 let (terminal, _screen) = Output::capture();
3559 let (o, copy) = terminal.teed(1 << 20);
3560 let pick = crate::cli::prompt::pick(
3561 &mut std::io::Cursor::new(b"1\n".to_vec()),
3562 &o,
3563 "1 USDC\n",
3564 "token",
3565 1,
3566 );
3567 assert_eq!(pick.unwrap(), 0);
3568 crate::cli::prompt::ask(
3569 &mut std::io::Cursor::new(b"n\n".to_vec()),
3570 &o,
3571 "Save this token for future use? [y/N]: ",
3572 )
3573 .unwrap();
3574 crate::outln!(o, "report").unwrap();
3575 s.show_outside(©, &Ending::Finished(Ok(())));
3576 assert_eq!(s.last_output.as_deref(), Some("report\n"));
3577 let shown = s.scrollback_text();
3578 assert!(
3579 shown.contains("Select token (1-1)"),
3580 "the user still sees it"
3581 );
3582 }
3583
3584 #[test]
3585 fn test_a_failed_outside_command_sets_the_failed_pane_state() {
3586 let mut s = state();
3588 let err = Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into())));
3589 s.record_suspended(
3590 "crawl USDC",
3591 &[],
3592 std::time::Duration::from_secs(1),
3593 String::new(),
3594 &err,
3595 );
3596 assert_eq!(s.pane_state(), PaneState::Failed);
3597 for ok in [Ending::Finished(Ok(())), Ending::Cancelled] {
3598 s.submit("crawl USDC", &Config::default());
3599 s.record_suspended(
3600 "crawl USDC",
3601 &[],
3602 std::time::Duration::from_secs(1),
3603 String::new(),
3604 &ok,
3605 );
3606 assert_eq!(s.pane_state(), PaneState::Idle);
3607 }
3608 }
3609
3610 #[test]
3611 fn test_status_bar_keeps_the_running_state_at_every_width() {
3612 let _lock = super::super::DIAG_LOCK.blocking_lock();
3615 let (s, _out) = running_state("market summary USDC --venue binance --every 2s");
3616 for w in [120, 60, 40, 30] {
3617 let row = status_row(&s, w);
3618 assert!(row.contains("running "), "{w}: {row:?}");
3619 assert!(row.contains("Esc cancels"), "{w}: {row:?}");
3620 }
3621 let wide = status_row(&s, 120);
3622 assert!(
3623 wide.contains("limit") && wide.contains("format"),
3624 "{wide:?}"
3625 );
3626 assert!(wide.contains("market summary USDC"), "{wide:?}");
3627 }
3628
3629 #[test]
3630 fn test_status_bar_drops_limit_and_format_before_the_cancel_hint() {
3631 let _lock = super::super::DIAG_LOCK.blocking_lock();
3632 let (s, _out) = running_state("market summary USDC --venue binance --every 2s");
3633 let row = status_row(&s, 40);
3634 assert!(!row.contains("limit"), "{row:?}");
3635 assert!(!row.contains("format"), "{row:?}");
3636 assert!(row.contains('…'), "{row:?}");
3638 }
3639
3640 #[test]
3641 fn test_idle_status_bar_keeps_help_at_a_narrow_width() {
3642 let s = state();
3643 let row = status_row(&s, 30);
3644 assert!(row.contains("help"), "{row:?}");
3645 assert!(!row.contains("limit"), "{row:?}");
3646 }
3647
3648 #[test]
3649 fn test_scrolled_status_bar_keeps_the_scroll_hint_at_a_narrow_width() {
3650 let mut s = state();
3651 for i in 0..40 {
3652 s.push_ansi(&format!("line {}\n", i));
3653 }
3654 screen(&s, 40, 12);
3655 s.handle_key(key(KeyCode::PageUp));
3656 let row = status_row(&s, 40);
3657 assert!(row.contains("scrolled up 4 lines"), "{row:?}");
3658 }
3659
3660 fn scrollable() -> TuiState {
3662 let mut s = state();
3663 for i in 0..30 {
3664 s.push_ansi(&format!("line {}\n", i));
3665 }
3666 screen(&s, 60, 15);
3667 s
3668 }
3669
3670 #[test]
3671 fn test_page_up_stops_at_the_top_so_page_down_always_moves() {
3672 let mut s = scrollable();
3673 for _ in 0..50 {
3674 s.handle_key(key(KeyCode::PageUp));
3675 }
3676 let top = screen(&s, 60, 15);
3677 assert!(top.contains("scrolled up 22 lines"), "{top}");
3678 s.handle_key(key(KeyCode::PageDown));
3679 let after = screen(&s, 60, 15);
3680 assert_ne!(top, after, "PageDown must change the view");
3681 assert!(after.contains("scrolled up 15 lines"), "{after}");
3682 }
3683
3684 #[test]
3685 fn test_ctrl_home_and_ctrl_end_jump_to_the_top_and_the_bottom() {
3686 let mut s = scrollable();
3687 s.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL));
3688 assert!(screen(&s, 60, 15).contains("line 0"));
3689 assert!(screen(&s, 60, 15).contains("scrolled up 22 lines"));
3690 s.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL));
3691 let text = screen(&s, 60, 15);
3692 assert!(text.contains("line 29"), "{text}");
3693 assert!(!text.contains("scrolled up"), "{text}");
3694 }
3695
3696 #[test]
3697 fn test_shift_arrows_scroll_one_line_and_plain_arrows_keep_the_history() {
3698 let mut s = scrollable();
3699 s.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT));
3700 s.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT));
3701 assert!(screen(&s, 60, 15).contains("scrolled up 2 lines"));
3702 s.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
3703 assert!(screen(&s, 60, 15).contains("scrolled up 1 lines"));
3704 s.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
3706 s.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
3707 assert!(!screen(&s, 60, 15).contains("scrolled up"));
3708 s.history.push("venues list".into());
3710 s.handle_key(key(KeyCode::Up));
3711 assert_eq!(s.input, "venues list");
3712 }
3713
3714 #[test]
3715 fn test_the_wheel_scrolls_three_lines_and_stops_at_the_top() {
3716 let mut s = scrollable();
3717 s.wheel(3);
3718 assert!(screen(&s, 60, 15).contains("scrolled up 3 lines"));
3719 for _ in 0..20 {
3720 s.wheel(3);
3721 }
3722 assert!(screen(&s, 60, 15).contains("scrolled up 22 lines"));
3723 s.wheel(-3);
3724 assert!(screen(&s, 60, 15).contains("scrolled up 19 lines"));
3725 s.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
3727 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
3728 s.wheel(3);
3729 assert_eq!(s.scroll.get(), 19);
3730 }
3731
3732 #[test]
3733 fn test_help_opens_at_its_first_line() {
3734 let mut s = scrollable();
3735 let config = scope::config::Config::default();
3736 let _ = s.submit("help", &config);
3737 let text = screen(&s, 60, 15);
3738 assert!(text.contains("Session commands:"), "{text}");
3739 assert!(text.contains("scrolled up"), "the rest is below: {text}");
3740 s.handle_key(key(KeyCode::PageDown));
3742 assert!(!screen(&s, 60, 15).contains("Session commands:"));
3743 }
3744
3745 #[test]
3746 fn test_the_hint_row_shows_the_navigation_keys_that_fit() {
3747 let mut s = scrollable();
3748 let text = screen(&s, 120, 15);
3749 assert!(text.contains("PgUp/wheel/Shift-↑ scroll back"), "{text}");
3750 assert!(text.contains("↑↓ history"), "{text}");
3751 assert!(text.contains("Ctrl-K commands"), "{text}");
3752 s.handle_key(key(KeyCode::PageUp));
3754 let text = screen(&s, 120, 15);
3755 assert!(text.contains("PgDn/wheel/Shift-↓ down"), "{text}");
3756 assert!(text.contains("Ctrl-End bottom"), "{text}");
3757 let line = nav_hints(&s.nav_keys(), 40);
3759 let shown: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
3760 assert!(UnicodeWidthStr::width(shown.as_str()) <= 40, "{shown:?}");
3761 assert!(
3762 shown.ends_with("down") || shown.ends_with("bottom"),
3763 "{shown:?}"
3764 );
3765 type_str(&mut s, "market summary");
3767 assert!(!screen(&s, 120, 15).contains("↑↓ history"));
3768 }
3769
3770 #[test]
3771 fn test_end_on_an_empty_input_jumps_to_the_bottom_else_moves_the_cursor() {
3772 let mut s = scrollable();
3773 s.handle_key(key(KeyCode::PageUp));
3774 s.handle_key(key(KeyCode::End));
3775 assert_eq!(s.scroll.get(), 0);
3776 type_str(&mut s, "abc");
3777 s.handle_key(key(KeyCode::Home));
3778 s.handle_key(key(KeyCode::PageUp));
3779 s.handle_key(key(KeyCode::End));
3780 assert_eq!(s.cursor, 3, "End moves the cursor on a non-empty input");
3781 assert_eq!(s.scroll.get(), 7, "and leaves the view alone");
3782 }
3783
3784 #[test]
3785 fn test_keys_that_edit_the_input_snap_the_view_to_the_bottom() {
3786 for edit in ["char", "backspace", "delete", "paste"] {
3787 let mut s = scrollable();
3788 type_str(&mut s, "ab");
3789 s.handle_key(key(KeyCode::Left));
3790 s.handle_key(key(KeyCode::PageUp));
3791 assert!(s.scroll.get() > 0, "{edit}");
3792 match edit {
3793 "char" => drop(s.handle_key(key(KeyCode::Char('a')))),
3794 "backspace" => drop(s.handle_key(key(KeyCode::Backspace))),
3795 "delete" => drop(s.handle_key(key(KeyCode::Delete))),
3796 _ => s.paste("x"),
3797 }
3798 assert_eq!(s.scroll.get(), 0, "{edit}");
3799 }
3800 }
3801
3802 #[test]
3803 fn test_navigation_keys_and_modifiers_alone_do_not_snap_the_view() {
3804 let mut s = scrollable();
3805 s.handle_key(key(KeyCode::PageUp));
3806 for k in [
3807 key(KeyCode::Left),
3808 key(KeyCode::Right),
3809 key(KeyCode::Home),
3810 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
3811 key(KeyCode::Tab),
3812 ] {
3813 s.handle_key(k);
3814 assert_eq!(s.scroll.get(), 7, "{k:?}");
3815 }
3816 }
3817
3818 #[test]
3819 fn test_status_shows_the_running_state_and_the_scroll_state_together() {
3820 let _lock = super::super::DIAG_LOCK.blocking_lock();
3821 let (mut s, _out) = running_state("market summary USDC --every 2s");
3822 for i in 0..40 {
3823 s.push_ansi(&format!("line {}\n", i));
3824 }
3825 screen(&s, 120, 12);
3826 s.handle_key(key(KeyCode::PageUp));
3827 let row = status_row(&s, 120);
3828 assert!(row.contains("running "), "{row:?}");
3829 assert!(row.contains("scrolled up 4 lines"), "{row:?}");
3830 let row = status_row(&s, 40);
3832 assert!(row.contains("Esc cancels"), "{row:?}");
3833 }
3834
3835 const TOO_SMALL: &str = "Terminal too small: need at least 40×10";
3836
3837 #[test]
3838 fn test_the_too_small_text_is_the_spec_text() {
3839 assert_eq!(too_small_note(), TOO_SMALL);
3840 }
3841
3842 #[test]
3843 fn test_below_the_minimum_size_only_the_too_small_line_is_drawn() {
3844 let mut s = state();
3845 s.push_ansi("some output\n");
3846 type_str(&mut s, "draft");
3847 let text = screen(&s, 39, 9);
3849 assert!(text.contains(TOO_SMALL), "{text}");
3850 let drawn: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
3851 assert_eq!(drawn.len(), 1, "only the message: {text}");
3852 assert!(!text.contains("draft"), "{text}");
3853 assert_eq!(s.input, "draft", "state is kept");
3854 let text = screen(&s, 40, 10);
3855 assert!(!text.contains(TOO_SMALL), "{text}");
3856 assert!(text.contains("draft"), "{text}");
3857 assert!(screen(&s, 40, 9).contains(TOO_SMALL));
3858 assert!(screen(&s, 39, 10).contains(TOO_SMALL));
3859 }
3860
3861 #[test]
3862 fn test_the_too_small_message_wraps_on_words_and_is_centered() {
3863 let s = state();
3864 let text = screen(&s, 30, 8);
3865 let rows: Vec<&str> = text.lines().collect();
3866 let at = rows
3867 .iter()
3868 .position(|r| r.contains("Terminal too small: need at"))
3869 .expect(&text);
3870 assert!(rows[at + 1].contains("least 40×10"), "{text}");
3871 assert_eq!(at, 3, "centered vertically: {text}");
3872 assert!(rows[at].starts_with(' '), "centered horizontally: {text}");
3873 assert!(screen(&s, 30, 1).contains("Terminal too small"));
3875 for w in 0..12 {
3876 for h in 0..4 {
3877 screen(&s, w, h);
3878 }
3879 }
3880 }
3881
3882 #[test]
3883 fn test_too_small_clears_the_pane_width_so_no_override_is_set() {
3884 let s = state();
3885 screen(&s, 80, 24);
3886 assert!(s.pane_width().is_some());
3887 screen(&s, 30, 8);
3888 assert_eq!(s.pane_width(), None);
3889 }
3890
3891 #[test]
3892 fn test_overlays_do_not_act_while_the_terminal_is_too_small() {
3893 let mut s = state();
3894 type_str(&mut s, "dra");
3895 s.handle_key(ctrl('k'));
3896 assert!(s.overlay.is_some());
3897 screen(&s, 30, 8);
3898 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3899 assert!(s.overlay.is_none(), "the overlay is closed");
3900 assert_eq!(s.input, "dra", "the palette did not fill the input");
3901 screen(&s, 80, 24);
3904 s.handle_key(ctrl('k'));
3905 screen(&s, 30, 8);
3906 s.handle_key(key(KeyCode::Char('x')));
3907 assert!(s.overlay.is_none());
3908 assert_eq!(s.input, "dra");
3909 screen(&s, 80, 24);
3911 s.handle_key(ctrl('k'));
3912 screen(&s, 30, 8);
3913 s.handle_key(key(KeyCode::Esc));
3914 assert!(s.overlay.is_none());
3915 assert_eq!(s.input, "dra");
3916 }
3917
3918 #[test]
3919 fn test_enter_does_nothing_while_too_small_and_a_command_runs() {
3920 let _lock = super::super::DIAG_LOCK.blocking_lock();
3921 let (mut s, _out) = running_state("market summary USDC --every 2s");
3922 type_str(&mut s, "ctx");
3923 screen(&s, 30, 8);
3924 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3925 assert_eq!(s.input, "ctx");
3926 assert!(s.running.is_some());
3927 }
3928
3929 #[test]
3930 fn test_enter_does_nothing_while_the_terminal_is_too_small() {
3931 let mut s = state();
3932 type_str(&mut s, "help");
3933 screen(&s, 30, 8);
3934 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3935 assert_eq!(s.input, "help", "the draft is not lost or run");
3936 s.handle_key(key(KeyCode::Char('x')));
3938 assert_eq!(s.input, "helpx");
3939 screen(&s, 80, 24);
3941 assert!(matches!(
3942 s.handle_key(key(KeyCode::Enter)),
3943 Action::Submit(_)
3944 ));
3945 }
3946
3947 #[test]
3948 fn test_fit_status_never_panics_and_never_exceeds_the_width() {
3949 let text = |t: &str| vec![Span::styled(t.to_string(), ACCENT)];
3952 let mut parts = StatusParts {
3953 context: vec![
3954 labeled("chain", "auto".into()),
3955 labeled("format", "Table".into()),
3956 labeled("limit", "100".into()),
3957 ],
3958 state: text("running 12s · Esc cancels"),
3959 scrolled: Some(text("scrolled up 7 lines · PgDn")),
3960 search: None,
3961 command: Some("market summary ✓✓ --every 2s".into()),
3962 };
3963 for search in [None, Some(text("search: alpha ✓ 1/2"))] {
3964 parts.search = search;
3965 for w in 0..=130 {
3966 let line = fit_status(&parts, w);
3967 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
3968 assert!(cols(&text) <= w, "{w}: {text:?}");
3969 }
3970 }
3971 parts.search = None;
3972 let text: String = fit_status(&parts, 0)
3973 .spans
3974 .iter()
3975 .map(|s| s.content.as_ref())
3976 .collect();
3977 assert!(text.is_empty());
3978 }
3979
3980 use crate::cli::output::RunProgress;
3981 use std::time::Duration;
3982
3983 #[test]
3984 fn test_running_pane_shows_a_spinner_and_the_repeat_status() {
3985 let _lock = super::super::DIAG_LOCK.blocking_lock();
3986 let (s, out) = running_state("market summary USDC --every 2s");
3987 let hook = out.progress_hook().expect("the pane output has a hook");
3988 *hook.lock().unwrap() = RunProgress {
3989 run: 2,
3990 total: Some(4),
3991 next_at: Some(Instant::now() + Duration::from_secs(3)),
3992 };
3993 let text = screen(&s, 80, 12);
3994 assert!(text.contains("run 2 of 4 · next in 3s"), "{text}");
3995 assert!(
3996 text.contains("running market summary USDC --every 2s…"),
3997 "{text}"
3998 );
3999 let row = text.lines().find(|l| l.contains("running market")).unwrap();
4001 let glyph = row.trim_start().chars().next().unwrap().to_string();
4002 assert!(
4003 SPINNER_FRAMES.contains(&glyph.as_str()) || SPINNER_ASCII.contains(&glyph.as_str()),
4004 "{row:?}"
4005 );
4006 }
4007
4008 #[test]
4009 fn test_spinner_uses_ascii_without_color_and_advances_with_time() {
4010 assert_eq!(spinner_glyph(Duration::from_millis(0), false), "-");
4011 assert_eq!(spinner_glyph(Duration::from_millis(100), false), "\\");
4012 assert_eq!(spinner_glyph(Duration::from_millis(200), false), "|");
4013 assert_eq!(spinner_glyph(Duration::from_millis(300), false), "/");
4014 assert_eq!(spinner_glyph(Duration::from_millis(400), false), "-");
4015 assert_ne!(
4016 spinner_glyph(Duration::from_millis(0), true),
4017 spinner_glyph(Duration::from_millis(100), true)
4018 );
4019 }
4020
4021 #[test]
4022 fn test_spinner_and_run_status_never_enter_the_record() {
4023 let _lock = super::super::DIAG_LOCK.blocking_lock();
4026 let dir = tempfile::tempdir().unwrap();
4027 let journal = Journal::at(dir.path().join("history"), dir.path().join("events"));
4028 let mut s = TuiState::new(SessionContext::default(), journal, Vocab::default());
4029 let inv = Invocation {
4030 command: venues_list(),
4031 route: Route::Pane,
4032 ai: false,
4033 argv: vec![],
4034 };
4035 let out = s.start("market summary USDC --every 2s", &inv);
4036 crate::outln!(out, "data line").unwrap();
4037 *out.progress_hook().unwrap().lock().unwrap() = RunProgress {
4038 run: 2,
4039 total: Some(4),
4040 next_at: Some(Instant::now() + Duration::from_secs(3)),
4041 };
4042 let drawn = screen(&s, 80, 12);
4043 assert!(drawn.contains("run 2 of 4"), "{drawn}");
4044 s.finish(Ending::Finished(Ok(())));
4045 let kept = format!(
4046 "{}\n{}",
4047 s.scrollback_text(),
4048 s.last_output.clone().unwrap_or_default()
4049 );
4050 assert!(kept.contains("data line"));
4051 for gone in [
4052 "run 2 of",
4053 "next in",
4054 "running market",
4055 "⠋",
4056 "Running market",
4057 ] {
4058 assert!(!kept.contains(gone), "{gone:?} leaked into: {kept}");
4059 }
4060 let events = std::fs::read_to_string(dir.path().join("events")).unwrap();
4061 assert!(
4062 !events.contains("run 2 of") && !events.contains("next in"),
4063 "{events}"
4064 );
4065 let after = screen(&s, 80, 12);
4067 assert!(!after.contains("run 2 of"), "{after}");
4068 }
4069
4070 #[test]
4071 fn test_render_shows_live_output_of_a_running_command() {
4072 let _lock = super::super::DIAG_LOCK.blocking_lock();
4074 let mut s = state();
4075 let inv = Invocation {
4076 command: venues_list(),
4077 route: Route::Pane,
4078 ai: false,
4079 argv: vec![],
4080 };
4081 let out = s.start("market summary USDC --every 30s", &inv);
4082 crate::outln!(out, "\x1b[32mrun 1: HEALTHY\x1b[0m").unwrap();
4083 let text = screen(&s, 80, 15);
4084 assert!(text.contains("run 1: HEALTHY"), "{}", text);
4085 assert!(text.contains("running 0s · Esc cancels"), "{}", text);
4086 s.finish(Ending::Finished(Ok(())));
4087 assert!(s.scrollback_text().contains("run 1: HEALTHY"));
4088 }
4089
4090 #[test]
4091 fn test_newest_line_is_on_the_last_pane_row() {
4092 let mut s = state();
4095 for i in 0..40 {
4096 s.push_ansi(&format!("line {}\n", i));
4097 }
4098 let rows: Vec<String> = screen(&s, 40, 15).lines().map(str::to_string).collect();
4099 assert!(rows[8].contains("line 39"), "{:#?}", rows);
4101 assert!(rows[9].starts_with('─'), "{:#?}", rows);
4102 }
4103
4104 #[test]
4105 fn test_hint_row_shows_usage_while_typing() {
4106 let mut s = state();
4107 type_str(&mut s, "market summary");
4108 let text = screen(&s, 100, 15);
4109 assert!(text.contains("scope market summary"), "{}", text);
4110 assert!(text.contains("next: <SYMBOL>"), "{}", text);
4111 }
4112
4113 #[test]
4114 fn test_render_tiny_terminal_does_not_panic() {
4115 let mut s = state();
4116 s.push_ansi("x\n");
4117 screen(&s, 10, 5);
4118 screen(&s, 1, 1);
4119 }
4120
4121 #[tokio::test]
4122 async fn test_finish_with_error_shows_cli_error_text_and_logs() {
4123 let _lock = super::super::DIAG_LOCK.lock().await;
4124 let dir = tempfile::tempdir().unwrap();
4125 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
4126 let mut s = TuiState::new(SessionContext::default(), journal, Vocab::default());
4127 let inv = Invocation {
4128 command: venues_list(),
4129 route: Route::Pane,
4130 ai: false,
4131 argv: vec!["scope".into(), "venues".into(), "list".into()],
4132 };
4133 s.start("venues list", &inv);
4134 s.finish(Ending::Finished(Err(
4135 scope::error::ScopeError::InvalidAddress("0xbad".into()),
4136 )));
4137 let text = s.scrollback_text();
4138 assert!(text.contains("Invalid address format: 0xbad"), "{}", text);
4139 assert!(text.contains("EVM: 0x followed by 40 hex characters"));
4140 let log = std::fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
4141 let ev: serde_json::Value = serde_json::from_str(log.lines().last().unwrap()).unwrap();
4142 assert_eq!(ev["outcome"], "error");
4143 assert_eq!(ev["route"], "pane");
4144 assert_eq!(ev["argv"][1], "venues");
4145 }
4146
4147 fn palette_selected(s: &TuiState) -> usize {
4148 let Some(Overlay::Palette(p)) = &s.overlay else {
4149 panic!("palette expected")
4150 };
4151 p.selected
4152 }
4153
4154 #[test]
4155 fn test_palette_selection_wraps_and_a_new_query_resets_it() {
4156 let mut s = state();
4159 s.handle_key(ctrl_k());
4160 let count = match &s.overlay {
4161 Some(Overlay::Palette(p)) => p.matches().len(),
4162 _ => panic!("palette expected"),
4163 };
4164 assert!(count > 2);
4165 s.handle_key(key(KeyCode::Up));
4166 assert_eq!(palette_selected(&s), count - 1);
4167 s.handle_key(key(KeyCode::Down));
4168 assert_eq!(palette_selected(&s), 0);
4169 s.handle_key(key(KeyCode::Tab));
4170 s.handle_key(key(KeyCode::BackTab));
4171 assert_eq!(palette_selected(&s), 0);
4172 s.handle_key(key(KeyCode::Down));
4173 s.handle_key(key(KeyCode::Backspace));
4174 assert_eq!(palette_selected(&s), 0);
4175 s.handle_key(key(KeyCode::Down));
4176 type_str(&mut s, "m");
4177 assert_eq!(palette_selected(&s), 0);
4178 }
4179
4180 #[test]
4181 fn test_palette_esc_closes_and_enter_without_a_match_keeps_the_draft() {
4182 let mut s = state();
4183 type_str(&mut s, "draft");
4184 s.handle_key(ctrl_k());
4185 s.handle_key(key(KeyCode::Esc));
4186 assert!(s.overlay.is_none());
4187 s.handle_key(ctrl_k());
4188 type_str(&mut s, "zzzzqqqq");
4189 s.handle_key(key(KeyCode::Down));
4190 s.handle_key(key(KeyCode::Enter));
4191 assert!(s.overlay.is_none());
4192 assert_eq!(s.input, "draft", "no match: nothing is filled in");
4193 }
4194
4195 #[test]
4196 fn test_menu_up_wraps_to_the_last_candidate() {
4197 let mut s = vocab_state();
4198 type_str(&mut s, "market summary USDC --venue b");
4199 s.handle_key(key(KeyCode::Tab));
4200 s.handle_key(key(KeyCode::Up));
4201 s.handle_key(key(KeyCode::Enter));
4202 assert_eq!(s.input, "market summary USDC --venue bitget ");
4203 s.set_input("market summary USDC --venue b".to_string());
4204 s.handle_key(key(KeyCode::Tab));
4205 s.handle_key(key(KeyCode::Down));
4206 s.handle_key(key(KeyCode::BackTab));
4207 s.handle_key(key(KeyCode::Enter));
4208 assert_eq!(s.input, "market summary USDC --venue binance ");
4209 }
4210
4211 #[test]
4212 fn test_record_signal_writes_the_signal_outcome_to_the_journal() {
4213 let dir = tempfile::tempdir().unwrap();
4216 let events = dir.path().join("e.jsonl");
4217 let mut s = TuiState::new(
4218 SessionContext::default(),
4219 Journal::at(dir.path().join("h"), events.clone()),
4220 Vocab::default(),
4221 );
4222 s.record_signal("HUP", "suspend", "setup", &["scope".into(), "setup".into()]);
4223 let row: serde_json::Value =
4224 serde_json::from_str(std::fs::read_to_string(events).unwrap().trim()).unwrap();
4225 assert_eq!(row["outcome"], "signal:HUP");
4226 assert_eq!(row["route"], "suspend");
4227 assert_eq!(row["input"], "setup");
4228 assert_eq!(row["argv"][1], "setup");
4229 }
4230
4231 #[test]
4232 fn test_failed_suspended_command_is_journaled_as_error_with_its_text() {
4233 let dir = tempfile::tempdir().unwrap();
4234 let events = dir.path().join("e.jsonl");
4235 let mut s = TuiState::new(
4236 SessionContext::default(),
4237 Journal::at(dir.path().join("h"), events.clone()),
4238 Vocab::default(),
4239 );
4240 let failed = Err(scope::error::ScopeError::Other("setup broke".into()));
4241 s.record_suspended(
4242 "setup",
4243 &["scope".into(), "setup".into()],
4244 std::time::Duration::from_millis(5),
4245 "2026-01-01T00:00:00Z".into(),
4246 &Ending::Finished(failed),
4247 );
4248 let row: serde_json::Value =
4249 serde_json::from_str(std::fs::read_to_string(events).unwrap().trim()).unwrap();
4250 assert_eq!(row["outcome"], "error");
4251 assert_eq!(row["route"], "suspend");
4252 assert!(row["error"].as_str().unwrap().contains("setup broke"));
4253 }
4254
4255 #[test]
4256 fn test_unwritable_event_log_is_reported_in_the_pane() {
4257 let dir = tempfile::tempdir().unwrap();
4260 let mut s = TuiState::new(
4262 SessionContext::default(),
4263 Journal::at(dir.path().join("h"), dir.path().to_path_buf()),
4264 Vocab::default(),
4265 );
4266 s.record_signal("TERM", "session", "", &[]);
4267 assert!(
4268 s.scrollback_text()
4269 .contains("Could not write the TUI event log"),
4270 "{}",
4271 s.scrollback_text()
4272 );
4273 }
4274
4275 #[test]
4276 fn test_paste_joins_lines_and_never_runs() {
4277 let mut s = state();
4278 s.paste("venues list\nmarket summary USDC\n");
4279 assert_eq!(s.input, "venues list market summary USDC");
4280 assert!(s.scrollback_text().contains("pasted 2 lines as one line"));
4281 }
4282
4283 #[test]
4284 fn test_paste_one_line_adds_no_note_and_crlf_counts_once() {
4285 let mut s = state();
4286 s.paste("help\r\n");
4287 assert_eq!(s.input, "help");
4288 assert!(!s.scrollback_text().contains("pasted"));
4289 s.set_input(String::new());
4290 s.paste("a\r\nb\rc");
4291 assert_eq!(s.input, "a b c");
4292 assert!(s.scrollback_text().contains("pasted 3 lines as one line"));
4293 }
4294
4295 #[test]
4296 fn test_paste_inserts_at_the_cursor() {
4297 let mut s = state();
4298 type_str(&mut s, "ac");
4299 s.handle_key(key(KeyCode::Left));
4300 s.paste("b");
4301 assert_eq!(s.input, "abc");
4302 type_str(&mut s, "!");
4303 assert_eq!(s.input, "ab!c", "the cursor sits after the paste");
4304 }
4305
4306 #[test]
4307 fn test_paste_cannot_inject_control_characters() {
4308 let mut s = state();
4309 s.paste("a\x1b[2Jb\tc\x07\x7fd\u{9b}e");
4310 assert_eq!(s.input, "a[2Jb cde");
4311 }
4312
4313 #[test]
4314 fn test_paste_closes_the_completion_menu() {
4315 let mut s = vocab_state();
4316 type_str(&mut s, "market summary X --venue b");
4317 s.handle_key(key(KeyCode::Tab));
4318 assert!(s.overlay.is_some());
4319 s.paste("z");
4320 assert!(s.overlay.is_none());
4321 }
4322
4323 #[test]
4324 fn test_paste_while_a_command_runs_goes_to_the_input() {
4325 let _lock = super::super::DIAG_LOCK.blocking_lock();
4326 let mut s = state();
4327 let inv = Invocation {
4328 command: venues_list(),
4329 route: Route::Pane,
4330 ai: false,
4331 argv: vec![],
4332 };
4333 s.start("venues list", &inv);
4334 s.paste("market summary USDC\n");
4335 assert_eq!(s.input, "market summary USDC");
4336 assert!(s.running.is_some(), "the paste does not run or cancel");
4337 s.finish(Ending::Cancelled);
4338 }
4339
4340 #[test]
4341 fn test_long_input_keeps_cursor_visible() {
4342 let mut s = state();
4343 s.paste(&"x".repeat(300));
4344 assert_eq!(cursor_pos(&s, 60, 12).x, 58);
4346 let sc = screen(&s, 60, 12);
4347 let row = sc.lines().find(|l| l.contains("xxxx")).unwrap();
4348 let cells: Vec<char> = row.chars().collect();
4349 assert_eq!(cells[0], '│');
4350 assert!(
4351 cells[1..58].iter().all(|&c| c == 'x'),
4352 "text ends at the cursor"
4353 );
4354 assert_eq!(cells[58], ' ', "the cursor cell is empty");
4355 assert_eq!(cells[59], '│', "the border is not overwritten");
4356 }
4357
4358 #[test]
4359 fn test_cursor_scrolls_back_with_home() {
4360 let mut s = state();
4361 s.paste(&"x".repeat(100));
4362 s.handle_key(key(KeyCode::Home));
4363 assert_eq!(
4364 cursor_pos(&s, 60, 12).x,
4365 1,
4366 "Home shows the start of the line"
4367 );
4368 s.handle_key(key(KeyCode::End));
4369 assert_eq!(cursor_pos(&s, 60, 12).x, 58);
4370 }
4371
4372 #[test]
4373 fn test_cursor_follows_display_width_of_cjk() {
4374 let mut s = state();
4376 type_str(&mut s, "日本 ok");
4377 s.handle_key(key(KeyCode::End));
4378 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 7);
4379 s.handle_key(key(KeyCode::Left));
4380 s.handle_key(key(KeyCode::Left));
4381 s.handle_key(key(KeyCode::Left));
4382 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 4, "after 日本");
4383 }
4384
4385 #[test]
4386 fn test_cursor_follows_display_width_of_emoji() {
4387 let mut s = state();
4388 type_str(&mut s, "😀x");
4389 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 3);
4390 }
4391
4392 #[test]
4393 fn test_wide_input_scrolls_by_columns_not_chars() {
4394 let mut s = state();
4396 s.paste(&"日".repeat(40));
4397 assert_eq!(cursor_pos(&s, 60, 12).x, 57);
4399 }
4400
4401 #[test]
4402 fn test_menu_width_uses_display_width() {
4403 let wide_a = "日本日本日本a";
4404 let wide_b = "日本日本日本b";
4405 let mut s = TuiState::new(
4406 SessionContext::default(),
4407 Journal::default(),
4408 Vocab {
4409 venues: vec![wide_a.into(), wide_b.into()],
4410 ..Vocab::default()
4411 },
4412 );
4413 type_str(&mut s, "market summary X --venue ");
4414 s.handle_key(key(KeyCode::Tab));
4415 assert!(matches!(s.overlay, Some(Overlay::Menu(_))));
4416 let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap();
4417 term.draw(|f| s.render(f)).unwrap();
4418 let buf = term.backend().buffer().clone();
4419 let row = (0..14)
4422 .find(|&y| (0..60).any(|x| buf[(x, y)].symbol() == "a"))
4423 .unwrap();
4424 assert_eq!(buf[(16, row)].symbol(), "│");
4425 }
4426
4427 fn start_pane_command(s: &mut TuiState) {
4428 let inv = Invocation {
4429 command: venues_list(),
4430 route: Route::Pane,
4431 ai: false,
4432 argv: vec![],
4433 };
4434 s.start("venues list", &inv);
4435 }
4436
4437 #[test]
4438 fn test_ctrl_d_while_running_explains_and_does_not_exit() {
4439 let _lock = super::super::DIAG_LOCK.blocking_lock();
4440 let mut s = state();
4441 start_pane_command(&mut s);
4442 assert_eq!(s.handle_key(ctrl('d')), Action::None);
4443 assert!(s.scrollback_text().contains(BUSY_NOTE));
4444 s.finish(Ending::Cancelled);
4445 }
4446
4447 #[test]
4448 fn test_exit_typed_while_running_explains_and_keeps_the_line() {
4449 let _lock = super::super::DIAG_LOCK.blocking_lock();
4450 for word in ["exit", "quit", "q", "EXIT"] {
4451 let mut s = state();
4452 start_pane_command(&mut s);
4453 type_str(&mut s, word);
4454 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None, "{word}");
4455 assert!(s.scrollback_text().contains(BUSY_NOTE), "{word}");
4456 assert_eq!(s.input, word, "the line stays");
4457 s.finish(Ending::Cancelled);
4458 }
4459 }
4460
4461 #[test]
4462 fn test_any_line_while_running_shows_the_hint_and_keeps_the_input() {
4463 let _lock = super::super::DIAG_LOCK.blocking_lock();
4464 let mut s = state();
4465 start_pane_command(&mut s);
4466 type_str(&mut s, "ctx");
4467 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
4468 assert_eq!(s.input, "ctx");
4469 assert!(screen(&s, 100, 14).contains(BUSY_NOTE), "hint row");
4470 assert!(
4471 !s.scrollback_text().contains(BUSY_NOTE),
4472 "no scrollback note"
4473 );
4474 s.finish(Ending::Cancelled);
4475 }
4476
4477 #[test]
4478 fn test_empty_enter_while_running_shows_no_hint() {
4479 let _lock = super::super::DIAG_LOCK.blocking_lock();
4480 let mut s = state();
4481 start_pane_command(&mut s);
4482 s.handle_key(key(KeyCode::Enter));
4483 assert!(!screen(&s, 100, 14).contains(BUSY_NOTE));
4484 s.finish(Ending::Cancelled);
4485 }
4486
4487 #[test]
4488 fn test_busy_hint_clears_on_the_next_key() {
4489 let _lock = super::super::DIAG_LOCK.blocking_lock();
4490 let mut s = state();
4491 start_pane_command(&mut s);
4492 s.handle_key(ctrl('d'));
4493 let hint_row = screen(&s, 100, 14).lines().nth(12).unwrap().to_string();
4494 assert!(hint_row.contains(BUSY_NOTE));
4495 s.handle_key(key(KeyCode::Char('x')));
4496 let hint_row = screen(&s, 100, 14).lines().nth(12).unwrap().to_string();
4497 assert!(!hint_row.contains(BUSY_NOTE));
4498 s.finish(Ending::Cancelled);
4499 }
4500
4501 #[test]
4502 fn test_busy_note_is_not_repeated_in_a_row() {
4503 let _lock = super::super::DIAG_LOCK.blocking_lock();
4504 let mut s = state();
4505 start_pane_command(&mut s);
4506 s.handle_key(ctrl('d'));
4507 s.handle_key(ctrl('d'));
4508 assert_eq!(s.scrollback_text().matches(BUSY_NOTE).count(), 1);
4509 s.finish(Ending::Cancelled);
4510 }
4511
4512 #[test]
4513 fn test_ctrl_d_exits_once_the_command_ended() {
4514 let _lock = super::super::DIAG_LOCK.blocking_lock();
4515 let mut s = state();
4516 start_pane_command(&mut s);
4517 s.handle_key(ctrl('d'));
4518 s.finish(Ending::Cancelled);
4519 assert_eq!(s.handle_key(ctrl('d')), Action::Exit);
4520 }
4521
4522 #[test]
4523 fn test_ctrl_d_with_text_and_no_command_does_nothing() {
4524 let mut s = state();
4525 type_str(&mut s, "ab");
4526 assert_eq!(s.handle_key(ctrl('d')), Action::None);
4527 assert!(!s.scrollback_text().contains(BUSY_NOTE));
4528 }
4529
4530 #[test]
4531 fn test_paste_drops_bidi_controls() {
4532 let mut s = state();
4533 s.paste("a\u{202E}b\u{2066}c\u{2069}d\u{202A}");
4534 assert_eq!(s.input, "abcd");
4535 }
4536
4537 #[test]
4538 fn test_paste_is_capped_with_a_note() {
4539 let mut s = state();
4540 s.paste(&"x".repeat(PASTE_LIMIT + 50));
4541 assert_eq!(s.input.chars().count(), PASTE_LIMIT);
4542 assert!(
4543 s.scrollback_text()
4544 .contains("paste truncated to 10000 characters")
4545 );
4546 let mut t = state();
4547 t.paste(&"x".repeat(PASTE_LIMIT));
4548 assert!(
4549 !t.scrollback_text().contains("truncated"),
4550 "exact limit is not truncated"
4551 );
4552 }
4553
4554 #[test]
4555 fn test_paste_goes_into_the_open_palette_query() {
4556 let mut s = state();
4557 s.handle_key(ctrl('k'));
4558 s.paste("ven\nues");
4559 match &s.overlay {
4560 Some(Overlay::Palette(p)) => assert_eq!(p.query, "ven ues"),
4561 _ => panic!("the palette stays open"),
4562 }
4563 assert_eq!(s.input, "", "the input line is untouched");
4564 }
4565
4566 fn row_cells(s: &TuiState, w: u16, h: u16, needle: &str) -> (Vec<String>, u16) {
4567 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
4568 term.draw(|f| s.render(f)).unwrap();
4569 let buf = term.backend().buffer().clone();
4570 let y = (0..h)
4571 .find(|&y| (0..w).any(|x| buf[(x, y)].symbol() == needle))
4572 .unwrap();
4573 (
4574 (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect(),
4575 y,
4576 )
4577 }
4578
4579 #[test]
4580 fn test_left_mid_line_keeps_the_text_right_of_the_cursor_visible() {
4581 let mut s = state();
4582 s.paste(&format!("{}{}", "a".repeat(100), "b".repeat(5)));
4583 let _ = cursor_pos(&s, 60, 12);
4584 for _ in 0..3 {
4585 s.handle_key(key(KeyCode::Left));
4586 }
4587 let (cells, _) = row_cells(&s, 60, 12, "b");
4588 assert_eq!(cursor_pos(&s, 60, 12).x, 55, "the window did not move");
4589 assert_eq!(cells[55], "b", "text under and right of the cursor stays");
4590 assert_eq!(cells[57], "b", "the last char is still shown");
4591 }
4592
4593 #[test]
4594 fn test_window_moves_back_when_the_cursor_leaves_on_the_left() {
4595 let mut s = state();
4596 s.paste(&"x".repeat(100));
4597 let _ = cursor_pos(&s, 60, 12);
4598 for _ in 0..80 {
4599 s.handle_key(key(KeyCode::Left));
4600 }
4601 assert_eq!(cursor_pos(&s, 60, 12).x, 1, "cursor at the left edge");
4602 }
4603
4604 #[test]
4605 fn test_wide_chars_are_never_cut_at_the_left_edge() {
4606 let mut s = state();
4609 s.paste(&"日".repeat(40));
4610 let (cells, _) = row_cells(&s, 60, 12, "日");
4611 assert_eq!(cells[1], "日", "the first shown cell holds a whole char");
4612 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 80 - 24);
4613 assert_eq!(cells[59], "│", "border intact");
4614 }
4615
4616 #[test]
4617 fn test_cursor_on_a_wide_char_is_never_half_visible() {
4618 let mut s = state();
4619 s.paste(&"x".repeat(56));
4620 s.paste("日日");
4621 s.handle_key(key(KeyCode::Left));
4622 let p = cursor_pos(&s, 60, 12);
4624 assert!(
4625 p.x + 2 <= 59,
4626 "wide char at the cursor fits inside the border"
4627 );
4628 }
4629
4630 fn run_pane(s: &mut TuiState, text: &str, ending: Ending) {
4632 let inv = Invocation {
4633 command: venues_list(),
4634 route: Route::Pane,
4635 ai: false,
4636 argv: vec![],
4637 };
4638 let out = s.start("venues list", &inv);
4639 out.write_out(format_args!("{}", text)).unwrap();
4640 s.finish(ending);
4641 }
4642
4643 #[test]
4644 fn test_ctrl_y_copies_the_plain_output_of_the_last_success() {
4645 let mut s = state();
4646 assert_eq!(s.handle_key(ctrl('y')), Action::None);
4647 assert!(s.scrollback_text().contains("nothing to copy yet"));
4648 run_pane(&mut s, "\x1b[1;36mA\x1b[0m\nb\n", Ending::Finished(Ok(())));
4649 assert_eq!(s.handle_key(ctrl('y')), Action::Copy("A\nb\n".into()));
4650 }
4651
4652 #[test]
4653 fn test_failed_or_cancelled_run_keeps_the_earlier_copy() {
4654 let mut s = state();
4655 run_pane(&mut s, "good\n", Ending::Finished(Ok(())));
4656 run_pane(
4657 &mut s,
4658 "partial\n",
4659 Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into()))),
4660 );
4661 run_pane(&mut s, "partial\n", Ending::Cancelled);
4662 assert_eq!(s.handle_key(ctrl('y')), Action::Copy("good\n".into()));
4663 }
4664
4665 #[test]
4666 fn test_save_without_output_says_so_and_escape_is_refused() {
4667 let mut s = state();
4668 s.submit("save out.json", &Config::default());
4669 assert!(s.scrollback_text().contains("nothing to save yet"));
4670 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4671 s.submit("save ../escape.json", &Config::default());
4672 assert!(
4673 s.scrollback_text()
4674 .contains("save writes inside the current directory or to an absolute path")
4675 );
4676 s.submit("save", &Config::default());
4677 assert!(s.scrollback_text().contains("Usage: save <file> [--force]"));
4678 }
4679
4680 #[test]
4681 fn test_pane_has_no_side_borders() {
4682 let mut s = state();
4683 s.push_note("hello");
4684 let text = screen(&s, 40, 12);
4685 let row = text.lines().find(|l| l.contains("hello")).unwrap();
4686 assert!(row.starts_with("ℹ hello"), "{row:?}");
4687 }
4688
4689 fn last_line(s: &TuiState) -> &Line<'static> {
4690 s.scrollback.last().unwrap()
4691 }
4692
4693 #[test]
4694 fn test_ok_run_ends_with_done_footer() {
4695 let mut s = state();
4696 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4697 let text = last_line(&s).to_string();
4698 assert!(
4699 regex::Regex::new(r"^✓ Done in \d+\.\ds\.$")
4700 .unwrap()
4701 .is_match(&text),
4702 "{text}"
4703 );
4704 assert_eq!(last_line(&s).style, OK);
4705 }
4706
4707 #[test]
4708 fn test_failed_run_ends_with_a_red_failed_footer() {
4709 let mut s = state();
4710 let err = Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into())));
4711 s.start("x", &pane_invocation());
4712 s.finish_with(err);
4713 let line = last_line(&s);
4714 let text = line.to_string();
4715 assert!(
4716 regex::Regex::new(r"^✗ Failed in \d+\.\ds\.$")
4717 .unwrap()
4718 .is_match(&text),
4719 "{text}"
4720 );
4721 assert_eq!(line.style, ERR.add_modifier(Modifier::BOLD));
4722 }
4723
4724 #[test]
4725 fn test_cancelled_run_ends_with_cancelled_after_footer() {
4726 let mut s = state();
4727 run_pane(&mut s, "x\n", Ending::Cancelled);
4728 let text = last_line(&s).to_string();
4729 assert!(
4730 regex::Regex::new(r"^Cancelled after \d+\.\ds\.$")
4731 .unwrap()
4732 .is_match(&text),
4733 "{text}"
4734 );
4735 assert_eq!(last_line(&s).style, WARN);
4736 }
4737
4738 fn pane_invocation() -> Invocation {
4739 Invocation {
4740 command: venues_list(),
4741 route: Route::Pane,
4742 ai: false,
4743 argv: vec![],
4744 }
4745 }
4746
4747 fn warned_json_run(s: &mut TuiState) {
4748 let inv = pane_invocation();
4749 let out = s.start("address 0x1 --format json", &inv);
4750 crate::errln!(out, "\u{26a0} Token balances unavailable").unwrap();
4751 crate::outln!(out, "{{\"a\": 1}}").unwrap();
4752 s.finish(Ending::Finished(Ok(())));
4753 }
4754
4755 #[test]
4756 fn test_last_output_excludes_warnings_the_pane_still_shows() {
4757 let mut s = state();
4758 warned_json_run(&mut s);
4759 let Action::Copy(text) = s.handle_key(ctrl('y')) else {
4760 panic!("expected a copy");
4761 };
4762 serde_json::from_str::<serde_json::Value>(&text).unwrap();
4763 assert!(!text.contains("Token balances"));
4764 assert!(s.scrollback_text().contains("Token balances unavailable"));
4765 }
4766
4767 #[test]
4768 fn test_a_success_with_no_output_clears_the_copy() {
4769 let mut s = state();
4770 run_pane(&mut s, "data\n", Ending::Finished(Ok(())));
4771 run_pane(&mut s, "", Ending::Finished(Ok(())));
4772 assert_eq!(s.handle_key(ctrl('y')), Action::None);
4773 s.submit("save out.json", &Config::default());
4774 assert!(s.scrollback_text().contains("nothing to save yet"));
4775 }
4776
4777 #[test]
4778 fn test_ctrl_y_refuses_output_over_the_osc52_cap() {
4779 let mut s = state();
4780 let big = "x".repeat(clip::OSC52_MAX_PAYLOAD);
4781 run_pane(&mut s, &format!("{big}\n"), Ending::Finished(Ok(())));
4782 assert_eq!(s.handle_key(ctrl('y')), Action::None);
4783 assert!(
4784 s.scrollback_text()
4785 .contains("too large to copy (1 lines); use save <file>")
4786 );
4787 }
4788
4789 #[test]
4790 fn test_save_with_unbalanced_quotes_gets_the_save_usage() {
4791 let mut s = state();
4792 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4793 s.submit("save \"unclosed", &Config::default());
4794 let text = s.scrollback_text();
4795 assert!(
4796 text.contains("Unbalanced quotes. Usage: save <file> [--force]"),
4797 "{text}"
4798 );
4799 }
4800
4801 fn pane_rows(s: &TuiState, w: u16) -> Vec<String> {
4803 let h = 200;
4804 let rows: Vec<String> = screen(s, w, h).lines().map(str::to_string).collect();
4805 rows[1..(h as usize - 6)].to_vec()
4807 }
4808
4809 #[tokio::test]
4810 async fn test_venues_list_rules_never_wrap_in_a_narrow_pane() {
4811 let _lock = super::super::DIAG_LOCK.lock().await;
4816 let mut s = state();
4817 let inv = pane_invocation();
4818 let out = s.start("venues list", &inv);
4819 let factory = scope::chains::mocks::MockClientFactory::new();
4820 let res = run_invocation(inv, &Config::default(), &factory, &out).await;
4821 s.finish(Ending::Finished(res));
4822 let rows = pane_rows(&s, 40);
4823 for pair in rows.windows(2) {
4824 let continued = pair[0].trim_end().ends_with('─') && pair[1].starts_with('─');
4825 assert!(!continued, "a rule wrapped:\n{}", rows.join("\n"));
4826 }
4827 let first = rows
4828 .iter()
4829 .position(|r| r.starts_with("┌─ Available Venues"));
4830 let last = rows.iter().position(|r| r.starts_with('└'));
4831 let (Some(first), Some(last)) = (first, last) else {
4832 panic!("no section:\n{}", rows.join("\n"));
4833 };
4834 for row in &rows[first + 1..last] {
4835 assert!(
4836 row.starts_with('│') || row.starts_with('├'),
4837 "row lost its gutter: {row:?}\n{}",
4838 rows.join("\n")
4839 );
4840 }
4841 for row in &rows {
4842 assert!(row.width() <= 40, "{row:?}");
4843 }
4844 }
4845
4846 #[test]
4847 fn test_pane_wraps_never_reach_the_copy() {
4848 let mut s = state();
4851 let long = format!("│ {}", "word ".repeat(30).trim_end());
4852 run_pane(&mut s, &format!("{long}\n"), Ending::Finished(Ok(())));
4853 let rows = pane_rows(&s, 40);
4854 let wrapped: Vec<_> = rows.iter().filter(|r| r.contains("word")).collect();
4855 assert!(wrapped.len() > 1, "{rows:#?}");
4856 assert!(
4857 wrapped.iter().all(|r| r.starts_with("│ word")),
4858 "{rows:#?}"
4859 );
4860 let Action::Copy(text) = s.handle_key(ctrl('y')) else {
4861 panic!("expected a copy");
4862 };
4863 assert_eq!(text, format!("{long}\n"));
4864 }
4865
4866 #[test]
4867 fn test_render_records_the_pane_width() {
4868 let s = state();
4871 assert_eq!(s.pane_width(), None, "nothing drawn yet");
4872 screen(&s, 40, 20);
4873 assert_eq!(s.pane_width(), Some(40));
4874 }
4875
4876 fn alpha_state() -> TuiState {
4879 let mut s = state();
4880 s.push_ansi("alpha\nbeta\nalpha2\n");
4881 screen(&s, 80, 20);
4882 s
4883 }
4884
4885 fn open_search(s: &mut TuiState, query: &str) {
4886 s.handle_key(ctrl('f'));
4887 type_str(s, query);
4888 screen(s, 80, 20);
4889 }
4890
4891 #[test]
4892 fn test_search_goes_to_the_newest_match_and_n_and_shift_n_walk_the_matches() {
4893 let mut s = alpha_state();
4894 open_search(&mut s, "alpha");
4895 assert!(
4896 status_row(&s, 80).contains("search: alpha 1/2"),
4897 "newest first"
4898 );
4899 assert_eq!(s.input, "", "typing goes to the query, not the input");
4900 s.handle_key(key(KeyCode::Enter));
4901 s.handle_key(key(KeyCode::Char('n')));
4902 assert!(status_row(&s, 80).contains("search: alpha 2/2"));
4903 s.handle_key(key(KeyCode::Char('n')));
4904 assert!(status_row(&s, 80).contains("1/2"), "n wraps to the newest");
4905 s.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
4906 assert!(status_row(&s, 80).contains("2/2"), "N wraps to the oldest");
4907 s.handle_key(key(KeyCode::Esc));
4908 assert!(s.overlay.is_none());
4909 assert!(!status_row(&s, 80).contains("search:"));
4910 }
4911
4912 #[test]
4913 fn test_n_is_a_query_character_while_editing() {
4914 let mut s = alpha_state();
4915 open_search(&mut s, "an");
4916 assert!(status_row(&s, 80).contains("search: an"));
4917 assert!(status_row(&s, 80).contains("no match"));
4918 }
4919
4920 #[test]
4921 fn test_slash_opens_search_only_on_an_empty_input() {
4922 let mut s = alpha_state();
4923 s.handle_key(key(KeyCode::Char('/')));
4924 assert!(matches!(s.overlay, Some(Overlay::Search(_))));
4925 assert_eq!(s.input, "");
4926 s.handle_key(key(KeyCode::Esc));
4927 type_str(&mut s, "a/b");
4928 assert!(s.overlay.is_none());
4929 assert_eq!(s.input, "a/b", "a slash inside text is text");
4930 }
4931
4932 #[test]
4933 fn test_search_is_case_insensitive_and_marks_matched_rows() {
4934 let mut s = alpha_state();
4935 open_search(&mut s, "ALPHA");
4936 let text = screen(&s, 80, 20);
4937 assert!(status_row(&s, 80).contains("1/2"));
4938 assert_eq!(text.matches('▶').count(), 2, "{text}");
4939 }
4940
4941 #[test]
4942 fn test_no_match_says_so_and_n_does_nothing() {
4943 let mut s = alpha_state();
4944 open_search(&mut s, "zzz");
4945 assert!(status_row(&s, 80).contains("no match"));
4946 s.handle_key(key(KeyCode::Enter));
4947 s.handle_key(key(KeyCode::Char('n')));
4948 s.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
4949 assert!(status_row(&s, 80).contains("no match"));
4950 assert!(!screen(&s, 80, 20).contains('▶'));
4951 }
4952
4953 fn needle_state() -> TuiState {
4954 let mut s = state();
4955 s.push_ansi("needle first\n");
4956 for i in 0..40 {
4957 s.push_ansi(&format!("line {}\n", i));
4958 }
4959 screen(&s, 60, 15);
4960 s
4961 }
4962
4963 #[test]
4964 fn test_search_scrolls_to_an_old_match_and_esc_restores_the_view() {
4965 let mut s = needle_state();
4966 s.handle_key(key(KeyCode::PageUp));
4967 let before = screen(&s, 60, 15);
4968 assert!(!before.contains("needle"));
4969 s.handle_key(ctrl('f'));
4970 type_str(&mut s, "needle");
4971 let during = screen(&s, 60, 15);
4972 assert!(during.contains("needle first"), "{during}");
4973 assert!(during.contains('▶'));
4974 s.handle_key(key(KeyCode::Esc));
4975 assert_eq!(screen(&s, 60, 15), before, "the view the user had");
4976 }
4977
4978 fn long_match_state() -> TuiState {
4979 let mut s = state();
4980 s.push_ansi(&format!("needle {}\n", "word ".repeat(40)));
4981 for i in 0..30 {
4982 s.push_ansi(&format!("line {}\n", i));
4983 }
4984 screen(&s, 60, 15);
4985 s
4986 }
4987
4988 #[test]
4989 fn test_a_long_match_highlights_every_row_it_spans() {
4990 let mut s = long_match_state();
4991 s.handle_key(ctrl('f'));
4992 type_str(&mut s, "needle");
4993 let text = screen(&s, 60, 15);
4994 assert!(text.matches('▶').count() >= 2, "{text}");
4995 let mut term = Terminal::new(TestBackend::new(60, 15)).unwrap();
4997 term.draw(|f| s.render(f)).unwrap();
4998 let buf = term.backend().buffer().clone();
4999 let reversed = (0..15)
5000 .filter(|&y| buf[(5, y)].modifier.contains(Modifier::REVERSED))
5001 .count();
5002 assert!(reversed >= 2, "{text}");
5003 }
5004
5005 #[test]
5006 fn test_search_follows_a_resize() {
5007 let mut s = long_match_state();
5008 s.handle_key(ctrl('f'));
5009 type_str(&mut s, "needle");
5010 assert!(screen(&s, 60, 15).contains("needle"));
5011 assert!(
5012 screen(&s, 100, 20).contains("needle"),
5013 "visible after a resize"
5014 );
5015 assert!(screen(&s, 45, 12).contains("needle"), "and again");
5016 }
5017
5018 #[test]
5019 fn test_a_paste_goes_into_the_search_query() {
5020 let mut s = alpha_state();
5021 s.handle_key(ctrl('f'));
5022 s.paste("alpha\n");
5023 screen(&s, 80, 20);
5024 assert_eq!(s.input, "");
5025 assert!(status_row(&s, 80).contains("search: alpha 1/2"));
5026 }
5027
5028 #[test]
5029 fn test_esc_closes_the_search_before_it_cancels_a_running_command() {
5030 let _lock = super::super::DIAG_LOCK.blocking_lock();
5031 let (mut s, _out) = running_state("market summary USDC --every 2s");
5032 s.handle_key(ctrl('f'));
5033 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::None);
5034 assert!(s.overlay.is_none());
5035 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
5036 }
5037
5038 #[test]
5039 fn test_ctrl_c_still_cancels_a_running_command_with_a_search_open() {
5040 let _lock = super::super::DIAG_LOCK.blocking_lock();
5041 let (mut s, _out) = running_state("market summary USDC --every 2s");
5042 s.handle_key(ctrl('f'));
5043 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
5044 assert!(s.overlay.is_none());
5045 }
5046
5047 #[test]
5048 fn test_status_keeps_search_before_the_command_text_and_always_the_cancel_hint() {
5049 let _lock = super::super::DIAG_LOCK.blocking_lock();
5050 let (mut s, _out) = running_state("market summary USDC --every 2s");
5051 for i in 0..5 {
5052 s.push_ansi(&format!("alpha {}\n", i));
5053 }
5054 screen(&s, 140, 12);
5055 s.handle_key(ctrl('f'));
5056 type_str(&mut s, "alpha");
5057 screen(&s, 140, 12);
5058 let wide = status_row(&s, 140);
5059 assert!(
5060 wide.contains("search: alpha 1/5") && wide.contains("market summary"),
5061 "{wide}"
5062 );
5063 for w in 32..140 {
5065 let row = status_row(&s, w);
5066 assert!(row.contains("Esc closes search"), "{w}: {row:?}");
5067 if row.contains("market") {
5068 assert!(
5069 row.contains("search:"),
5070 "{w}: command without search: {row:?}"
5071 );
5072 }
5073 }
5074 let search_without_command = (26..140).any(|w| {
5075 let row = status_row(&s, w);
5076 row.contains("search:") && !row.contains("market")
5077 });
5078 assert!(search_without_command, "the command goes first");
5079 }
5080
5081 #[test]
5082 fn test_search_closes_when_the_terminal_is_too_small_and_restores_the_view() {
5083 let mut s = needle_state();
5084 s.handle_key(key(KeyCode::PageUp));
5085 let before = screen(&s, 60, 15);
5086 s.handle_key(ctrl('f'));
5087 type_str(&mut s, "needle");
5088 screen(&s, 30, 8);
5089 assert_eq!(s.handle_key(key(KeyCode::Char('x'))), Action::None);
5090 assert!(s.overlay.is_none());
5091 assert_eq!(s.input, "");
5092 assert_eq!(screen(&s, 60, 15), before);
5093 }
5094
5095 fn history_state() -> TuiState {
5096 let mut s = state();
5097 s.history = vec![
5098 "venues list".into(),
5099 "ctx".into(),
5100 "market summary USDC".into(),
5101 ];
5102 s
5103 }
5104
5105 #[test]
5106 fn test_ctrl_r_filters_the_history_and_enter_accepts_without_running() {
5107 let mut s = history_state();
5108 s.handle_key(ctrl('r'));
5109 type_str(&mut s, "ven");
5110 assert_eq!(s.input, "venues list");
5111 screen(&s, 80, 20);
5112 assert!(status_row(&s, 80).contains("1/1"), "{}", status_row(&s, 80));
5113 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
5114 assert!(s.overlay.is_none());
5115 assert_eq!(s.input, "venues list", "accepted into the input, not run");
5116 }
5117
5118 #[test]
5119 fn test_ctrl_r_again_cycles_to_older_matches() {
5120 let mut s = history_state();
5121 s.history.push("venues x".into());
5122 s.handle_key(ctrl('r'));
5123 type_str(&mut s, "ven");
5124 assert_eq!(s.input, "venues x", "most recent first");
5125 s.handle_key(ctrl('r'));
5126 assert_eq!(s.input, "venues list");
5127 s.handle_key(ctrl('r'));
5128 assert_eq!(s.input, "venues x", "wraps");
5129 }
5130
5131 #[test]
5132 fn test_esc_in_history_search_restores_the_draft() {
5133 let mut s = history_state();
5134 type_str(&mut s, "dra");
5135 s.handle_key(ctrl('r'));
5136 type_str(&mut s, "ven");
5137 assert_eq!(s.input, "venues list");
5138 s.handle_key(key(KeyCode::Esc));
5139 assert_eq!(s.input, "dra");
5140 assert!(s.overlay.is_none());
5141 }
5142
5143 #[test]
5144 fn test_history_search_with_no_match_says_so_and_keeps_the_draft() {
5145 let mut s = history_state();
5146 type_str(&mut s, "dra");
5147 s.handle_key(ctrl('r'));
5148 type_str(&mut s, "zzz");
5149 screen(&s, 80, 20);
5150 assert!(status_row(&s, 80).contains("no match"));
5151 assert_eq!(s.input, "dra");
5152 for _ in 0..3 {
5153 s.handle_key(key(KeyCode::Backspace));
5154 }
5155 assert_eq!(s.input, "dra", "an empty query matches nothing");
5156 }
5157
5158 #[test]
5159 fn test_a_paste_goes_into_the_history_query() {
5160 let mut s = history_state();
5161 s.handle_key(ctrl('r'));
5162 s.paste("market");
5163 assert_eq!(s.input, "market summary USDC");
5164 }
5165
5166 #[test]
5167 fn test_n_and_shift_n_go_in_opposite_directions_over_three_matches() {
5168 let mut s = state();
5169 s.push_ansi("alpha\nalpha2\nbeta\nalpha3\n");
5170 screen(&s, 80, 20);
5171 open_search(&mut s, "alpha");
5172 let at = |s: &TuiState| status_row(s, 80);
5173 assert!(at(&s).contains("1/3"));
5174 s.handle_key(key(KeyCode::Enter));
5175 let shift_n = KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT);
5176 s.handle_key(shift_n);
5177 assert!(
5178 at(&s).contains("3/3"),
5179 "N wraps from the newest to the oldest"
5180 );
5181 s.handle_key(key(KeyCode::Char('n')));
5182 assert!(
5183 at(&s).contains("1/3"),
5184 "n wraps from the oldest to the newest"
5185 );
5186 s.handle_key(key(KeyCode::Char('n')));
5187 assert!(at(&s).contains("2/3"));
5188 s.handle_key(shift_n);
5189 assert!(at(&s).contains("1/3"), "N from the middle goes newer");
5190 s.handle_key(key(KeyCode::Char('n')));
5191 s.handle_key(key(KeyCode::Char('n')));
5192 assert!(at(&s).contains("3/3"));
5193 s.handle_key(key(KeyCode::Char('n')));
5194 assert!(at(&s).contains("1/3"));
5195 }
5196
5197 fn fill_to_limit(s: &mut TuiState, filler_first: usize) {
5198 for i in 0..filler_first {
5199 s.push_ansi(&format!("filler {i}\n"));
5200 }
5201 s.push_ansi("needle one\n");
5202 for i in 0..100 {
5203 s.push_ansi(&format!("more {i}\n"));
5204 }
5205 s.push_ansi("needle two\n");
5206 while s.scrollback.len() < SCROLLBACK_LIMIT {
5207 s.push_ansi("tail\n");
5208 }
5209 }
5210
5211 #[test]
5212 fn test_search_keeps_its_match_when_the_scrollback_drops_old_lines() {
5213 let mut s = state();
5214 fill_to_limit(&mut s, 100);
5215 assert_eq!(s.scrollback.len(), SCROLLBACK_LIMIT);
5216 screen(&s, 80, 20);
5217 open_search(&mut s, "needle");
5218 s.handle_key(key(KeyCode::Enter));
5219 s.handle_key(key(KeyCode::Char('n')));
5220 assert!(status_row(&s, 80).contains("2/2"), "on the older match");
5221 for i in 0..50 {
5222 s.push_ansi(&format!("new {i}\n"));
5223 }
5224 let row = screen_status(&s);
5225 assert!(
5226 row.contains("search: needle 2/2"),
5227 "still the same match: {row}"
5228 );
5229 s.handle_key(key(KeyCode::Char('n')));
5230 assert!(status_row(&s, 80).contains("1/2"));
5231 s.handle_key(key(KeyCode::Char('n')));
5232 for i in 0..100 {
5233 s.push_ansi(&format!("newer {i}\n"));
5234 }
5235 let row = screen_status(&s);
5237 assert!(row.contains("needle 1/1"), "{row}");
5238 }
5239
5240 fn screen_status(s: &TuiState) -> String {
5241 screen(s, 80, 20);
5242 status_row(s, 80)
5243 }
5244
5245 #[test]
5246 fn test_search_finds_the_live_output_of_a_running_command() {
5247 let _lock = super::super::DIAG_LOCK.blocking_lock();
5248 let (mut s, out) = running_state("market summary USDC --every 2s");
5249 s.push_ansi("needle done\n");
5250 out.write_out(format_args!("needle live\nother\n")).unwrap();
5251 screen(&s, 80, 20);
5252 open_search(&mut s, "needle");
5253 let row = status_row(&s, 140);
5254 assert!(row.contains("search: needle 1/2"), "{row}");
5255 assert!(screen(&s, 80, 20).matches('▶').count() == 2);
5256 }
5257
5258 #[test]
5259 fn test_opening_a_search_keeps_the_top_of_the_view_where_it_was() {
5260 let mut s = state();
5263 for i in 0..40 {
5264 s.push_ansi(&format!("r{i:02} {}\n", "x".repeat(55)));
5265 }
5266 screen(&s, 60, 15);
5267 s.handle_key(key(KeyCode::PageUp));
5268 s.handle_key(key(KeyCode::PageUp));
5269 let top_label = |text: &str| text.lines().nth(1).unwrap()[..3].trim().to_string();
5270 let before = top_label(&screen(&s, 60, 15));
5271 s.handle_key(ctrl('f'));
5272 let during = screen(&s, 60, 15);
5273 assert!(
5274 during.lines().nth(1).unwrap().contains(&before),
5275 "{before} stays at the top: {during}"
5276 );
5277 s.handle_key(key(KeyCode::Esc));
5278 assert_eq!(top_label(&screen(&s, 60, 15)), before);
5279 }
5280
5281 #[test]
5282 fn test_the_status_does_not_offer_pgdn_while_a_search_is_open() {
5283 let mut s = scrollable();
5284 s.handle_key(key(KeyCode::PageUp));
5285 assert!(status_row(&s, 80).contains("PgDn"));
5286 s.handle_key(ctrl('f'));
5287 screen(&s, 60, 15);
5288 let row = status_row(&s, 80);
5289 assert!(row.contains("scrolled up"), "{row}");
5290 assert!(!row.contains("PgDn"), "{row}");
5291 }
5292
5293 #[test]
5294 fn test_search_matches_the_text_of_colored_output_not_its_codes() {
5295 let mut s = state();
5296 s.push_ansi("al\x1b[31mpha\x1b[0m\n");
5297 screen(&s, 80, 20);
5298 open_search(&mut s, "alpha");
5299 assert!(status_row(&s, 80).contains("1/1"));
5300 s.handle_key(key(KeyCode::Esc));
5301 open_search(&mut s, "31m");
5302 assert!(status_row(&s, 80).contains("no match"));
5303 }
5304
5305 fn buffer(s: &TuiState, w: u16, h: u16) -> ratatui::buffer::Buffer {
5308 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
5309 term.draw(|f| s.render(f)).unwrap();
5310 term.backend().buffer().clone()
5311 }
5312
5313 fn row_text(buf: &ratatui::buffer::Buffer, y: u16) -> String {
5314 (0..buf.area.width)
5315 .map(|x| buf[(x, y)].symbol().to_string())
5316 .collect()
5317 }
5318
5319 fn rule_fg(buf: &ratatui::buffer::Buffer) -> Color {
5321 let x = (0..buf.area.width)
5322 .find(|&x| buf[(x, 0)].symbol() == "─")
5323 .expect("a rule cell");
5324 buf[(x, 0)].fg
5325 }
5326
5327 fn failed_state() -> TuiState {
5328 let mut s = state();
5329 s.start("x", &pane_invocation());
5330 s.finish(Ending::Finished(Err(scope::error::ScopeError::Chain(
5331 "boom".into(),
5332 ))));
5333 s
5334 }
5335
5336 #[test]
5337 fn test_pane_rule_is_accent_idle_warn_running_and_err_after_a_failure() {
5338 let idle = state();
5340 assert_eq!(rule_fg(&buffer(&idle, 60, 14)), Color::Cyan);
5341 let (running, _out) = running_state("venues list");
5342 assert_eq!(rule_fg(&buffer(&running, 60, 14)), Color::Yellow);
5343 let failed = failed_state();
5344 assert_eq!(rule_fg(&buffer(&failed, 60, 14)), Color::Red);
5345 }
5346
5347 #[test]
5348 fn test_failed_rule_lasts_until_the_next_command_starts() {
5349 let mut s = failed_state();
5350 assert_eq!(s.pane_state(), PaneState::Failed);
5351 s.start("venues list", &pane_invocation());
5352 assert_eq!(s.pane_state(), PaneState::Running);
5353 s.finish(Ending::Finished(Ok(())));
5354 assert_eq!(s.pane_state(), PaneState::Idle);
5355 }
5356
5357 #[test]
5358 fn test_cancelled_run_leaves_the_rule_idle() {
5359 let mut s = state();
5360 run_pane(&mut s, "x\n", Ending::Cancelled);
5361 assert_eq!(s.pane_state(), PaneState::Idle);
5362 }
5363
5364 #[test]
5365 fn test_state_word_is_at_the_right_end_of_the_top_rule_and_title_stays_left() {
5366 let buf = buffer(&state(), 60, 14);
5367 let top = row_text(&buf, 0);
5368 let title = format!(" scope v{} ", scope::VERSION);
5369 assert!(top.starts_with(&title), "{top:?}");
5370 assert!(top.trim_end().ends_with(" idle"), "{top:?}");
5371 assert!(buf[(1, 0)].modifier.contains(Modifier::BOLD), "title bold");
5372 assert_eq!(buf[(1, 0)].fg, Color::Cyan);
5373 }
5374
5375 #[test]
5376 fn test_prompt_spans_scope_accent_chain_key_rest_default() {
5377 let mut s = state();
5378 s.ctx.chain = "base".into();
5379 let prompt = s.prompt();
5380 assert_eq!(prompt.to_string(), "scope:base> ");
5381 assert_eq!(prompt.spans[0].style, ACCENT);
5382 assert_eq!(prompt.spans[1].style, KEY);
5383 assert_eq!(prompt.spans[2].style, Style::new());
5384 s.push_echo("venues list");
5385 let echo = last_line(&s);
5386 assert_eq!(echo.spans[0].style, ACCENT);
5387 assert_eq!(echo.spans[1].style, KEY);
5388 assert_eq!(echo.spans[3].content, "venues list");
5389 assert_eq!(echo.spans[3].style, Style::new());
5390 }
5391
5392 #[test]
5393 fn test_notes_carry_an_info_glyph_and_dim_text() {
5394 let mut s = state();
5395 s.push_note("hello");
5396 let note = last_line(&s);
5397 assert_eq!(note.to_string(), "ℹ hello");
5398 assert_eq!(note.spans[0].style, INFO);
5399 assert_eq!(note.spans[1].style, SECONDARY);
5400 }
5401
5402 #[test]
5403 fn test_selected_palette_row_is_reversed_cyan() {
5404 let mut s = state();
5405 s.handle_key(ctrl_k());
5406 let buf = buffer(&s, 100, 24);
5407 let selected = buf.content.iter().any(|c| {
5408 c.fg == Color::Cyan && c.modifier.contains(Modifier::REVERSED) && c.symbol() != " "
5409 });
5410 assert!(selected, "a reversed cyan palette row");
5411 let names = buf
5413 .content
5414 .iter()
5415 .any(|c| c.fg == Color::Cyan && !c.modifier.contains(Modifier::REVERSED));
5416 assert!(names);
5417 }
5418
5419 #[test]
5420 fn test_search_match_is_reversed_yellow() {
5421 let mut s = alpha_state();
5422 open_search(&mut s, "alpha");
5423 let buf = buffer(&s, 80, 20);
5424 let hits = buf
5425 .content
5426 .iter()
5427 .filter(|c| c.fg == Color::Yellow && c.modifier.contains(Modifier::REVERSED))
5428 .count();
5429 assert!(hits >= 2 * "alpha".len(), "{hits} matched cells");
5430 }
5431
5432 #[test]
5433 fn test_status_values_are_accent_and_labels_secondary() {
5434 let line = fit_status(&state().status_parts(), 100);
5435 let find = |text: &str| {
5436 line.spans
5437 .iter()
5438 .find(|sp| sp.content.contains(text))
5439 .unwrap_or_else(|| panic!("no span with {text:?}"))
5440 };
5441 assert_eq!(find("chain").style, SECONDARY);
5442 assert_eq!(find("auto").style, ACCENT);
5443 assert_eq!(find("format").style, SECONDARY);
5444 assert_eq!(find("help").style, ACCENT);
5445 }
5446
5447 #[test]
5448 fn test_running_status_is_warn_and_esc_cancels_is_bold() {
5449 let (s, _out) = running_state("venues list");
5450 let line = fit_status(&s.status_parts(), 120);
5451 let find = |text: &str| {
5452 line.spans
5453 .iter()
5454 .find(|sp| sp.content.contains(text))
5455 .unwrap_or_else(|| panic!("no span with {text:?}"))
5456 };
5457 assert_eq!(find("running").style, WARN);
5458 assert_eq!(
5459 find("Esc cancels").style,
5460 Style::new().add_modifier(Modifier::BOLD)
5461 );
5462 assert_eq!(find("`venues list`").style, ACCENT);
5463 }
5464
5465 #[test]
5466 fn test_hint_next_argument_is_a_key_and_busy_note_is_warn() {
5467 let mut s = state();
5468 type_str(&mut s, "address");
5469 let buf = buffer(&s, 100, 14);
5470 let hint_row = 14 - 2;
5471 let next = (0..100u16)
5472 .rfind(|&x| buf[(x, hint_row)].symbol() == "<")
5473 .unwrap();
5474 assert_eq!(buf[(next, hint_row)].fg, Color::Magenta);
5475 assert!(buf[(next, hint_row)].modifier.contains(Modifier::BOLD));
5476 let (mut busy, _out) = running_state("venues list");
5477 busy.busy_hint = true;
5478 let buf = buffer(&busy, 100, 14);
5479 let x = (0..100u16)
5480 .find(|&x| buf[(x, hint_row)].symbol() == "A")
5481 .unwrap();
5482 assert_eq!(buf[(x, hint_row)].fg, Color::Yellow);
5483 }
5484
5485 #[test]
5486 fn test_with_color_off_every_state_still_differs_by_text() {
5487 let strip = |s: &TuiState| {
5489 let mut buf = buffer(s, 60, 14);
5490 crate::cli::plain::strip_color(&mut buf);
5491 assert!(buf.content.iter().all(|c| c.fg == Color::Reset));
5492 buf
5493 };
5494 let idle = row_text(&strip(&state()), 0);
5495 let (running, _out) = running_state("venues list");
5496 let running = row_text(&strip(&running), 0);
5497 let failed = row_text(&strip(&failed_state()), 0);
5498 assert!(idle.trim_end().ends_with(" idle"), "{idle:?}");
5499 assert!(running.trim_end().ends_with(" running"), "{running:?}");
5500 assert!(failed.trim_end().ends_with(" failed"), "{failed:?}");
5501 let mut s = state();
5503 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
5504 assert!(last_line(&s).to_string().starts_with("✓ Done in "));
5505 let failed = failed_state();
5506 assert!(last_line(&failed).to_string().starts_with("✗ Failed in "));
5507 assert!(
5509 last_line(&failed)
5510 .style
5511 .add_modifier
5512 .contains(Modifier::BOLD)
5513 );
5514 let buf = strip(&failed);
5515 assert!(
5516 buf.content
5517 .iter()
5518 .any(|c| c.symbol() == "✗" && c.modifier.contains(Modifier::BOLD))
5519 );
5520 }
5521
5522 #[test]
5523 fn test_ctrl_k_and_tab_open_no_overlay_while_the_terminal_is_too_small() {
5524 let mut s = state();
5526 type_str(&mut s, "m");
5527 screen(&s, 80, 24);
5528 s.handle_key(key(KeyCode::Tab));
5529 assert!(
5530 matches!(s.overlay, Some(Overlay::Menu(_))),
5531 "Tab offers a menu at a normal size"
5532 );
5533 s.handle_key(key(KeyCode::Esc));
5534 screen(&s, 30, 8);
5535 s.handle_key(key(KeyCode::Tab));
5536 assert!(s.overlay.is_none());
5537 assert_eq!(s.input, "m", "Tab did not complete unseen");
5538 s.handle_key(ctrl('k'));
5539 assert!(s.overlay.is_none());
5540 }
5541
5542 #[test]
5543 fn test_status_styles_come_from_the_parts_not_from_their_text() {
5544 let mut s = alpha_state();
5547 s.push_ansi("x · running\n");
5548 open_search(&mut s, "x · running");
5549 let line = fit_status(&s.status_parts(), 120);
5550 let at = |text: &str| {
5551 line.spans
5552 .iter()
5553 .position(|sp| sp.content.contains(text))
5554 .unwrap_or_else(|| panic!("no {text:?} in {line:?}"))
5555 };
5556 for sp in &line.spans[at("search:")..=at("1/1")] {
5557 assert_eq!(sp.style, ACCENT, "{sp:?} in {line:?}");
5558 }
5559 let (run, _out) = running_state("venues list");
5562 let line = fit_status(&run.status_parts(), 120);
5563 let style_of = |text: &str| {
5564 line.spans
5565 .iter()
5566 .find(|sp| sp.content == text)
5567 .unwrap_or_else(|| panic!("no span {text:?} in {line:?}"))
5568 .style
5569 };
5570 assert_eq!(
5571 style_of("Esc cancels"),
5572 Style::new().add_modifier(Modifier::BOLD)
5573 );
5574 assert_eq!(style_of(STATUS_SEP), SECONDARY);
5575 assert_eq!(style_of("`venues list`"), ACCENT);
5576 }
5577
5578 #[test]
5579 fn test_help_shows_key_names_in_the_key_style() {
5580 let mut s = state();
5581 assert!(matches!(s.submit("help", &Config::default()), Plan::Done));
5582 let span = s
5583 .scrollback
5584 .iter()
5585 .flat_map(|l| l.spans.iter())
5586 .find(|sp| sp.content == "Ctrl-K")
5587 .expect("help names Ctrl-K");
5588 assert_eq!(span.style.fg, Some(Color::Magenta));
5589 assert!(span.style.add_modifier.contains(Modifier::BOLD));
5590 assert_eq!(span.style, Style::reset().patch(KEY));
5593 let what = s
5594 .scrollback
5595 .iter()
5596 .flat_map(|l| l.spans.iter())
5597 .find(|sp| sp.content == "palette")
5598 .expect("help says what Ctrl-K does");
5599 assert_eq!(what.style, Style::reset().patch(SECONDARY));
5600 }
5601
5602 #[test]
5603 fn test_any_accepted_line_clears_the_failed_state() {
5604 for line in ["ctx", "format json", "setup"] {
5607 let mut s = failed_state();
5608 assert_eq!(s.pane_state(), PaneState::Failed);
5609 s.submit(line, &Config::default());
5610 assert_eq!(s.pane_state(), PaneState::Idle, "{line}");
5611 }
5612 let mut s = failed_state();
5614 s.submit(" ", &Config::default());
5615 assert_eq!(s.pane_state(), PaneState::Failed);
5616 }
5617}