Skip to main content

scope_cli/cli/tui/
app.rs

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