Skip to main content

scope_cli/cli/tui/
app.rs

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