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