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