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 tokens: Vec::new(),
3058 }));
3059 s.finish(Ending::Finished(Ok(())));
3060 let shown = s.scrollback_text();
3061 assert!(shown.contains("USDC/USDT · binance"), "{shown}");
3062 assert!(!shown.contains("CLI REPORT"), "{shown}");
3063 assert!(shown.contains("Using the saved token choice"), "{shown}");
3064 assert_eq!(
3065 s.last_output.as_deref(),
3066 Some(
3067 "CLI REPORT
3068Bid Side — 20 levels
3069"
3070 )
3071 );
3072 }
3073
3074 #[test]
3075 fn test_a_repeat_run_draws_the_view_not_one_report_per_run() {
3076 let _lock = super::super::DIAG_LOCK.blocking_lock();
3077 let mut s = state();
3078 repeating(&mut s, 3, 2);
3079 let text = screen(&s, 120, 36);
3080 assert!(text.contains("SNAPSHOT 3"), "{text}");
3081 assert!(!text.contains("SNAPSHOT 1"), "{text}");
3082 assert!(text.contains("Trends · run 3"), "{text}");
3083 assert!(text.contains("run 2: spread 2%"), "{text}");
3084 s.finish(Ending::Cancelled);
3085 }
3086
3087 #[test]
3088 fn test_the_view_stays_after_esc_and_the_next_key_leaves_it_once() {
3089 let _lock = super::super::DIAG_LOCK.blocking_lock();
3090 let mut s = state();
3091 repeating(&mut s, 3, 0);
3092 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
3093 s.finish(Ending::Cancelled);
3094 let text = screen(&s, 120, 36);
3095 assert!(text.contains("Cancelled after"), "{text}");
3096 assert!(text.contains(repeat_view::LEAVE_HINT), "{text}");
3097 assert!(!s.scrollback_text().contains("SNAPSHOT"), "not yet pushed");
3098 assert_eq!(s.handle_key(key(KeyCode::Char('x'))), Action::None);
3100 assert_eq!(s.input, "x");
3101 let back = s.scrollback_text();
3102 assert_eq!(back.matches("SNAPSHOT 3").count(), 1, "{back}");
3103 assert!(!back.contains("SNAPSHOT 1"), "{back}");
3104 assert!(back.contains("3 runs · none failed"), "{back}");
3105 assert!(back.contains("Cancelled after"), "{back}");
3106 assert!(!screen(&s, 120, 36).contains("Trends"), "the view is gone");
3107 }
3108
3109 #[test]
3110 fn test_copy_and_save_take_the_final_snapshot_and_the_series() {
3111 let _lock = super::super::DIAG_LOCK.blocking_lock();
3112 let mut s = state();
3113 repeating(&mut s, 2, 0);
3114 s.finish(Ending::Finished(Ok(())));
3115 assert_eq!(s.last_output.as_deref(), Some("SNAPSHOT 2\n"));
3116 let csv = s
3117 .last_series
3118 .as_ref()
3119 .and_then(repeat_view::series_csv)
3120 .unwrap();
3121 assert_eq!(csv.lines().count(), 3, "header and two runs: {csv}");
3122 }
3123
3124 fn alt(c: char) -> KeyEvent {
3125 KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)
3126 }
3127
3128 #[test]
3129 fn test_readline_word_moves_and_kill_to_end() {
3130 let mut s = state();
3131 type_str(&mut s, "market summary USDC");
3132 s.handle_key(alt('b'));
3133 assert_eq!(s.cursor, 15, "Alt-B: start of USDC");
3134 s.handle_key(alt('b'));
3135 assert_eq!(s.cursor, 7, "Alt-B: start of summary");
3136 s.handle_key(alt('f'));
3137 assert_eq!(s.cursor, 14, "Alt-F: end of summary");
3138 s.handle_key(ctrl('b'));
3139 assert_eq!(s.cursor, 13, "Ctrl-B: one left");
3140 s.handle_key(alt('k'));
3141 assert_eq!(s.input, "market summar", "Alt-K: deletes to the end");
3142 }
3143
3144 #[test]
3145 fn test_up_brings_back_a_line_that_esc_cleared() {
3146 let mut s = state();
3147 type_str(&mut s, "market summary USDC --venue binance --every 30s");
3148 s.handle_key(key(KeyCode::Esc));
3149 assert_eq!(s.input, "");
3150 s.handle_key(key(KeyCode::Up));
3151 assert_eq!(s.input, "market summary USDC --venue binance --every 30s");
3152 }
3153
3154 #[test]
3155 fn test_clear_empties_the_pane_and_reset_resets_the_context() {
3156 let mut s = state();
3157 s.push_note("old output");
3158 s.ctx.limit = 7;
3159 s.submit("clear", &Config::default());
3160 assert!(!s.scrollback_text().contains("old output"));
3161 assert_eq!(s.ctx.limit, 7, "clear leaves the context");
3162 s.submit("reset", &Config::default());
3163 assert_eq!(s.ctx.limit, SessionContext::default().limit);
3164 }
3165
3166 #[test]
3167 fn test_the_pane_says_when_it_dropped_old_lines() {
3168 let mut s = state();
3169 let big: String = (0..SCROLLBACK_LIMIT + 7)
3170 .map(|i| format!("l{i}\n"))
3171 .collect();
3172 s.push_ansi(&big);
3173 assert!(s.dropped_lines > 0);
3174 let rows = 200;
3175 s.scroll.set(usize::MAX / 2);
3176 let text = screen(&s, 100, rows);
3177 assert!(text.contains("older lines dropped"), "{text}");
3178 }
3179
3180 #[test]
3181 fn test_ctrl_c_in_palette_still_cancels_a_running_command() {
3182 let _lock = super::super::DIAG_LOCK.blocking_lock();
3184 let mut s = state();
3185 let inv = Invocation {
3186 command: venues_list(),
3187 route: Route::Pane,
3188 ai: false,
3189 argv: vec![],
3190 };
3191 s.start("venues list", &inv);
3192 s.handle_key(ctrl_k());
3193 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
3194 assert!(s.overlay.is_none(), "Ctrl-C also closes the palette");
3195 s.finish(Ending::Cancelled);
3196 }
3197
3198 #[test]
3199 fn test_palette_escape_keeps_the_input() {
3200 let mut s = state();
3201 type_str(&mut s, "draft");
3202 s.handle_key(ctrl_k());
3203 type_str(&mut s, "zzz");
3204 s.handle_key(key(KeyCode::Esc));
3205 assert!(s.overlay.is_none());
3206 assert_eq!(s.input, "draft");
3207 }
3208
3209 #[test]
3210 fn test_palette_with_long_history_renders_bounded() {
3211 let mut s = state();
3213 for i in 0..60 {
3214 type_str(&mut s, &format!("venues list {}", i));
3215 s.handle_key(key(KeyCode::Enter));
3216 }
3217 s.handle_key(ctrl_k());
3218 for _ in 0..30 {
3219 s.handle_key(key(KeyCode::Down));
3220 }
3221 screen(&s, 80, 20);
3222 screen(&s, 10, 5);
3223 }
3224
3225 #[test]
3226 fn test_editing_keys() {
3227 let mut s = state();
3228 type_str(&mut s, "adress");
3229 s.handle_key(key(KeyCode::Left));
3230 s.handle_key(key(KeyCode::Left));
3231 s.handle_key(key(KeyCode::Left));
3232 s.handle_key(key(KeyCode::Left));
3233 type_str(&mut s, "d");
3234 assert_eq!(s.input, "address");
3235 s.handle_key(key(KeyCode::End));
3236 type_str(&mut s, " 0xab cd");
3237 s.handle_key(ctrl('w'));
3238 assert_eq!(s.input, "address 0xab ");
3239 s.handle_key(key(KeyCode::Backspace));
3240 s.handle_key(ctrl('u'));
3241 assert_eq!(s.input, "");
3242 }
3243
3244 #[test]
3245 fn test_cursor_keys_and_idle_escape() {
3246 let mut s = state();
3247 type_str(&mut s, "bc");
3248 s.handle_key(key(KeyCode::Home));
3249 type_str(&mut s, "a");
3250 s.handle_key(ctrl('e'));
3251 type_str(&mut s, "d");
3252 assert_eq!(s.input, "abcd");
3253 s.handle_key(ctrl('a'));
3254 s.handle_key(key(KeyCode::Delete));
3255 assert_eq!(s.input, "bcd");
3256 s.handle_key(key(KeyCode::End));
3257 s.handle_key(key(KeyCode::Delete));
3258 assert_eq!(s.input, "bcd", "Delete at the end does nothing");
3259 s.handle_key(key(KeyCode::Right));
3260 type_str(&mut s, "e");
3261 assert_eq!(s.input, "bcde");
3262 s.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT));
3264 assert_eq!(s.input, "bcde");
3265 s.handle_key(key(KeyCode::Esc));
3266 assert_eq!(s.input, "", "Esc clears the line when idle");
3267 s.handle_key(ctrl('c'));
3269 assert!(s.scrollback_text().contains("Ctrl-D to leave"));
3270 s.handle_key(key(KeyCode::Up));
3272 assert_eq!(s.input, "bcde");
3273 s.cleared = None;
3275 s.set_input(String::new());
3276 s.handle_key(key(KeyCode::Up));
3277 s.handle_key(key(KeyCode::Down));
3278 assert_eq!(s.input, "");
3279 s.handle_key(key(KeyCode::PageDown));
3280 }
3281
3282 #[test]
3283 fn test_multibyte_input_does_not_panic() {
3284 let mut s = state();
3285 type_str(&mut s, "tokens add €UR");
3286 s.handle_key(key(KeyCode::Left));
3287 s.handle_key(key(KeyCode::Left));
3288 s.handle_key(key(KeyCode::Backspace));
3289 assert_eq!(s.input, "tokens add UR");
3290 }
3291
3292 #[test]
3297 fn test_line_feed_from_cooked_mode_submits_like_enter() {
3298 for lf in [ctrl('j'), key(KeyCode::Char('\n'))] {
3299 let mut s = state();
3300 type_str(&mut s, "ctx");
3301 assert_eq!(s.handle_key(lf), Action::Submit("ctx".into()), "{lf:?}");
3302 assert_eq!(s.input, "");
3303 }
3304 }
3305
3306 #[test]
3309 fn test_line_feed_picks_palette_entry_like_enter() {
3310 let mut s = state();
3311 s.handle_key(ctrl('k'));
3312 s.handle_key(ctrl('j'));
3313 assert!(s.overlay.is_none());
3314 assert!(!s.input.contains('\n'));
3315 }
3316
3317 #[test]
3318 fn test_enter_submits_and_records_history() {
3319 let mut s = state();
3320 type_str(&mut s, "venues list");
3321 assert_eq!(
3322 s.handle_key(key(KeyCode::Enter)),
3323 Action::Submit("venues list".into())
3324 );
3325 assert_eq!(s.input, "");
3326 type_str(&mut s, "venues list");
3327 s.handle_key(key(KeyCode::Enter));
3328 assert_eq!(s.history, vec!["venues list"]);
3330 }
3331
3332 #[test]
3333 fn test_history_browsing_restores_draft() {
3334 let mut s = state();
3335 for cmd in ["one", "two"] {
3336 type_str(&mut s, cmd);
3337 s.handle_key(key(KeyCode::Enter));
3338 }
3339 type_str(&mut s, "draft");
3340 s.handle_key(key(KeyCode::Up));
3341 assert_eq!(s.input, "two");
3342 s.handle_key(key(KeyCode::Up));
3343 s.handle_key(key(KeyCode::Up));
3344 assert_eq!(s.input, "one");
3345 s.handle_key(key(KeyCode::Down));
3346 assert_eq!(s.input, "two");
3347 s.handle_key(key(KeyCode::Down));
3348 assert_eq!(s.input, "draft");
3349 }
3350
3351 #[test]
3352 fn test_ctrl_z_suspends_and_keeps_the_draft_and_the_palette() {
3353 let mut s = state();
3356 type_str(&mut s, "draf");
3357 assert_eq!(s.handle_key(ctrl('z')), Action::Suspend);
3358 assert_eq!(s.input, "draf");
3359 s.handle_key(ctrl('k'));
3360 assert_eq!(s.handle_key(ctrl('z')), Action::Suspend);
3361 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
3362 }
3363
3364 #[test]
3365 fn test_ctrl_c_and_esc_cancel_only_when_running() {
3366 let _lock = super::super::DIAG_LOCK.blocking_lock();
3367 let mut s = state();
3368 type_str(&mut s, "abc");
3369 assert_eq!(s.handle_key(ctrl('c')), Action::None);
3370 assert_eq!(s.input, "", "Ctrl-C clears the line when idle");
3371 assert_eq!(s.handle_key(ctrl('d')), Action::Exit);
3372
3373 let inv = Invocation {
3374 command: venues_list(),
3375 route: Route::Pane,
3376 ai: false,
3377 argv: vec![],
3378 };
3379 s.start("venues list", &inv);
3380 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
3381 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
3382 assert_eq!(
3383 s.handle_key(ctrl('d')),
3384 Action::None,
3385 "no exit while running"
3386 );
3387 type_str(&mut s, "x");
3388 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
3389 assert_eq!(s.input, "x", "the next line waits for the command to end");
3390 s.finish(Ending::Cancelled);
3391 assert!(s.scrollback_text().contains("Cancelled after"));
3392 }
3393
3394 #[test]
3395 fn test_session_command_output_reaches_scrollback() {
3396 let mut s = state();
3397 let plan = s.submit("chain base", &Config::default());
3398 assert!(matches!(plan, Plan::Done));
3399 let text = s.scrollback_text();
3400 assert!(text.contains("scope:auto> chain base"));
3401 assert!(text.contains("Chain pinned to: base"));
3402 assert_eq!(s.ctx.chain, "base");
3403 }
3404
3405 #[test]
3406 fn test_ansi_colors_become_styles_not_escape_codes() {
3407 let mut s = state();
3408 s.push_ansi("\x1b[31mred\x1b[0m plain\n");
3409 assert_eq!(s.scrollback_text(), "red plain\n");
3410 let first = &s.scrollback[0].spans[0];
3411 assert_eq!(first.style.fg, Some(Color::Red));
3412 }
3413
3414 #[test]
3415 fn test_scrollback_is_capped() {
3416 let mut s = state();
3417 let big: String = (0..SCROLLBACK_LIMIT + 50)
3418 .map(|i| format!("l{}\n", i))
3419 .collect();
3420 s.push_ansi(&big);
3421 assert_eq!(s.scrollback.len(), SCROLLBACK_LIMIT);
3422 assert!(s.scrollback_text().starts_with("l50\n"));
3423 }
3424
3425 #[test]
3426 fn test_render_shows_latest_output_prompt_and_status() {
3427 let mut s = state();
3428 for i in 0..40 {
3429 s.push_ansi(&format!("line {}\n", i));
3430 }
3431 type_str(&mut s, "venues");
3432 let text = screen(&s, 60, 15);
3433 assert!(text.contains("line 39"), "{}", text);
3434 assert!(!text.contains("line 0 "), "{}", text);
3435 assert!(text.contains("scope:auto>"));
3436 assert!(text.contains("venues"));
3437 assert!(text.contains("chain auto"));
3438
3439 s.handle_key(key(KeyCode::PageUp));
3440 let text = screen(&s, 60, 15);
3441 assert!(!text.contains("line 39"), "{}", text);
3442 assert!(text.contains("scrolled up 7 lines"), "{}", text);
3444 }
3445
3446 fn status_row(s: &TuiState, w: u16) -> String {
3449 let line = fit_status(&s.status_parts(), w as usize);
3450 let text: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
3451 format!("{:<width$}", text, width = w as usize)
3452 }
3453
3454 fn running_state(input: &str) -> (TuiState, Output) {
3455 let mut s = state();
3456 let inv = Invocation {
3457 command: venues_list(),
3458 route: Route::Pane,
3459 ai: false,
3460 argv: vec![],
3461 };
3462 let out = s.start(input, &inv);
3463 (s, out)
3464 }
3465
3466 fn status_text(s: &TuiState) -> String {
3468 let line = fit_status(&s.status_parts(), 160);
3469 line.spans.iter().map(|sp| sp.content.as_ref()).collect()
3470 }
3471
3472 #[test]
3473 fn test_esc_hint_says_what_esc_does_with_each_overlay_open() {
3474 let _lock = super::super::DIAG_LOCK.blocking_lock();
3477 type Open = fn(&mut TuiState);
3478 let opens: [(&str, Open); 4] = [
3479 ("Esc closes palette", |s| {
3480 s.handle_key(ctrl('k'));
3481 }),
3482 ("Esc closes menu", |s| {
3483 type_str(s, "ad");
3484 s.handle_key(key(KeyCode::Tab));
3485 }),
3486 ("Esc closes search", |s| {
3487 s.handle_key(ctrl('f'));
3488 }),
3489 ("Esc closes history search", |s| {
3490 s.handle_key(ctrl('r'));
3491 }),
3492 ];
3493 for (hint, open) in opens {
3494 let mut s = vocab_state();
3495 let inv = Invocation {
3496 command: venues_list(),
3497 route: Route::Pane,
3498 ai: false,
3499 argv: vec![],
3500 };
3501 let _out = s.start("venues list", &inv);
3502 screen(&s, 80, 24);
3503 open(&mut s);
3504 assert!(s.overlay.is_some(), "{hint}: no overlay");
3505 let bar = status_text(&s);
3506 assert!(bar.contains(hint), "{hint}: {bar:?}");
3507 assert!(!bar.contains("Esc cancels"), "{hint}: {bar:?}");
3508 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::None, "{hint}");
3511 assert!(s.overlay.is_none(), "{hint}");
3512 assert!(status_text(&s).contains("Esc cancels"), "{hint}");
3513 }
3514 }
3515
3516 #[test]
3517 fn test_idle_esc_hint_shows_only_while_an_overlay_is_open() {
3518 let mut s = state();
3519 assert!(!status_text(&s).contains("Esc"), "{}", status_text(&s));
3520 screen(&s, 80, 24);
3521 s.handle_key(ctrl('k'));
3522 assert!(status_text(&s).contains("Esc closes palette"));
3523 s.handle_key(key(KeyCode::Esc));
3524 assert!(status_text(&s).contains("help"), "{}", status_text(&s));
3525 }
3526
3527 #[test]
3528 fn test_trimmed_outside_output_is_not_kept_for_copy_or_save() {
3529 let mut s = state();
3532 let (o, done) = Output::capture_merged();
3533 crate::outln!(o, "earlier data").unwrap();
3534 s.show_outside(&done, &Ending::Finished(Ok(())));
3535 assert_eq!(s.last_output.as_deref(), Some("earlier data\n"));
3536 let (terminal, _screen) = Output::capture();
3537 let (o, long) = terminal.teed(64);
3538 for i in 0..100 {
3539 crate::outln!(o, "row {i}").unwrap();
3540 }
3541 assert!(long.trimmed());
3542 s.show_outside(&long, &Ending::Finished(Ok(())));
3543 assert_eq!(s.last_output, None, "neither the part nor the old copy");
3544 assert!(
3545 s.scrollback_text()
3546 .contains("only the end is kept here, and Ctrl-Y and save do not have it."),
3547 "{}",
3548 s.scrollback_text()
3549 );
3550 assert!(matches!(s.handle_key(ctrl('y')), Action::None));
3551 assert!(s.scrollback_text().contains("nothing to copy yet"));
3552 }
3553
3554 #[test]
3555 fn test_an_outside_run_that_prompted_keeps_only_its_data_for_copy() {
3556 let mut s = state();
3559 let (terminal, _screen) = Output::capture();
3560 let (o, copy) = terminal.teed(1 << 20);
3561 let pick = crate::cli::prompt::pick(
3562 &mut std::io::Cursor::new(b"1\n".to_vec()),
3563 &o,
3564 "1 USDC\n",
3565 "token",
3566 1,
3567 );
3568 assert_eq!(pick.unwrap(), 0);
3569 crate::cli::prompt::ask(
3570 &mut std::io::Cursor::new(b"n\n".to_vec()),
3571 &o,
3572 "Save this token for future use? [y/N]: ",
3573 )
3574 .unwrap();
3575 crate::outln!(o, "report").unwrap();
3576 s.show_outside(©, &Ending::Finished(Ok(())));
3577 assert_eq!(s.last_output.as_deref(), Some("report\n"));
3578 let shown = s.scrollback_text();
3579 assert!(
3580 shown.contains("Select token (1-1)"),
3581 "the user still sees it"
3582 );
3583 }
3584
3585 #[test]
3586 fn test_a_failed_outside_command_sets_the_failed_pane_state() {
3587 let mut s = state();
3589 let err = Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into())));
3590 s.record_suspended(
3591 "crawl USDC",
3592 &[],
3593 std::time::Duration::from_secs(1),
3594 String::new(),
3595 &err,
3596 );
3597 assert_eq!(s.pane_state(), PaneState::Failed);
3598 for ok in [Ending::Finished(Ok(())), Ending::Cancelled] {
3599 s.submit("crawl USDC", &Config::default());
3600 s.record_suspended(
3601 "crawl USDC",
3602 &[],
3603 std::time::Duration::from_secs(1),
3604 String::new(),
3605 &ok,
3606 );
3607 assert_eq!(s.pane_state(), PaneState::Idle);
3608 }
3609 }
3610
3611 #[test]
3612 fn test_status_bar_keeps_the_running_state_at_every_width() {
3613 let _lock = super::super::DIAG_LOCK.blocking_lock();
3616 let (s, _out) = running_state("market summary USDC --venue binance --every 2s");
3617 for w in [120, 60, 40, 30] {
3618 let row = status_row(&s, w);
3619 assert!(row.contains("running "), "{w}: {row:?}");
3620 assert!(row.contains("Esc cancels"), "{w}: {row:?}");
3621 }
3622 let wide = status_row(&s, 120);
3623 assert!(
3624 wide.contains("limit") && wide.contains("format"),
3625 "{wide:?}"
3626 );
3627 assert!(wide.contains("market summary USDC"), "{wide:?}");
3628 }
3629
3630 #[test]
3631 fn test_status_bar_drops_limit_and_format_before_the_cancel_hint() {
3632 let _lock = super::super::DIAG_LOCK.blocking_lock();
3633 let (s, _out) = running_state("market summary USDC --venue binance --every 2s");
3634 let row = status_row(&s, 40);
3635 assert!(!row.contains("limit"), "{row:?}");
3636 assert!(!row.contains("format"), "{row:?}");
3637 assert!(row.contains('…'), "{row:?}");
3639 }
3640
3641 #[test]
3642 fn test_idle_status_bar_keeps_help_at_a_narrow_width() {
3643 let s = state();
3644 let row = status_row(&s, 30);
3645 assert!(row.contains("help"), "{row:?}");
3646 assert!(!row.contains("limit"), "{row:?}");
3647 }
3648
3649 #[test]
3650 fn test_scrolled_status_bar_keeps_the_scroll_hint_at_a_narrow_width() {
3651 let mut s = state();
3652 for i in 0..40 {
3653 s.push_ansi(&format!("line {}\n", i));
3654 }
3655 screen(&s, 40, 12);
3656 s.handle_key(key(KeyCode::PageUp));
3657 let row = status_row(&s, 40);
3658 assert!(row.contains("scrolled up 4 lines"), "{row:?}");
3659 }
3660
3661 fn scrollable() -> TuiState {
3663 let mut s = state();
3664 for i in 0..30 {
3665 s.push_ansi(&format!("line {}\n", i));
3666 }
3667 screen(&s, 60, 15);
3668 s
3669 }
3670
3671 #[test]
3672 fn test_page_up_stops_at_the_top_so_page_down_always_moves() {
3673 let mut s = scrollable();
3674 for _ in 0..50 {
3675 s.handle_key(key(KeyCode::PageUp));
3676 }
3677 let top = screen(&s, 60, 15);
3678 assert!(top.contains("scrolled up 22 lines"), "{top}");
3679 s.handle_key(key(KeyCode::PageDown));
3680 let after = screen(&s, 60, 15);
3681 assert_ne!(top, after, "PageDown must change the view");
3682 assert!(after.contains("scrolled up 15 lines"), "{after}");
3683 }
3684
3685 #[test]
3686 fn test_ctrl_home_and_ctrl_end_jump_to_the_top_and_the_bottom() {
3687 let mut s = scrollable();
3688 s.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL));
3689 assert!(screen(&s, 60, 15).contains("line 0"));
3690 assert!(screen(&s, 60, 15).contains("scrolled up 22 lines"));
3691 s.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL));
3692 let text = screen(&s, 60, 15);
3693 assert!(text.contains("line 29"), "{text}");
3694 assert!(!text.contains("scrolled up"), "{text}");
3695 }
3696
3697 #[test]
3698 fn test_shift_arrows_scroll_one_line_and_plain_arrows_keep_the_history() {
3699 let mut s = scrollable();
3700 s.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT));
3701 s.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT));
3702 assert!(screen(&s, 60, 15).contains("scrolled up 2 lines"));
3703 s.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
3704 assert!(screen(&s, 60, 15).contains("scrolled up 1 lines"));
3705 s.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
3707 s.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
3708 assert!(!screen(&s, 60, 15).contains("scrolled up"));
3709 s.history.push("venues list".into());
3711 s.handle_key(key(KeyCode::Up));
3712 assert_eq!(s.input, "venues list");
3713 }
3714
3715 #[test]
3716 fn test_the_wheel_scrolls_three_lines_and_stops_at_the_top() {
3717 let mut s = scrollable();
3718 s.wheel(3);
3719 assert!(screen(&s, 60, 15).contains("scrolled up 3 lines"));
3720 for _ in 0..20 {
3721 s.wheel(3);
3722 }
3723 assert!(screen(&s, 60, 15).contains("scrolled up 22 lines"));
3724 s.wheel(-3);
3725 assert!(screen(&s, 60, 15).contains("scrolled up 19 lines"));
3726 s.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
3728 assert!(matches!(s.overlay, Some(Overlay::Palette(_))));
3729 s.wheel(3);
3730 assert_eq!(s.scroll.get(), 19);
3731 }
3732
3733 #[test]
3734 fn test_help_opens_at_its_first_line() {
3735 let mut s = scrollable();
3736 let config = scope::config::Config::default();
3737 let _ = s.submit("help", &config);
3738 let text = screen(&s, 60, 15);
3739 assert!(text.contains("Session commands:"), "{text}");
3740 assert!(text.contains("scrolled up"), "the rest is below: {text}");
3741 s.handle_key(key(KeyCode::PageDown));
3743 assert!(!screen(&s, 60, 15).contains("Session commands:"));
3744 }
3745
3746 #[test]
3747 fn test_the_hint_row_shows_the_navigation_keys_that_fit() {
3748 let mut s = scrollable();
3749 let text = screen(&s, 120, 15);
3750 assert!(text.contains("PgUp/wheel/Shift-↑ scroll back"), "{text}");
3751 assert!(text.contains("↑↓ history"), "{text}");
3752 assert!(text.contains("Ctrl-K commands"), "{text}");
3753 s.handle_key(key(KeyCode::PageUp));
3755 let text = screen(&s, 120, 15);
3756 assert!(text.contains("PgDn/wheel/Shift-↓ down"), "{text}");
3757 assert!(text.contains("Ctrl-End bottom"), "{text}");
3758 let line = nav_hints(&s.nav_keys(), 40);
3760 let shown: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
3761 assert!(UnicodeWidthStr::width(shown.as_str()) <= 40, "{shown:?}");
3762 assert!(
3763 shown.ends_with("down") || shown.ends_with("bottom"),
3764 "{shown:?}"
3765 );
3766 type_str(&mut s, "market summary");
3768 assert!(!screen(&s, 120, 15).contains("↑↓ history"));
3769 }
3770
3771 #[test]
3772 fn test_end_on_an_empty_input_jumps_to_the_bottom_else_moves_the_cursor() {
3773 let mut s = scrollable();
3774 s.handle_key(key(KeyCode::PageUp));
3775 s.handle_key(key(KeyCode::End));
3776 assert_eq!(s.scroll.get(), 0);
3777 type_str(&mut s, "abc");
3778 s.handle_key(key(KeyCode::Home));
3779 s.handle_key(key(KeyCode::PageUp));
3780 s.handle_key(key(KeyCode::End));
3781 assert_eq!(s.cursor, 3, "End moves the cursor on a non-empty input");
3782 assert_eq!(s.scroll.get(), 7, "and leaves the view alone");
3783 }
3784
3785 #[test]
3786 fn test_keys_that_edit_the_input_snap_the_view_to_the_bottom() {
3787 for edit in ["char", "backspace", "delete", "paste"] {
3788 let mut s = scrollable();
3789 type_str(&mut s, "ab");
3790 s.handle_key(key(KeyCode::Left));
3791 s.handle_key(key(KeyCode::PageUp));
3792 assert!(s.scroll.get() > 0, "{edit}");
3793 match edit {
3794 "char" => drop(s.handle_key(key(KeyCode::Char('a')))),
3795 "backspace" => drop(s.handle_key(key(KeyCode::Backspace))),
3796 "delete" => drop(s.handle_key(key(KeyCode::Delete))),
3797 _ => s.paste("x"),
3798 }
3799 assert_eq!(s.scroll.get(), 0, "{edit}");
3800 }
3801 }
3802
3803 #[test]
3804 fn test_navigation_keys_and_modifiers_alone_do_not_snap_the_view() {
3805 let mut s = scrollable();
3806 s.handle_key(key(KeyCode::PageUp));
3807 for k in [
3808 key(KeyCode::Left),
3809 key(KeyCode::Right),
3810 key(KeyCode::Home),
3811 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
3812 key(KeyCode::Tab),
3813 ] {
3814 s.handle_key(k);
3815 assert_eq!(s.scroll.get(), 7, "{k:?}");
3816 }
3817 }
3818
3819 #[test]
3820 fn test_status_shows_the_running_state_and_the_scroll_state_together() {
3821 let _lock = super::super::DIAG_LOCK.blocking_lock();
3822 let (mut s, _out) = running_state("market summary USDC --every 2s");
3823 for i in 0..40 {
3824 s.push_ansi(&format!("line {}\n", i));
3825 }
3826 screen(&s, 120, 12);
3827 s.handle_key(key(KeyCode::PageUp));
3828 let row = status_row(&s, 120);
3829 assert!(row.contains("running "), "{row:?}");
3830 assert!(row.contains("scrolled up 4 lines"), "{row:?}");
3831 let row = status_row(&s, 40);
3833 assert!(row.contains("Esc cancels"), "{row:?}");
3834 }
3835
3836 const TOO_SMALL: &str = "Terminal too small: need at least 40×10";
3837
3838 #[test]
3839 fn test_the_too_small_text_is_the_spec_text() {
3840 assert_eq!(too_small_note(), TOO_SMALL);
3841 }
3842
3843 #[test]
3844 fn test_below_the_minimum_size_only_the_too_small_line_is_drawn() {
3845 let mut s = state();
3846 s.push_ansi("some output\n");
3847 type_str(&mut s, "draft");
3848 let text = screen(&s, 39, 9);
3850 assert!(text.contains(TOO_SMALL), "{text}");
3851 let drawn: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
3852 assert_eq!(drawn.len(), 1, "only the message: {text}");
3853 assert!(!text.contains("draft"), "{text}");
3854 assert_eq!(s.input, "draft", "state is kept");
3855 let text = screen(&s, 40, 10);
3856 assert!(!text.contains(TOO_SMALL), "{text}");
3857 assert!(text.contains("draft"), "{text}");
3858 assert!(screen(&s, 40, 9).contains(TOO_SMALL));
3859 assert!(screen(&s, 39, 10).contains(TOO_SMALL));
3860 }
3861
3862 #[test]
3863 fn test_the_too_small_message_wraps_on_words_and_is_centered() {
3864 let s = state();
3865 let text = screen(&s, 30, 8);
3866 let rows: Vec<&str> = text.lines().collect();
3867 let at = rows
3868 .iter()
3869 .position(|r| r.contains("Terminal too small: need at"))
3870 .expect(&text);
3871 assert!(rows[at + 1].contains("least 40×10"), "{text}");
3872 assert_eq!(at, 3, "centered vertically: {text}");
3873 assert!(rows[at].starts_with(' '), "centered horizontally: {text}");
3874 assert!(screen(&s, 30, 1).contains("Terminal too small"));
3876 for w in 0..12 {
3877 for h in 0..4 {
3878 screen(&s, w, h);
3879 }
3880 }
3881 }
3882
3883 #[test]
3884 fn test_too_small_clears_the_pane_width_so_no_override_is_set() {
3885 let s = state();
3886 screen(&s, 80, 24);
3887 assert!(s.pane_width().is_some());
3888 screen(&s, 30, 8);
3889 assert_eq!(s.pane_width(), None);
3890 }
3891
3892 #[test]
3893 fn test_overlays_do_not_act_while_the_terminal_is_too_small() {
3894 let mut s = state();
3895 type_str(&mut s, "dra");
3896 s.handle_key(ctrl('k'));
3897 assert!(s.overlay.is_some());
3898 screen(&s, 30, 8);
3899 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3900 assert!(s.overlay.is_none(), "the overlay is closed");
3901 assert_eq!(s.input, "dra", "the palette did not fill the input");
3902 screen(&s, 80, 24);
3905 s.handle_key(ctrl('k'));
3906 screen(&s, 30, 8);
3907 s.handle_key(key(KeyCode::Char('x')));
3908 assert!(s.overlay.is_none());
3909 assert_eq!(s.input, "dra");
3910 screen(&s, 80, 24);
3912 s.handle_key(ctrl('k'));
3913 screen(&s, 30, 8);
3914 s.handle_key(key(KeyCode::Esc));
3915 assert!(s.overlay.is_none());
3916 assert_eq!(s.input, "dra");
3917 }
3918
3919 #[test]
3920 fn test_enter_does_nothing_while_too_small_and_a_command_runs() {
3921 let _lock = super::super::DIAG_LOCK.blocking_lock();
3922 let (mut s, _out) = running_state("market summary USDC --every 2s");
3923 type_str(&mut s, "ctx");
3924 screen(&s, 30, 8);
3925 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3926 assert_eq!(s.input, "ctx");
3927 assert!(s.running.is_some());
3928 }
3929
3930 #[test]
3931 fn test_enter_does_nothing_while_the_terminal_is_too_small() {
3932 let mut s = state();
3933 type_str(&mut s, "help");
3934 screen(&s, 30, 8);
3935 assert!(matches!(s.handle_key(key(KeyCode::Enter)), Action::None));
3936 assert_eq!(s.input, "help", "the draft is not lost or run");
3937 s.handle_key(key(KeyCode::Char('x')));
3939 assert_eq!(s.input, "helpx");
3940 screen(&s, 80, 24);
3942 assert!(matches!(
3943 s.handle_key(key(KeyCode::Enter)),
3944 Action::Submit(_)
3945 ));
3946 }
3947
3948 #[test]
3949 fn test_fit_status_never_panics_and_never_exceeds_the_width() {
3950 let text = |t: &str| vec![Span::styled(t.to_string(), ACCENT)];
3953 let mut parts = StatusParts {
3954 context: vec![
3955 labeled("chain", "auto".into()),
3956 labeled("format", "Table".into()),
3957 labeled("limit", "100".into()),
3958 ],
3959 state: text("running 12s · Esc cancels"),
3960 scrolled: Some(text("scrolled up 7 lines · PgDn")),
3961 search: None,
3962 command: Some("market summary ✓✓ --every 2s".into()),
3963 };
3964 for search in [None, Some(text("search: alpha ✓ 1/2"))] {
3965 parts.search = search;
3966 for w in 0..=130 {
3967 let line = fit_status(&parts, w);
3968 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
3969 assert!(cols(&text) <= w, "{w}: {text:?}");
3970 }
3971 }
3972 parts.search = None;
3973 let text: String = fit_status(&parts, 0)
3974 .spans
3975 .iter()
3976 .map(|s| s.content.as_ref())
3977 .collect();
3978 assert!(text.is_empty());
3979 }
3980
3981 use crate::cli::output::RunProgress;
3982 use std::time::Duration;
3983
3984 #[test]
3985 fn test_running_pane_shows_a_spinner_and_the_repeat_status() {
3986 let _lock = super::super::DIAG_LOCK.blocking_lock();
3987 let (s, out) = running_state("market summary USDC --every 2s");
3988 let hook = out.progress_hook().expect("the pane output has a hook");
3989 *hook.lock().unwrap() = RunProgress {
3990 run: 2,
3991 total: Some(4),
3992 next_at: Some(Instant::now() + Duration::from_secs(3)),
3993 };
3994 let text = screen(&s, 80, 12);
3995 assert!(text.contains("run 2 of 4 · next in 3s"), "{text}");
3996 assert!(
3997 text.contains("running market summary USDC --every 2s…"),
3998 "{text}"
3999 );
4000 let row = text.lines().find(|l| l.contains("running market")).unwrap();
4002 let glyph = row.trim_start().chars().next().unwrap().to_string();
4003 assert!(
4004 SPINNER_FRAMES.contains(&glyph.as_str()) || SPINNER_ASCII.contains(&glyph.as_str()),
4005 "{row:?}"
4006 );
4007 }
4008
4009 #[test]
4010 fn test_spinner_uses_ascii_without_color_and_advances_with_time() {
4011 assert_eq!(spinner_glyph(Duration::from_millis(0), false), "-");
4012 assert_eq!(spinner_glyph(Duration::from_millis(100), false), "\\");
4013 assert_eq!(spinner_glyph(Duration::from_millis(200), false), "|");
4014 assert_eq!(spinner_glyph(Duration::from_millis(300), false), "/");
4015 assert_eq!(spinner_glyph(Duration::from_millis(400), false), "-");
4016 assert_ne!(
4017 spinner_glyph(Duration::from_millis(0), true),
4018 spinner_glyph(Duration::from_millis(100), true)
4019 );
4020 }
4021
4022 #[test]
4023 fn test_spinner_and_run_status_never_enter_the_record() {
4024 let _lock = super::super::DIAG_LOCK.blocking_lock();
4027 let dir = tempfile::tempdir().unwrap();
4028 let journal = Journal::at(dir.path().join("history"), dir.path().join("events"));
4029 let mut s = TuiState::new(SessionContext::default(), journal, Vocab::default());
4030 let inv = Invocation {
4031 command: venues_list(),
4032 route: Route::Pane,
4033 ai: false,
4034 argv: vec![],
4035 };
4036 let out = s.start("market summary USDC --every 2s", &inv);
4037 crate::outln!(out, "data line").unwrap();
4038 *out.progress_hook().unwrap().lock().unwrap() = RunProgress {
4039 run: 2,
4040 total: Some(4),
4041 next_at: Some(Instant::now() + Duration::from_secs(3)),
4042 };
4043 let drawn = screen(&s, 80, 12);
4044 assert!(drawn.contains("run 2 of 4"), "{drawn}");
4045 s.finish(Ending::Finished(Ok(())));
4046 let kept = format!(
4047 "{}\n{}",
4048 s.scrollback_text(),
4049 s.last_output.clone().unwrap_or_default()
4050 );
4051 assert!(kept.contains("data line"));
4052 for gone in [
4053 "run 2 of",
4054 "next in",
4055 "running market",
4056 "⠋",
4057 "Running market",
4058 ] {
4059 assert!(!kept.contains(gone), "{gone:?} leaked into: {kept}");
4060 }
4061 let events = std::fs::read_to_string(dir.path().join("events")).unwrap();
4062 assert!(
4063 !events.contains("run 2 of") && !events.contains("next in"),
4064 "{events}"
4065 );
4066 let after = screen(&s, 80, 12);
4068 assert!(!after.contains("run 2 of"), "{after}");
4069 }
4070
4071 #[test]
4072 fn test_render_shows_live_output_of_a_running_command() {
4073 let _lock = super::super::DIAG_LOCK.blocking_lock();
4075 let mut s = state();
4076 let inv = Invocation {
4077 command: venues_list(),
4078 route: Route::Pane,
4079 ai: false,
4080 argv: vec![],
4081 };
4082 let out = s.start("market summary USDC --every 30s", &inv);
4083 crate::outln!(out, "\x1b[32mrun 1: HEALTHY\x1b[0m").unwrap();
4084 let text = screen(&s, 80, 15);
4085 assert!(text.contains("run 1: HEALTHY"), "{}", text);
4086 assert!(text.contains("running 0s · Esc cancels"), "{}", text);
4087 s.finish(Ending::Finished(Ok(())));
4088 assert!(s.scrollback_text().contains("run 1: HEALTHY"));
4089 }
4090
4091 #[test]
4092 fn test_newest_line_is_on_the_last_pane_row() {
4093 let mut s = state();
4096 for i in 0..40 {
4097 s.push_ansi(&format!("line {}\n", i));
4098 }
4099 let rows: Vec<String> = screen(&s, 40, 15).lines().map(str::to_string).collect();
4100 assert!(rows[8].contains("line 39"), "{:#?}", rows);
4102 assert!(rows[9].starts_with('─'), "{:#?}", rows);
4103 }
4104
4105 #[test]
4106 fn test_hint_row_shows_usage_while_typing() {
4107 let mut s = state();
4108 type_str(&mut s, "market summary");
4109 let text = screen(&s, 100, 15);
4110 assert!(text.contains("scope market summary"), "{}", text);
4111 assert!(text.contains("next: <SYMBOL>"), "{}", text);
4112 }
4113
4114 #[test]
4115 fn test_render_tiny_terminal_does_not_panic() {
4116 let mut s = state();
4117 s.push_ansi("x\n");
4118 screen(&s, 10, 5);
4119 screen(&s, 1, 1);
4120 }
4121
4122 #[tokio::test]
4123 async fn test_finish_with_error_shows_cli_error_text_and_logs() {
4124 let _lock = super::super::DIAG_LOCK.lock().await;
4125 let dir = tempfile::tempdir().unwrap();
4126 let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
4127 let mut s = TuiState::new(SessionContext::default(), journal, Vocab::default());
4128 let inv = Invocation {
4129 command: venues_list(),
4130 route: Route::Pane,
4131 ai: false,
4132 argv: vec!["scope".into(), "venues".into(), "list".into()],
4133 };
4134 s.start("venues list", &inv);
4135 s.finish(Ending::Finished(Err(
4136 scope::error::ScopeError::InvalidAddress("0xbad".into()),
4137 )));
4138 let text = s.scrollback_text();
4139 assert!(text.contains("Invalid address format: 0xbad"), "{}", text);
4140 assert!(text.contains("EVM: 0x followed by 40 hex characters"));
4141 let log = std::fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
4142 let ev: serde_json::Value = serde_json::from_str(log.lines().last().unwrap()).unwrap();
4143 assert_eq!(ev["outcome"], "error");
4144 assert_eq!(ev["route"], "pane");
4145 assert_eq!(ev["argv"][1], "venues");
4146 }
4147
4148 fn palette_selected(s: &TuiState) -> usize {
4149 let Some(Overlay::Palette(p)) = &s.overlay else {
4150 panic!("palette expected")
4151 };
4152 p.selected
4153 }
4154
4155 #[test]
4156 fn test_palette_selection_wraps_and_a_new_query_resets_it() {
4157 let mut s = state();
4160 s.handle_key(ctrl_k());
4161 let count = match &s.overlay {
4162 Some(Overlay::Palette(p)) => p.matches().len(),
4163 _ => panic!("palette expected"),
4164 };
4165 assert!(count > 2);
4166 s.handle_key(key(KeyCode::Up));
4167 assert_eq!(palette_selected(&s), count - 1);
4168 s.handle_key(key(KeyCode::Down));
4169 assert_eq!(palette_selected(&s), 0);
4170 s.handle_key(key(KeyCode::Tab));
4171 s.handle_key(key(KeyCode::BackTab));
4172 assert_eq!(palette_selected(&s), 0);
4173 s.handle_key(key(KeyCode::Down));
4174 s.handle_key(key(KeyCode::Backspace));
4175 assert_eq!(palette_selected(&s), 0);
4176 s.handle_key(key(KeyCode::Down));
4177 type_str(&mut s, "m");
4178 assert_eq!(palette_selected(&s), 0);
4179 }
4180
4181 #[test]
4182 fn test_palette_esc_closes_and_enter_without_a_match_keeps_the_draft() {
4183 let mut s = state();
4184 type_str(&mut s, "draft");
4185 s.handle_key(ctrl_k());
4186 s.handle_key(key(KeyCode::Esc));
4187 assert!(s.overlay.is_none());
4188 s.handle_key(ctrl_k());
4189 type_str(&mut s, "zzzzqqqq");
4190 s.handle_key(key(KeyCode::Down));
4191 s.handle_key(key(KeyCode::Enter));
4192 assert!(s.overlay.is_none());
4193 assert_eq!(s.input, "draft", "no match: nothing is filled in");
4194 }
4195
4196 #[test]
4197 fn test_menu_up_wraps_to_the_last_candidate() {
4198 let mut s = vocab_state();
4199 type_str(&mut s, "market summary USDC --venue b");
4200 s.handle_key(key(KeyCode::Tab));
4201 s.handle_key(key(KeyCode::Up));
4202 s.handle_key(key(KeyCode::Enter));
4203 assert_eq!(s.input, "market summary USDC --venue bitget ");
4204 s.set_input("market summary USDC --venue b".to_string());
4205 s.handle_key(key(KeyCode::Tab));
4206 s.handle_key(key(KeyCode::Down));
4207 s.handle_key(key(KeyCode::BackTab));
4208 s.handle_key(key(KeyCode::Enter));
4209 assert_eq!(s.input, "market summary USDC --venue binance ");
4210 }
4211
4212 #[test]
4213 fn test_record_signal_writes_the_signal_outcome_to_the_journal() {
4214 let dir = tempfile::tempdir().unwrap();
4217 let events = dir.path().join("e.jsonl");
4218 let mut s = TuiState::new(
4219 SessionContext::default(),
4220 Journal::at(dir.path().join("h"), events.clone()),
4221 Vocab::default(),
4222 );
4223 s.record_signal("HUP", "suspend", "setup", &["scope".into(), "setup".into()]);
4224 let row: serde_json::Value =
4225 serde_json::from_str(std::fs::read_to_string(events).unwrap().trim()).unwrap();
4226 assert_eq!(row["outcome"], "signal:HUP");
4227 assert_eq!(row["route"], "suspend");
4228 assert_eq!(row["input"], "setup");
4229 assert_eq!(row["argv"][1], "setup");
4230 }
4231
4232 #[test]
4233 fn test_failed_suspended_command_is_journaled_as_error_with_its_text() {
4234 let dir = tempfile::tempdir().unwrap();
4235 let events = dir.path().join("e.jsonl");
4236 let mut s = TuiState::new(
4237 SessionContext::default(),
4238 Journal::at(dir.path().join("h"), events.clone()),
4239 Vocab::default(),
4240 );
4241 let failed = Err(scope::error::ScopeError::Other("setup broke".into()));
4242 s.record_suspended(
4243 "setup",
4244 &["scope".into(), "setup".into()],
4245 std::time::Duration::from_millis(5),
4246 "2026-01-01T00:00:00Z".into(),
4247 &Ending::Finished(failed),
4248 );
4249 let row: serde_json::Value =
4250 serde_json::from_str(std::fs::read_to_string(events).unwrap().trim()).unwrap();
4251 assert_eq!(row["outcome"], "error");
4252 assert_eq!(row["route"], "suspend");
4253 assert!(row["error"].as_str().unwrap().contains("setup broke"));
4254 }
4255
4256 #[test]
4257 fn test_unwritable_event_log_is_reported_in_the_pane() {
4258 let dir = tempfile::tempdir().unwrap();
4261 let mut s = TuiState::new(
4263 SessionContext::default(),
4264 Journal::at(dir.path().join("h"), dir.path().to_path_buf()),
4265 Vocab::default(),
4266 );
4267 s.record_signal("TERM", "session", "", &[]);
4268 assert!(
4269 s.scrollback_text()
4270 .contains("Could not write the TUI event log"),
4271 "{}",
4272 s.scrollback_text()
4273 );
4274 }
4275
4276 #[test]
4277 fn test_paste_joins_lines_and_never_runs() {
4278 let mut s = state();
4279 s.paste("venues list\nmarket summary USDC\n");
4280 assert_eq!(s.input, "venues list market summary USDC");
4281 assert!(s.scrollback_text().contains("pasted 2 lines as one line"));
4282 }
4283
4284 #[test]
4285 fn test_paste_one_line_adds_no_note_and_crlf_counts_once() {
4286 let mut s = state();
4287 s.paste("help\r\n");
4288 assert_eq!(s.input, "help");
4289 assert!(!s.scrollback_text().contains("pasted"));
4290 s.set_input(String::new());
4291 s.paste("a\r\nb\rc");
4292 assert_eq!(s.input, "a b c");
4293 assert!(s.scrollback_text().contains("pasted 3 lines as one line"));
4294 }
4295
4296 #[test]
4297 fn test_paste_inserts_at_the_cursor() {
4298 let mut s = state();
4299 type_str(&mut s, "ac");
4300 s.handle_key(key(KeyCode::Left));
4301 s.paste("b");
4302 assert_eq!(s.input, "abc");
4303 type_str(&mut s, "!");
4304 assert_eq!(s.input, "ab!c", "the cursor sits after the paste");
4305 }
4306
4307 #[test]
4308 fn test_paste_cannot_inject_control_characters() {
4309 let mut s = state();
4310 s.paste("a\x1b[2Jb\tc\x07\x7fd\u{9b}e");
4311 assert_eq!(s.input, "a[2Jb cde");
4312 }
4313
4314 #[test]
4315 fn test_paste_closes_the_completion_menu() {
4316 let mut s = vocab_state();
4317 type_str(&mut s, "market summary X --venue b");
4318 s.handle_key(key(KeyCode::Tab));
4319 assert!(s.overlay.is_some());
4320 s.paste("z");
4321 assert!(s.overlay.is_none());
4322 }
4323
4324 #[test]
4325 fn test_paste_while_a_command_runs_goes_to_the_input() {
4326 let _lock = super::super::DIAG_LOCK.blocking_lock();
4327 let mut s = state();
4328 let inv = Invocation {
4329 command: venues_list(),
4330 route: Route::Pane,
4331 ai: false,
4332 argv: vec![],
4333 };
4334 s.start("venues list", &inv);
4335 s.paste("market summary USDC\n");
4336 assert_eq!(s.input, "market summary USDC");
4337 assert!(s.running.is_some(), "the paste does not run or cancel");
4338 s.finish(Ending::Cancelled);
4339 }
4340
4341 #[test]
4342 fn test_long_input_keeps_cursor_visible() {
4343 let mut s = state();
4344 s.paste(&"x".repeat(300));
4345 assert_eq!(cursor_pos(&s, 60, 12).x, 58);
4347 let sc = screen(&s, 60, 12);
4348 let row = sc.lines().find(|l| l.contains("xxxx")).unwrap();
4349 let cells: Vec<char> = row.chars().collect();
4350 assert_eq!(cells[0], '│');
4351 assert!(
4352 cells[1..58].iter().all(|&c| c == 'x'),
4353 "text ends at the cursor"
4354 );
4355 assert_eq!(cells[58], ' ', "the cursor cell is empty");
4356 assert_eq!(cells[59], '│', "the border is not overwritten");
4357 }
4358
4359 #[test]
4360 fn test_cursor_scrolls_back_with_home() {
4361 let mut s = state();
4362 s.paste(&"x".repeat(100));
4363 s.handle_key(key(KeyCode::Home));
4364 assert_eq!(
4365 cursor_pos(&s, 60, 12).x,
4366 1,
4367 "Home shows the start of the line"
4368 );
4369 s.handle_key(key(KeyCode::End));
4370 assert_eq!(cursor_pos(&s, 60, 12).x, 58);
4371 }
4372
4373 #[test]
4374 fn test_cursor_follows_display_width_of_cjk() {
4375 let mut s = state();
4377 type_str(&mut s, "日本 ok");
4378 s.handle_key(key(KeyCode::End));
4379 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 7);
4380 s.handle_key(key(KeyCode::Left));
4381 s.handle_key(key(KeyCode::Left));
4382 s.handle_key(key(KeyCode::Left));
4383 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 4, "after 日本");
4384 }
4385
4386 #[test]
4387 fn test_cursor_follows_display_width_of_emoji() {
4388 let mut s = state();
4389 type_str(&mut s, "😀x");
4390 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 3);
4391 }
4392
4393 #[test]
4394 fn test_wide_input_scrolls_by_columns_not_chars() {
4395 let mut s = state();
4397 s.paste(&"日".repeat(40));
4398 assert_eq!(cursor_pos(&s, 60, 12).x, 57);
4400 }
4401
4402 #[test]
4403 fn test_menu_width_uses_display_width() {
4404 let wide_a = "日本日本日本a";
4405 let wide_b = "日本日本日本b";
4406 let mut s = TuiState::new(
4407 SessionContext::default(),
4408 Journal::default(),
4409 Vocab {
4410 venues: vec![wide_a.into(), wide_b.into()],
4411 ..Vocab::default()
4412 },
4413 );
4414 type_str(&mut s, "market summary X --venue ");
4415 s.handle_key(key(KeyCode::Tab));
4416 assert!(matches!(s.overlay, Some(Overlay::Menu(_))));
4417 let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap();
4418 term.draw(|f| s.render(f)).unwrap();
4419 let buf = term.backend().buffer().clone();
4420 let row = (0..14)
4423 .find(|&y| (0..60).any(|x| buf[(x, y)].symbol() == "a"))
4424 .unwrap();
4425 assert_eq!(buf[(16, row)].symbol(), "│");
4426 }
4427
4428 fn start_pane_command(s: &mut TuiState) {
4429 let inv = Invocation {
4430 command: venues_list(),
4431 route: Route::Pane,
4432 ai: false,
4433 argv: vec![],
4434 };
4435 s.start("venues list", &inv);
4436 }
4437
4438 #[test]
4439 fn test_ctrl_d_while_running_explains_and_does_not_exit() {
4440 let _lock = super::super::DIAG_LOCK.blocking_lock();
4441 let mut s = state();
4442 start_pane_command(&mut s);
4443 assert_eq!(s.handle_key(ctrl('d')), Action::None);
4444 assert!(s.scrollback_text().contains(BUSY_NOTE));
4445 s.finish(Ending::Cancelled);
4446 }
4447
4448 #[test]
4449 fn test_exit_typed_while_running_explains_and_keeps_the_line() {
4450 let _lock = super::super::DIAG_LOCK.blocking_lock();
4451 for word in ["exit", "quit", "q", "EXIT"] {
4452 let mut s = state();
4453 start_pane_command(&mut s);
4454 type_str(&mut s, word);
4455 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None, "{word}");
4456 assert!(s.scrollback_text().contains(BUSY_NOTE), "{word}");
4457 assert_eq!(s.input, word, "the line stays");
4458 s.finish(Ending::Cancelled);
4459 }
4460 }
4461
4462 #[test]
4463 fn test_any_line_while_running_shows_the_hint_and_keeps_the_input() {
4464 let _lock = super::super::DIAG_LOCK.blocking_lock();
4465 let mut s = state();
4466 start_pane_command(&mut s);
4467 type_str(&mut s, "ctx");
4468 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
4469 assert_eq!(s.input, "ctx");
4470 assert!(screen(&s, 100, 14).contains(BUSY_NOTE), "hint row");
4471 assert!(
4472 !s.scrollback_text().contains(BUSY_NOTE),
4473 "no scrollback note"
4474 );
4475 s.finish(Ending::Cancelled);
4476 }
4477
4478 #[test]
4479 fn test_empty_enter_while_running_shows_no_hint() {
4480 let _lock = super::super::DIAG_LOCK.blocking_lock();
4481 let mut s = state();
4482 start_pane_command(&mut s);
4483 s.handle_key(key(KeyCode::Enter));
4484 assert!(!screen(&s, 100, 14).contains(BUSY_NOTE));
4485 s.finish(Ending::Cancelled);
4486 }
4487
4488 #[test]
4489 fn test_busy_hint_clears_on_the_next_key() {
4490 let _lock = super::super::DIAG_LOCK.blocking_lock();
4491 let mut s = state();
4492 start_pane_command(&mut s);
4493 s.handle_key(ctrl('d'));
4494 let hint_row = screen(&s, 100, 14).lines().nth(12).unwrap().to_string();
4495 assert!(hint_row.contains(BUSY_NOTE));
4496 s.handle_key(key(KeyCode::Char('x')));
4497 let hint_row = screen(&s, 100, 14).lines().nth(12).unwrap().to_string();
4498 assert!(!hint_row.contains(BUSY_NOTE));
4499 s.finish(Ending::Cancelled);
4500 }
4501
4502 #[test]
4503 fn test_busy_note_is_not_repeated_in_a_row() {
4504 let _lock = super::super::DIAG_LOCK.blocking_lock();
4505 let mut s = state();
4506 start_pane_command(&mut s);
4507 s.handle_key(ctrl('d'));
4508 s.handle_key(ctrl('d'));
4509 assert_eq!(s.scrollback_text().matches(BUSY_NOTE).count(), 1);
4510 s.finish(Ending::Cancelled);
4511 }
4512
4513 #[test]
4514 fn test_ctrl_d_exits_once_the_command_ended() {
4515 let _lock = super::super::DIAG_LOCK.blocking_lock();
4516 let mut s = state();
4517 start_pane_command(&mut s);
4518 s.handle_key(ctrl('d'));
4519 s.finish(Ending::Cancelled);
4520 assert_eq!(s.handle_key(ctrl('d')), Action::Exit);
4521 }
4522
4523 #[test]
4524 fn test_ctrl_d_with_text_and_no_command_does_nothing() {
4525 let mut s = state();
4526 type_str(&mut s, "ab");
4527 assert_eq!(s.handle_key(ctrl('d')), Action::None);
4528 assert!(!s.scrollback_text().contains(BUSY_NOTE));
4529 }
4530
4531 #[test]
4532 fn test_paste_drops_bidi_controls() {
4533 let mut s = state();
4534 s.paste("a\u{202E}b\u{2066}c\u{2069}d\u{202A}");
4535 assert_eq!(s.input, "abcd");
4536 }
4537
4538 #[test]
4539 fn test_paste_is_capped_with_a_note() {
4540 let mut s = state();
4541 s.paste(&"x".repeat(PASTE_LIMIT + 50));
4542 assert_eq!(s.input.chars().count(), PASTE_LIMIT);
4543 assert!(
4544 s.scrollback_text()
4545 .contains("paste truncated to 10000 characters")
4546 );
4547 let mut t = state();
4548 t.paste(&"x".repeat(PASTE_LIMIT));
4549 assert!(
4550 !t.scrollback_text().contains("truncated"),
4551 "exact limit is not truncated"
4552 );
4553 }
4554
4555 #[test]
4556 fn test_paste_goes_into_the_open_palette_query() {
4557 let mut s = state();
4558 s.handle_key(ctrl('k'));
4559 s.paste("ven\nues");
4560 match &s.overlay {
4561 Some(Overlay::Palette(p)) => assert_eq!(p.query, "ven ues"),
4562 _ => panic!("the palette stays open"),
4563 }
4564 assert_eq!(s.input, "", "the input line is untouched");
4565 }
4566
4567 fn row_cells(s: &TuiState, w: u16, h: u16, needle: &str) -> (Vec<String>, u16) {
4568 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
4569 term.draw(|f| s.render(f)).unwrap();
4570 let buf = term.backend().buffer().clone();
4571 let y = (0..h)
4572 .find(|&y| (0..w).any(|x| buf[(x, y)].symbol() == needle))
4573 .unwrap();
4574 (
4575 (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect(),
4576 y,
4577 )
4578 }
4579
4580 #[test]
4581 fn test_left_mid_line_keeps_the_text_right_of_the_cursor_visible() {
4582 let mut s = state();
4583 s.paste(&format!("{}{}", "a".repeat(100), "b".repeat(5)));
4584 let _ = cursor_pos(&s, 60, 12);
4585 for _ in 0..3 {
4586 s.handle_key(key(KeyCode::Left));
4587 }
4588 let (cells, _) = row_cells(&s, 60, 12, "b");
4589 assert_eq!(cursor_pos(&s, 60, 12).x, 55, "the window did not move");
4590 assert_eq!(cells[55], "b", "text under and right of the cursor stays");
4591 assert_eq!(cells[57], "b", "the last char is still shown");
4592 }
4593
4594 #[test]
4595 fn test_window_moves_back_when_the_cursor_leaves_on_the_left() {
4596 let mut s = state();
4597 s.paste(&"x".repeat(100));
4598 let _ = cursor_pos(&s, 60, 12);
4599 for _ in 0..80 {
4600 s.handle_key(key(KeyCode::Left));
4601 }
4602 assert_eq!(cursor_pos(&s, 60, 12).x, 1, "cursor at the left edge");
4603 }
4604
4605 #[test]
4606 fn test_wide_chars_are_never_cut_at_the_left_edge() {
4607 let mut s = state();
4610 s.paste(&"日".repeat(40));
4611 let (cells, _) = row_cells(&s, 60, 12, "日");
4612 assert_eq!(cells[1], "日", "the first shown cell holds a whole char");
4613 assert_eq!(cursor_pos(&s, 60, 12).x, 1 + 80 - 24);
4614 assert_eq!(cells[59], "│", "border intact");
4615 }
4616
4617 #[test]
4618 fn test_cursor_on_a_wide_char_is_never_half_visible() {
4619 let mut s = state();
4620 s.paste(&"x".repeat(56));
4621 s.paste("日日");
4622 s.handle_key(key(KeyCode::Left));
4623 let p = cursor_pos(&s, 60, 12);
4625 assert!(
4626 p.x + 2 <= 59,
4627 "wide char at the cursor fits inside the border"
4628 );
4629 }
4630
4631 fn run_pane(s: &mut TuiState, text: &str, ending: Ending) {
4633 let inv = Invocation {
4634 command: venues_list(),
4635 route: Route::Pane,
4636 ai: false,
4637 argv: vec![],
4638 };
4639 let out = s.start("venues list", &inv);
4640 out.write_out(format_args!("{}", text)).unwrap();
4641 s.finish(ending);
4642 }
4643
4644 #[test]
4645 fn test_ctrl_y_copies_the_plain_output_of_the_last_success() {
4646 let mut s = state();
4647 assert_eq!(s.handle_key(ctrl('y')), Action::None);
4648 assert!(s.scrollback_text().contains("nothing to copy yet"));
4649 run_pane(&mut s, "\x1b[1;36mA\x1b[0m\nb\n", Ending::Finished(Ok(())));
4650 assert_eq!(s.handle_key(ctrl('y')), Action::Copy("A\nb\n".into()));
4651 }
4652
4653 #[test]
4654 fn test_failed_or_cancelled_run_keeps_the_earlier_copy() {
4655 let mut s = state();
4656 run_pane(&mut s, "good\n", Ending::Finished(Ok(())));
4657 run_pane(
4658 &mut s,
4659 "partial\n",
4660 Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into()))),
4661 );
4662 run_pane(&mut s, "partial\n", Ending::Cancelled);
4663 assert_eq!(s.handle_key(ctrl('y')), Action::Copy("good\n".into()));
4664 }
4665
4666 #[test]
4667 fn test_save_without_output_says_so_and_escape_is_refused() {
4668 let mut s = state();
4669 s.submit("save out.json", &Config::default());
4670 assert!(s.scrollback_text().contains("nothing to save yet"));
4671 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4672 s.submit("save ../escape.json", &Config::default());
4673 assert!(
4674 s.scrollback_text()
4675 .contains("save writes inside the current directory or to an absolute path")
4676 );
4677 s.submit("save", &Config::default());
4678 assert!(s.scrollback_text().contains("Usage: save <file> [--force]"));
4679 }
4680
4681 #[test]
4682 fn test_pane_has_no_side_borders() {
4683 let mut s = state();
4684 s.push_note("hello");
4685 let text = screen(&s, 40, 12);
4686 let row = text.lines().find(|l| l.contains("hello")).unwrap();
4687 assert!(row.starts_with("ℹ hello"), "{row:?}");
4688 }
4689
4690 fn last_line(s: &TuiState) -> &Line<'static> {
4691 s.scrollback.last().unwrap()
4692 }
4693
4694 #[test]
4695 fn test_ok_run_ends_with_done_footer() {
4696 let mut s = state();
4697 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4698 let text = last_line(&s).to_string();
4699 assert!(
4700 regex::Regex::new(r"^✓ Done in \d+\.\ds\.$")
4701 .unwrap()
4702 .is_match(&text),
4703 "{text}"
4704 );
4705 assert_eq!(last_line(&s).style, OK);
4706 }
4707
4708 #[test]
4709 fn test_failed_run_ends_with_a_red_failed_footer() {
4710 let mut s = state();
4711 let err = Ending::Finished(Err(scope::error::ScopeError::Chain("boom".into())));
4712 s.start("x", &pane_invocation());
4713 s.finish_with(err);
4714 let line = last_line(&s);
4715 let text = line.to_string();
4716 assert!(
4717 regex::Regex::new(r"^✗ Failed in \d+\.\ds\.$")
4718 .unwrap()
4719 .is_match(&text),
4720 "{text}"
4721 );
4722 assert_eq!(line.style, ERR.add_modifier(Modifier::BOLD));
4723 }
4724
4725 #[test]
4726 fn test_cancelled_run_ends_with_cancelled_after_footer() {
4727 let mut s = state();
4728 run_pane(&mut s, "x\n", Ending::Cancelled);
4729 let text = last_line(&s).to_string();
4730 assert!(
4731 regex::Regex::new(r"^Cancelled after \d+\.\ds\.$")
4732 .unwrap()
4733 .is_match(&text),
4734 "{text}"
4735 );
4736 assert_eq!(last_line(&s).style, WARN);
4737 }
4738
4739 fn pane_invocation() -> Invocation {
4740 Invocation {
4741 command: venues_list(),
4742 route: Route::Pane,
4743 ai: false,
4744 argv: vec![],
4745 }
4746 }
4747
4748 fn warned_json_run(s: &mut TuiState) {
4749 let inv = pane_invocation();
4750 let out = s.start("address 0x1 --format json", &inv);
4751 crate::errln!(out, "\u{26a0} Token balances unavailable").unwrap();
4752 crate::outln!(out, "{{\"a\": 1}}").unwrap();
4753 s.finish(Ending::Finished(Ok(())));
4754 }
4755
4756 #[test]
4757 fn test_last_output_excludes_warnings_the_pane_still_shows() {
4758 let mut s = state();
4759 warned_json_run(&mut s);
4760 let Action::Copy(text) = s.handle_key(ctrl('y')) else {
4761 panic!("expected a copy");
4762 };
4763 serde_json::from_str::<serde_json::Value>(&text).unwrap();
4764 assert!(!text.contains("Token balances"));
4765 assert!(s.scrollback_text().contains("Token balances unavailable"));
4766 }
4767
4768 #[test]
4769 fn test_a_success_with_no_output_clears_the_copy() {
4770 let mut s = state();
4771 run_pane(&mut s, "data\n", Ending::Finished(Ok(())));
4772 run_pane(&mut s, "", Ending::Finished(Ok(())));
4773 assert_eq!(s.handle_key(ctrl('y')), Action::None);
4774 s.submit("save out.json", &Config::default());
4775 assert!(s.scrollback_text().contains("nothing to save yet"));
4776 }
4777
4778 #[test]
4779 fn test_ctrl_y_refuses_output_over_the_osc52_cap() {
4780 let mut s = state();
4781 let big = "x".repeat(clip::OSC52_MAX_PAYLOAD);
4782 run_pane(&mut s, &format!("{big}\n"), Ending::Finished(Ok(())));
4783 assert_eq!(s.handle_key(ctrl('y')), Action::None);
4784 assert!(
4785 s.scrollback_text()
4786 .contains("too large to copy (1 lines); use save <file>")
4787 );
4788 }
4789
4790 #[test]
4791 fn test_save_with_unbalanced_quotes_gets_the_save_usage() {
4792 let mut s = state();
4793 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
4794 s.submit("save \"unclosed", &Config::default());
4795 let text = s.scrollback_text();
4796 assert!(
4797 text.contains("Unbalanced quotes. Usage: save <file> [--force]"),
4798 "{text}"
4799 );
4800 }
4801
4802 fn pane_rows(s: &TuiState, w: u16) -> Vec<String> {
4804 let h = 200;
4805 let rows: Vec<String> = screen(s, w, h).lines().map(str::to_string).collect();
4806 rows[1..(h as usize - 6)].to_vec()
4808 }
4809
4810 #[tokio::test]
4811 async fn test_venues_list_rules_never_wrap_in_a_narrow_pane() {
4812 let _lock = super::super::DIAG_LOCK.lock().await;
4817 let mut s = state();
4818 let inv = pane_invocation();
4819 let out = s.start("venues list", &inv);
4820 let factory = scope::chains::mocks::MockClientFactory::new();
4821 let res = run_invocation(inv, &Config::default(), &factory, &out).await;
4822 s.finish(Ending::Finished(res));
4823 let rows = pane_rows(&s, 40);
4824 for pair in rows.windows(2) {
4825 let continued = pair[0].trim_end().ends_with('─') && pair[1].starts_with('─');
4826 assert!(!continued, "a rule wrapped:\n{}", rows.join("\n"));
4827 }
4828 let first = rows
4829 .iter()
4830 .position(|r| r.starts_with("┌─ Available Venues"));
4831 let last = rows.iter().position(|r| r.starts_with('└'));
4832 let (Some(first), Some(last)) = (first, last) else {
4833 panic!("no section:\n{}", rows.join("\n"));
4834 };
4835 for row in &rows[first + 1..last] {
4836 assert!(
4837 row.starts_with('│') || row.starts_with('├'),
4838 "row lost its gutter: {row:?}\n{}",
4839 rows.join("\n")
4840 );
4841 }
4842 for row in &rows {
4843 assert!(row.width() <= 40, "{row:?}");
4844 }
4845 }
4846
4847 #[test]
4848 fn test_pane_wraps_never_reach_the_copy() {
4849 let mut s = state();
4852 let long = format!("│ {}", "word ".repeat(30).trim_end());
4853 run_pane(&mut s, &format!("{long}\n"), Ending::Finished(Ok(())));
4854 let rows = pane_rows(&s, 40);
4855 let wrapped: Vec<_> = rows.iter().filter(|r| r.contains("word")).collect();
4856 assert!(wrapped.len() > 1, "{rows:#?}");
4857 assert!(
4858 wrapped.iter().all(|r| r.starts_with("│ word")),
4859 "{rows:#?}"
4860 );
4861 let Action::Copy(text) = s.handle_key(ctrl('y')) else {
4862 panic!("expected a copy");
4863 };
4864 assert_eq!(text, format!("{long}\n"));
4865 }
4866
4867 #[test]
4868 fn test_render_records_the_pane_width() {
4869 let s = state();
4872 assert_eq!(s.pane_width(), None, "nothing drawn yet");
4873 screen(&s, 40, 20);
4874 assert_eq!(s.pane_width(), Some(40));
4875 }
4876
4877 fn alpha_state() -> TuiState {
4880 let mut s = state();
4881 s.push_ansi("alpha\nbeta\nalpha2\n");
4882 screen(&s, 80, 20);
4883 s
4884 }
4885
4886 fn open_search(s: &mut TuiState, query: &str) {
4887 s.handle_key(ctrl('f'));
4888 type_str(s, query);
4889 screen(s, 80, 20);
4890 }
4891
4892 #[test]
4893 fn test_search_goes_to_the_newest_match_and_n_and_shift_n_walk_the_matches() {
4894 let mut s = alpha_state();
4895 open_search(&mut s, "alpha");
4896 assert!(
4897 status_row(&s, 80).contains("search: alpha 1/2"),
4898 "newest first"
4899 );
4900 assert_eq!(s.input, "", "typing goes to the query, not the input");
4901 s.handle_key(key(KeyCode::Enter));
4902 s.handle_key(key(KeyCode::Char('n')));
4903 assert!(status_row(&s, 80).contains("search: alpha 2/2"));
4904 s.handle_key(key(KeyCode::Char('n')));
4905 assert!(status_row(&s, 80).contains("1/2"), "n wraps to the newest");
4906 s.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
4907 assert!(status_row(&s, 80).contains("2/2"), "N wraps to the oldest");
4908 s.handle_key(key(KeyCode::Esc));
4909 assert!(s.overlay.is_none());
4910 assert!(!status_row(&s, 80).contains("search:"));
4911 }
4912
4913 #[test]
4914 fn test_n_is_a_query_character_while_editing() {
4915 let mut s = alpha_state();
4916 open_search(&mut s, "an");
4917 assert!(status_row(&s, 80).contains("search: an"));
4918 assert!(status_row(&s, 80).contains("no match"));
4919 }
4920
4921 #[test]
4922 fn test_slash_opens_search_only_on_an_empty_input() {
4923 let mut s = alpha_state();
4924 s.handle_key(key(KeyCode::Char('/')));
4925 assert!(matches!(s.overlay, Some(Overlay::Search(_))));
4926 assert_eq!(s.input, "");
4927 s.handle_key(key(KeyCode::Esc));
4928 type_str(&mut s, "a/b");
4929 assert!(s.overlay.is_none());
4930 assert_eq!(s.input, "a/b", "a slash inside text is text");
4931 }
4932
4933 #[test]
4934 fn test_search_is_case_insensitive_and_marks_matched_rows() {
4935 let mut s = alpha_state();
4936 open_search(&mut s, "ALPHA");
4937 let text = screen(&s, 80, 20);
4938 assert!(status_row(&s, 80).contains("1/2"));
4939 assert_eq!(text.matches('▶').count(), 2, "{text}");
4940 }
4941
4942 #[test]
4943 fn test_no_match_says_so_and_n_does_nothing() {
4944 let mut s = alpha_state();
4945 open_search(&mut s, "zzz");
4946 assert!(status_row(&s, 80).contains("no match"));
4947 s.handle_key(key(KeyCode::Enter));
4948 s.handle_key(key(KeyCode::Char('n')));
4949 s.handle_key(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT));
4950 assert!(status_row(&s, 80).contains("no match"));
4951 assert!(!screen(&s, 80, 20).contains('▶'));
4952 }
4953
4954 fn needle_state() -> TuiState {
4955 let mut s = state();
4956 s.push_ansi("needle first\n");
4957 for i in 0..40 {
4958 s.push_ansi(&format!("line {}\n", i));
4959 }
4960 screen(&s, 60, 15);
4961 s
4962 }
4963
4964 #[test]
4965 fn test_search_scrolls_to_an_old_match_and_esc_restores_the_view() {
4966 let mut s = needle_state();
4967 s.handle_key(key(KeyCode::PageUp));
4968 let before = screen(&s, 60, 15);
4969 assert!(!before.contains("needle"));
4970 s.handle_key(ctrl('f'));
4971 type_str(&mut s, "needle");
4972 let during = screen(&s, 60, 15);
4973 assert!(during.contains("needle first"), "{during}");
4974 assert!(during.contains('▶'));
4975 s.handle_key(key(KeyCode::Esc));
4976 assert_eq!(screen(&s, 60, 15), before, "the view the user had");
4977 }
4978
4979 fn long_match_state() -> TuiState {
4980 let mut s = state();
4981 s.push_ansi(&format!("needle {}\n", "word ".repeat(40)));
4982 for i in 0..30 {
4983 s.push_ansi(&format!("line {}\n", i));
4984 }
4985 screen(&s, 60, 15);
4986 s
4987 }
4988
4989 #[test]
4990 fn test_a_long_match_highlights_every_row_it_spans() {
4991 let mut s = long_match_state();
4992 s.handle_key(ctrl('f'));
4993 type_str(&mut s, "needle");
4994 let text = screen(&s, 60, 15);
4995 assert!(text.matches('▶').count() >= 2, "{text}");
4996 let mut term = Terminal::new(TestBackend::new(60, 15)).unwrap();
4998 term.draw(|f| s.render(f)).unwrap();
4999 let buf = term.backend().buffer().clone();
5000 let reversed = (0..15)
5001 .filter(|&y| buf[(5, y)].modifier.contains(Modifier::REVERSED))
5002 .count();
5003 assert!(reversed >= 2, "{text}");
5004 }
5005
5006 #[test]
5007 fn test_search_follows_a_resize() {
5008 let mut s = long_match_state();
5009 s.handle_key(ctrl('f'));
5010 type_str(&mut s, "needle");
5011 assert!(screen(&s, 60, 15).contains("needle"));
5012 assert!(
5013 screen(&s, 100, 20).contains("needle"),
5014 "visible after a resize"
5015 );
5016 assert!(screen(&s, 45, 12).contains("needle"), "and again");
5017 }
5018
5019 #[test]
5020 fn test_a_paste_goes_into_the_search_query() {
5021 let mut s = alpha_state();
5022 s.handle_key(ctrl('f'));
5023 s.paste("alpha\n");
5024 screen(&s, 80, 20);
5025 assert_eq!(s.input, "");
5026 assert!(status_row(&s, 80).contains("search: alpha 1/2"));
5027 }
5028
5029 #[test]
5030 fn test_esc_closes_the_search_before_it_cancels_a_running_command() {
5031 let _lock = super::super::DIAG_LOCK.blocking_lock();
5032 let (mut s, _out) = running_state("market summary USDC --every 2s");
5033 s.handle_key(ctrl('f'));
5034 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::None);
5035 assert!(s.overlay.is_none());
5036 assert_eq!(s.handle_key(key(KeyCode::Esc)), Action::Cancel);
5037 }
5038
5039 #[test]
5040 fn test_ctrl_c_still_cancels_a_running_command_with_a_search_open() {
5041 let _lock = super::super::DIAG_LOCK.blocking_lock();
5042 let (mut s, _out) = running_state("market summary USDC --every 2s");
5043 s.handle_key(ctrl('f'));
5044 assert_eq!(s.handle_key(ctrl('c')), Action::Cancel);
5045 assert!(s.overlay.is_none());
5046 }
5047
5048 #[test]
5049 fn test_status_keeps_search_before_the_command_text_and_always_the_cancel_hint() {
5050 let _lock = super::super::DIAG_LOCK.blocking_lock();
5051 let (mut s, _out) = running_state("market summary USDC --every 2s");
5052 for i in 0..5 {
5053 s.push_ansi(&format!("alpha {}\n", i));
5054 }
5055 screen(&s, 140, 12);
5056 s.handle_key(ctrl('f'));
5057 type_str(&mut s, "alpha");
5058 screen(&s, 140, 12);
5059 let wide = status_row(&s, 140);
5060 assert!(
5061 wide.contains("search: alpha 1/5") && wide.contains("market summary"),
5062 "{wide}"
5063 );
5064 for w in 32..140 {
5066 let row = status_row(&s, w);
5067 assert!(row.contains("Esc closes search"), "{w}: {row:?}");
5068 if row.contains("market") {
5069 assert!(
5070 row.contains("search:"),
5071 "{w}: command without search: {row:?}"
5072 );
5073 }
5074 }
5075 let search_without_command = (26..140).any(|w| {
5076 let row = status_row(&s, w);
5077 row.contains("search:") && !row.contains("market")
5078 });
5079 assert!(search_without_command, "the command goes first");
5080 }
5081
5082 #[test]
5083 fn test_search_closes_when_the_terminal_is_too_small_and_restores_the_view() {
5084 let mut s = needle_state();
5085 s.handle_key(key(KeyCode::PageUp));
5086 let before = screen(&s, 60, 15);
5087 s.handle_key(ctrl('f'));
5088 type_str(&mut s, "needle");
5089 screen(&s, 30, 8);
5090 assert_eq!(s.handle_key(key(KeyCode::Char('x'))), Action::None);
5091 assert!(s.overlay.is_none());
5092 assert_eq!(s.input, "");
5093 assert_eq!(screen(&s, 60, 15), before);
5094 }
5095
5096 fn history_state() -> TuiState {
5097 let mut s = state();
5098 s.history = vec![
5099 "venues list".into(),
5100 "ctx".into(),
5101 "market summary USDC".into(),
5102 ];
5103 s
5104 }
5105
5106 #[test]
5107 fn test_ctrl_r_filters_the_history_and_enter_accepts_without_running() {
5108 let mut s = history_state();
5109 s.handle_key(ctrl('r'));
5110 type_str(&mut s, "ven");
5111 assert_eq!(s.input, "venues list");
5112 screen(&s, 80, 20);
5113 assert!(status_row(&s, 80).contains("1/1"), "{}", status_row(&s, 80));
5114 assert_eq!(s.handle_key(key(KeyCode::Enter)), Action::None);
5115 assert!(s.overlay.is_none());
5116 assert_eq!(s.input, "venues list", "accepted into the input, not run");
5117 }
5118
5119 #[test]
5120 fn test_ctrl_r_again_cycles_to_older_matches() {
5121 let mut s = history_state();
5122 s.history.push("venues x".into());
5123 s.handle_key(ctrl('r'));
5124 type_str(&mut s, "ven");
5125 assert_eq!(s.input, "venues x", "most recent first");
5126 s.handle_key(ctrl('r'));
5127 assert_eq!(s.input, "venues list");
5128 s.handle_key(ctrl('r'));
5129 assert_eq!(s.input, "venues x", "wraps");
5130 }
5131
5132 #[test]
5133 fn test_esc_in_history_search_restores_the_draft() {
5134 let mut s = history_state();
5135 type_str(&mut s, "dra");
5136 s.handle_key(ctrl('r'));
5137 type_str(&mut s, "ven");
5138 assert_eq!(s.input, "venues list");
5139 s.handle_key(key(KeyCode::Esc));
5140 assert_eq!(s.input, "dra");
5141 assert!(s.overlay.is_none());
5142 }
5143
5144 #[test]
5145 fn test_history_search_with_no_match_says_so_and_keeps_the_draft() {
5146 let mut s = history_state();
5147 type_str(&mut s, "dra");
5148 s.handle_key(ctrl('r'));
5149 type_str(&mut s, "zzz");
5150 screen(&s, 80, 20);
5151 assert!(status_row(&s, 80).contains("no match"));
5152 assert_eq!(s.input, "dra");
5153 for _ in 0..3 {
5154 s.handle_key(key(KeyCode::Backspace));
5155 }
5156 assert_eq!(s.input, "dra", "an empty query matches nothing");
5157 }
5158
5159 #[test]
5160 fn test_a_paste_goes_into_the_history_query() {
5161 let mut s = history_state();
5162 s.handle_key(ctrl('r'));
5163 s.paste("market");
5164 assert_eq!(s.input, "market summary USDC");
5165 }
5166
5167 #[test]
5168 fn test_n_and_shift_n_go_in_opposite_directions_over_three_matches() {
5169 let mut s = state();
5170 s.push_ansi("alpha\nalpha2\nbeta\nalpha3\n");
5171 screen(&s, 80, 20);
5172 open_search(&mut s, "alpha");
5173 let at = |s: &TuiState| status_row(s, 80);
5174 assert!(at(&s).contains("1/3"));
5175 s.handle_key(key(KeyCode::Enter));
5176 let shift_n = KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT);
5177 s.handle_key(shift_n);
5178 assert!(
5179 at(&s).contains("3/3"),
5180 "N wraps from the newest to the oldest"
5181 );
5182 s.handle_key(key(KeyCode::Char('n')));
5183 assert!(
5184 at(&s).contains("1/3"),
5185 "n wraps from the oldest to the newest"
5186 );
5187 s.handle_key(key(KeyCode::Char('n')));
5188 assert!(at(&s).contains("2/3"));
5189 s.handle_key(shift_n);
5190 assert!(at(&s).contains("1/3"), "N from the middle goes newer");
5191 s.handle_key(key(KeyCode::Char('n')));
5192 s.handle_key(key(KeyCode::Char('n')));
5193 assert!(at(&s).contains("3/3"));
5194 s.handle_key(key(KeyCode::Char('n')));
5195 assert!(at(&s).contains("1/3"));
5196 }
5197
5198 fn fill_to_limit(s: &mut TuiState, filler_first: usize) {
5199 for i in 0..filler_first {
5200 s.push_ansi(&format!("filler {i}\n"));
5201 }
5202 s.push_ansi("needle one\n");
5203 for i in 0..100 {
5204 s.push_ansi(&format!("more {i}\n"));
5205 }
5206 s.push_ansi("needle two\n");
5207 while s.scrollback.len() < SCROLLBACK_LIMIT {
5208 s.push_ansi("tail\n");
5209 }
5210 }
5211
5212 #[test]
5213 fn test_search_keeps_its_match_when_the_scrollback_drops_old_lines() {
5214 let mut s = state();
5215 fill_to_limit(&mut s, 100);
5216 assert_eq!(s.scrollback.len(), SCROLLBACK_LIMIT);
5217 screen(&s, 80, 20);
5218 open_search(&mut s, "needle");
5219 s.handle_key(key(KeyCode::Enter));
5220 s.handle_key(key(KeyCode::Char('n')));
5221 assert!(status_row(&s, 80).contains("2/2"), "on the older match");
5222 for i in 0..50 {
5223 s.push_ansi(&format!("new {i}\n"));
5224 }
5225 let row = screen_status(&s);
5226 assert!(
5227 row.contains("search: needle 2/2"),
5228 "still the same match: {row}"
5229 );
5230 s.handle_key(key(KeyCode::Char('n')));
5231 assert!(status_row(&s, 80).contains("1/2"));
5232 s.handle_key(key(KeyCode::Char('n')));
5233 for i in 0..100 {
5234 s.push_ansi(&format!("newer {i}\n"));
5235 }
5236 let row = screen_status(&s);
5238 assert!(row.contains("needle 1/1"), "{row}");
5239 }
5240
5241 fn screen_status(s: &TuiState) -> String {
5242 screen(s, 80, 20);
5243 status_row(s, 80)
5244 }
5245
5246 #[test]
5247 fn test_search_finds_the_live_output_of_a_running_command() {
5248 let _lock = super::super::DIAG_LOCK.blocking_lock();
5249 let (mut s, out) = running_state("market summary USDC --every 2s");
5250 s.push_ansi("needle done\n");
5251 out.write_out(format_args!("needle live\nother\n")).unwrap();
5252 screen(&s, 80, 20);
5253 open_search(&mut s, "needle");
5254 let row = status_row(&s, 140);
5255 assert!(row.contains("search: needle 1/2"), "{row}");
5256 assert!(screen(&s, 80, 20).matches('▶').count() == 2);
5257 }
5258
5259 #[test]
5260 fn test_opening_a_search_keeps_the_top_of_the_view_where_it_was() {
5261 let mut s = state();
5264 for i in 0..40 {
5265 s.push_ansi(&format!("r{i:02} {}\n", "x".repeat(55)));
5266 }
5267 screen(&s, 60, 15);
5268 s.handle_key(key(KeyCode::PageUp));
5269 s.handle_key(key(KeyCode::PageUp));
5270 let top_label = |text: &str| text.lines().nth(1).unwrap()[..3].trim().to_string();
5271 let before = top_label(&screen(&s, 60, 15));
5272 s.handle_key(ctrl('f'));
5273 let during = screen(&s, 60, 15);
5274 assert!(
5275 during.lines().nth(1).unwrap().contains(&before),
5276 "{before} stays at the top: {during}"
5277 );
5278 s.handle_key(key(KeyCode::Esc));
5279 assert_eq!(top_label(&screen(&s, 60, 15)), before);
5280 }
5281
5282 #[test]
5283 fn test_the_status_does_not_offer_pgdn_while_a_search_is_open() {
5284 let mut s = scrollable();
5285 s.handle_key(key(KeyCode::PageUp));
5286 assert!(status_row(&s, 80).contains("PgDn"));
5287 s.handle_key(ctrl('f'));
5288 screen(&s, 60, 15);
5289 let row = status_row(&s, 80);
5290 assert!(row.contains("scrolled up"), "{row}");
5291 assert!(!row.contains("PgDn"), "{row}");
5292 }
5293
5294 #[test]
5295 fn test_search_matches_the_text_of_colored_output_not_its_codes() {
5296 let mut s = state();
5297 s.push_ansi("al\x1b[31mpha\x1b[0m\n");
5298 screen(&s, 80, 20);
5299 open_search(&mut s, "alpha");
5300 assert!(status_row(&s, 80).contains("1/1"));
5301 s.handle_key(key(KeyCode::Esc));
5302 open_search(&mut s, "31m");
5303 assert!(status_row(&s, 80).contains("no match"));
5304 }
5305
5306 fn buffer(s: &TuiState, w: u16, h: u16) -> ratatui::buffer::Buffer {
5309 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
5310 term.draw(|f| s.render(f)).unwrap();
5311 term.backend().buffer().clone()
5312 }
5313
5314 fn row_text(buf: &ratatui::buffer::Buffer, y: u16) -> String {
5315 (0..buf.area.width)
5316 .map(|x| buf[(x, y)].symbol().to_string())
5317 .collect()
5318 }
5319
5320 fn rule_fg(buf: &ratatui::buffer::Buffer) -> Color {
5322 let x = (0..buf.area.width)
5323 .find(|&x| buf[(x, 0)].symbol() == "─")
5324 .expect("a rule cell");
5325 buf[(x, 0)].fg
5326 }
5327
5328 fn failed_state() -> TuiState {
5329 let mut s = state();
5330 s.start("x", &pane_invocation());
5331 s.finish(Ending::Finished(Err(scope::error::ScopeError::Chain(
5332 "boom".into(),
5333 ))));
5334 s
5335 }
5336
5337 #[test]
5338 fn test_pane_rule_is_accent_idle_warn_running_and_err_after_a_failure() {
5339 let idle = state();
5341 assert_eq!(rule_fg(&buffer(&idle, 60, 14)), Color::Cyan);
5342 let (running, _out) = running_state("venues list");
5343 assert_eq!(rule_fg(&buffer(&running, 60, 14)), Color::Yellow);
5344 let failed = failed_state();
5345 assert_eq!(rule_fg(&buffer(&failed, 60, 14)), Color::Red);
5346 }
5347
5348 #[test]
5349 fn test_failed_rule_lasts_until_the_next_command_starts() {
5350 let mut s = failed_state();
5351 assert_eq!(s.pane_state(), PaneState::Failed);
5352 s.start("venues list", &pane_invocation());
5353 assert_eq!(s.pane_state(), PaneState::Running);
5354 s.finish(Ending::Finished(Ok(())));
5355 assert_eq!(s.pane_state(), PaneState::Idle);
5356 }
5357
5358 #[test]
5359 fn test_cancelled_run_leaves_the_rule_idle() {
5360 let mut s = state();
5361 run_pane(&mut s, "x\n", Ending::Cancelled);
5362 assert_eq!(s.pane_state(), PaneState::Idle);
5363 }
5364
5365 #[test]
5366 fn test_state_word_is_at_the_right_end_of_the_top_rule_and_title_stays_left() {
5367 let buf = buffer(&state(), 60, 14);
5368 let top = row_text(&buf, 0);
5369 let title = format!(" scope v{} ", scope::VERSION);
5370 assert!(top.starts_with(&title), "{top:?}");
5371 assert!(top.trim_end().ends_with(" idle"), "{top:?}");
5372 assert!(buf[(1, 0)].modifier.contains(Modifier::BOLD), "title bold");
5373 assert_eq!(buf[(1, 0)].fg, Color::Cyan);
5374 }
5375
5376 #[test]
5377 fn test_prompt_spans_scope_accent_chain_key_rest_default() {
5378 let mut s = state();
5379 s.ctx.chain = "base".into();
5380 let prompt = s.prompt();
5381 assert_eq!(prompt.to_string(), "scope:base> ");
5382 assert_eq!(prompt.spans[0].style, ACCENT);
5383 assert_eq!(prompt.spans[1].style, KEY);
5384 assert_eq!(prompt.spans[2].style, Style::new());
5385 s.push_echo("venues list");
5386 let echo = last_line(&s);
5387 assert_eq!(echo.spans[0].style, ACCENT);
5388 assert_eq!(echo.spans[1].style, KEY);
5389 assert_eq!(echo.spans[3].content, "venues list");
5390 assert_eq!(echo.spans[3].style, Style::new());
5391 }
5392
5393 #[test]
5394 fn test_notes_carry_an_info_glyph_and_dim_text() {
5395 let mut s = state();
5396 s.push_note("hello");
5397 let note = last_line(&s);
5398 assert_eq!(note.to_string(), "ℹ hello");
5399 assert_eq!(note.spans[0].style, INFO);
5400 assert_eq!(note.spans[1].style, SECONDARY);
5401 }
5402
5403 #[test]
5404 fn test_selected_palette_row_is_reversed_cyan() {
5405 let mut s = state();
5406 s.handle_key(ctrl_k());
5407 let buf = buffer(&s, 100, 24);
5408 let selected = buf.content.iter().any(|c| {
5409 c.fg == Color::Cyan && c.modifier.contains(Modifier::REVERSED) && c.symbol() != " "
5410 });
5411 assert!(selected, "a reversed cyan palette row");
5412 let names = buf
5414 .content
5415 .iter()
5416 .any(|c| c.fg == Color::Cyan && !c.modifier.contains(Modifier::REVERSED));
5417 assert!(names);
5418 }
5419
5420 #[test]
5421 fn test_search_match_is_reversed_yellow() {
5422 let mut s = alpha_state();
5423 open_search(&mut s, "alpha");
5424 let buf = buffer(&s, 80, 20);
5425 let hits = buf
5426 .content
5427 .iter()
5428 .filter(|c| c.fg == Color::Yellow && c.modifier.contains(Modifier::REVERSED))
5429 .count();
5430 assert!(hits >= 2 * "alpha".len(), "{hits} matched cells");
5431 }
5432
5433 #[test]
5434 fn test_status_values_are_accent_and_labels_secondary() {
5435 let line = fit_status(&state().status_parts(), 100);
5436 let find = |text: &str| {
5437 line.spans
5438 .iter()
5439 .find(|sp| sp.content.contains(text))
5440 .unwrap_or_else(|| panic!("no span with {text:?}"))
5441 };
5442 assert_eq!(find("chain").style, SECONDARY);
5443 assert_eq!(find("auto").style, ACCENT);
5444 assert_eq!(find("format").style, SECONDARY);
5445 assert_eq!(find("help").style, ACCENT);
5446 }
5447
5448 #[test]
5449 fn test_running_status_is_warn_and_esc_cancels_is_bold() {
5450 let (s, _out) = running_state("venues list");
5451 let line = fit_status(&s.status_parts(), 120);
5452 let find = |text: &str| {
5453 line.spans
5454 .iter()
5455 .find(|sp| sp.content.contains(text))
5456 .unwrap_or_else(|| panic!("no span with {text:?}"))
5457 };
5458 assert_eq!(find("running").style, WARN);
5459 assert_eq!(
5460 find("Esc cancels").style,
5461 Style::new().add_modifier(Modifier::BOLD)
5462 );
5463 assert_eq!(find("`venues list`").style, ACCENT);
5464 }
5465
5466 #[test]
5467 fn test_hint_next_argument_is_a_key_and_busy_note_is_warn() {
5468 let mut s = state();
5469 type_str(&mut s, "address");
5470 let buf = buffer(&s, 100, 14);
5471 let hint_row = 14 - 2;
5472 let next = (0..100u16)
5473 .rfind(|&x| buf[(x, hint_row)].symbol() == "<")
5474 .unwrap();
5475 assert_eq!(buf[(next, hint_row)].fg, Color::Magenta);
5476 assert!(buf[(next, hint_row)].modifier.contains(Modifier::BOLD));
5477 let (mut busy, _out) = running_state("venues list");
5478 busy.busy_hint = true;
5479 let buf = buffer(&busy, 100, 14);
5480 let x = (0..100u16)
5481 .find(|&x| buf[(x, hint_row)].symbol() == "A")
5482 .unwrap();
5483 assert_eq!(buf[(x, hint_row)].fg, Color::Yellow);
5484 }
5485
5486 #[test]
5487 fn test_with_color_off_every_state_still_differs_by_text() {
5488 let strip = |s: &TuiState| {
5490 let mut buf = buffer(s, 60, 14);
5491 crate::cli::plain::strip_color(&mut buf);
5492 assert!(buf.content.iter().all(|c| c.fg == Color::Reset));
5493 buf
5494 };
5495 let idle = row_text(&strip(&state()), 0);
5496 let (running, _out) = running_state("venues list");
5497 let running = row_text(&strip(&running), 0);
5498 let failed = row_text(&strip(&failed_state()), 0);
5499 assert!(idle.trim_end().ends_with(" idle"), "{idle:?}");
5500 assert!(running.trim_end().ends_with(" running"), "{running:?}");
5501 assert!(failed.trim_end().ends_with(" failed"), "{failed:?}");
5502 let mut s = state();
5504 run_pane(&mut s, "x\n", Ending::Finished(Ok(())));
5505 assert!(last_line(&s).to_string().starts_with("✓ Done in "));
5506 let failed = failed_state();
5507 assert!(last_line(&failed).to_string().starts_with("✗ Failed in "));
5508 assert!(
5510 last_line(&failed)
5511 .style
5512 .add_modifier
5513 .contains(Modifier::BOLD)
5514 );
5515 let buf = strip(&failed);
5516 assert!(
5517 buf.content
5518 .iter()
5519 .any(|c| c.symbol() == "✗" && c.modifier.contains(Modifier::BOLD))
5520 );
5521 }
5522
5523 #[test]
5524 fn test_ctrl_k_and_tab_open_no_overlay_while_the_terminal_is_too_small() {
5525 let mut s = state();
5527 type_str(&mut s, "m");
5528 screen(&s, 80, 24);
5529 s.handle_key(key(KeyCode::Tab));
5530 assert!(
5531 matches!(s.overlay, Some(Overlay::Menu(_))),
5532 "Tab offers a menu at a normal size"
5533 );
5534 s.handle_key(key(KeyCode::Esc));
5535 screen(&s, 30, 8);
5536 s.handle_key(key(KeyCode::Tab));
5537 assert!(s.overlay.is_none());
5538 assert_eq!(s.input, "m", "Tab did not complete unseen");
5539 s.handle_key(ctrl('k'));
5540 assert!(s.overlay.is_none());
5541 }
5542
5543 #[test]
5544 fn test_status_styles_come_from_the_parts_not_from_their_text() {
5545 let mut s = alpha_state();
5548 s.push_ansi("x · running\n");
5549 open_search(&mut s, "x · running");
5550 let line = fit_status(&s.status_parts(), 120);
5551 let at = |text: &str| {
5552 line.spans
5553 .iter()
5554 .position(|sp| sp.content.contains(text))
5555 .unwrap_or_else(|| panic!("no {text:?} in {line:?}"))
5556 };
5557 for sp in &line.spans[at("search:")..=at("1/1")] {
5558 assert_eq!(sp.style, ACCENT, "{sp:?} in {line:?}");
5559 }
5560 let (run, _out) = running_state("venues list");
5563 let line = fit_status(&run.status_parts(), 120);
5564 let style_of = |text: &str| {
5565 line.spans
5566 .iter()
5567 .find(|sp| sp.content == text)
5568 .unwrap_or_else(|| panic!("no span {text:?} in {line:?}"))
5569 .style
5570 };
5571 assert_eq!(
5572 style_of("Esc cancels"),
5573 Style::new().add_modifier(Modifier::BOLD)
5574 );
5575 assert_eq!(style_of(STATUS_SEP), SECONDARY);
5576 assert_eq!(style_of("`venues list`"), ACCENT);
5577 }
5578
5579 #[test]
5580 fn test_help_shows_key_names_in_the_key_style() {
5581 let mut s = state();
5582 assert!(matches!(s.submit("help", &Config::default()), Plan::Done));
5583 let span = s
5584 .scrollback
5585 .iter()
5586 .flat_map(|l| l.spans.iter())
5587 .find(|sp| sp.content == "Ctrl-K")
5588 .expect("help names Ctrl-K");
5589 assert_eq!(span.style.fg, Some(Color::Magenta));
5590 assert!(span.style.add_modifier.contains(Modifier::BOLD));
5591 assert_eq!(span.style, Style::reset().patch(KEY));
5594 let what = s
5595 .scrollback
5596 .iter()
5597 .flat_map(|l| l.spans.iter())
5598 .find(|sp| sp.content == "palette")
5599 .expect("help says what Ctrl-K does");
5600 assert_eq!(what.style, Style::reset().patch(SECONDARY));
5601 }
5602
5603 #[test]
5604 fn test_any_accepted_line_clears_the_failed_state() {
5605 for line in ["ctx", "format json", "setup"] {
5608 let mut s = failed_state();
5609 assert_eq!(s.pane_state(), PaneState::Failed);
5610 s.submit(line, &Config::default());
5611 assert_eq!(s.pane_state(), PaneState::Idle, "{line}");
5612 }
5613 let mut s = failed_state();
5615 s.submit(" ", &Config::default());
5616 assert_eq!(s.pane_state(), PaneState::Failed);
5617 }
5618}