Skip to main content

scope_cli/cli/tui/
mod.rs

1//! # Unified TUI
2//!
3//! `scope tui` (also `scope interactive` and `scope shell`) opens one
4//! full-screen terminal UI. Every CLI command runs inside it with the same
5//! flags and the same handlers as on the command line (issue #47).
6//!
7//! ```text
8//!  scope vX.Y.Z ──────────────────────────────────────── idle
9//! scope:auto> address 0x742d…
10//! ┌─ Address Analysis ─…
11//! ──────────────────────────────────────────────────────────
12//! ┌scope:auto> ────────────────────────────────────────────┐
13//! │ market summary USDC --venue binance                    │
14//! └────────────────────────────────────────────────────────┘
15//!  <next argument hint>
16//!  chain auto · format Table · limit 100      help · exit
17//! ```
18//!
19//! The output pane has a top and a bottom rule and no side borders. The
20//! top rule names the version and the pane state (idle, running, failed).
21//!
22//! - [`exec`] parses a line: session commands, or the CLI's clap definition.
23//! - [`app`] holds the state and draws the screen.
24//! - [`journal`] keeps the history and the event log.
25//!
26//! A command that reads stdin or draws its own screen (the setup wizard,
27//! the live monitor) runs in suspend mode: the TUI gives the terminal back
28//! while it runs. What it writes through its output also joins the
29//! scrollback when the TUI returns.
30
31pub mod app;
32pub mod clip;
33pub mod complete;
34pub mod crash;
35pub mod exec;
36pub mod fit;
37pub mod grid;
38pub mod hint;
39pub mod journal;
40pub(crate) mod market_view;
41pub mod palette;
42pub(crate) mod repeat_view;
43pub(crate) mod venue_charts;
44pub mod vocab;
45pub mod wizard;
46
47use app::{Action, Ending, TuiState, run_invocation};
48use crossterm::event::{Event, EventStream, KeyEventKind, MouseEventKind};
49use exec::{Plan, Route, WatchCommand};
50use futures::StreamExt;
51use futures::stream::BoxStream;
52use journal::Journal;
53use scope::chains::ChainClientFactory;
54use scope::config::Config;
55use scope::error::{Result, ScopeError};
56use std::future::Future;
57use std::io::{self, Write};
58use std::pin::Pin;
59use std::sync::atomic::{AtomicBool, Ordering};
60use std::sync::{Arc, Mutex};
61use std::time::{Duration, Instant};
62
63use crate::cli::interactive::SessionContext;
64use crate::cli::output::{Captured, Output};
65use crate::cli::prompt;
66use crate::cli::screen;
67
68/// How long `run_suspended` waits for the signal behind a stopped
69/// prompt. The signal is normally already queued.
70const PROMPT_SIGNAL_WAIT: Duration = Duration::from_secs(1);
71
72/// How often the screen redraws while a command runs, so its output and
73/// elapsed time stay current.
74const LIVE_REDRAW: Duration = Duration::from_millis(100);
75
76/// How many bytes of a suspend-mode command's output the TUI keeps to
77/// show after it returns: about the scrollback limit of 200-column lines.
78/// A long crawl cannot grow it further.
79const OUTSIDE_COPY_LIMIT: usize = app::SCROLLBACK_LIMIT * 200;
80
81/// The startup banner.
82const BANNER: &str = include_str!("../../../assets/banner.txt");
83
84/// The shell convention for the exit code after a fatal signal: 128 plus
85/// the signal number.
86const SIGNAL_EXIT_BASE: i32 = 128;
87
88/// How the TUI ended.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct TuiExit {
91    /// The process exit code: 0 when the user left, 128+N after signal N.
92    pub code: i32,
93}
94
95/// A process signal the TUI listens for. Off Unix only Ctrl-C (`Int`)
96/// arrives; the other variants stay so one match covers every platform.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[cfg_attr(not(unix), allow(dead_code))]
99pub(crate) enum Signal {
100    Term,
101    Hup,
102    Int,
103    Quit,
104    Tstp,
105    Cont,
106}
107
108impl Signal {
109    /// The name without `SIG`, as the journal records it.
110    fn name(self) -> &'static str {
111        match self {
112            Signal::Term => "TERM",
113            Signal::Hup => "HUP",
114            Signal::Int => "INT",
115            Signal::Quit => "QUIT",
116            Signal::Tstp => "TSTP",
117            Signal::Cont => "CONT",
118        }
119    }
120
121    /// True for a signal that ends a suspend-mode command: INT cancels
122    /// it, TERM, HUP and QUIT end the session.
123    fn ends_suspended(self) -> bool {
124        matches!(
125            self,
126            Signal::Int | Signal::Term | Signal::Hup | Signal::Quit
127        )
128    }
129
130    /// The exit code for a signal that ends the TUI. `None` for a signal
131    /// that does not end it. The numbers are fixed by POSIX.
132    fn exit_code(self) -> Option<i32> {
133        let number = match self {
134            Signal::Hup => 1,
135            Signal::Int => 2,
136            Signal::Quit => 3,
137            Signal::Term => 15,
138            Signal::Tstp | Signal::Cont => return None,
139        };
140        Some(SIGNAL_EXIT_BASE + number)
141    }
142}
143
144/// Checks that the TUI has a terminal to draw on and read keys from.
145///
146/// `scope tui` needs stdin and stdout on a terminal, and a `TERM` that is
147/// not `dumb`. Pure: the caller passes the facts.
148///
149/// # Errors
150///
151/// Returns the message to print when any of them is missing.
152pub fn tui_terminal_ok(
153    stdin_tty: bool,
154    stdout_tty: bool,
155    term: Option<&str>,
156) -> std::result::Result<(), String> {
157    if stdin_tty && stdout_tty && term != Some("dumb") {
158        return Ok(());
159    }
160    Err("scope tui needs an interactive terminal; run the command directly, e.g. scope market summary …".to_string())
161}
162
163/// What the TUI's commands run with: the config, and the chain clients
164/// built from it.
165pub struct Settings {
166    pub config: Config,
167    pub clients: Box<dyn ChainClientFactory>,
168}
169
170/// Reads the [`Settings`] again, after `setup` saved new ones (#99).
171pub type Reload<'a> = &'a dyn Fn() -> Result<Settings>;
172
173/// Runs the TUI until the user leaves or a signal ends it.
174///
175/// # Errors
176///
177/// Returns an error when the signal handlers cannot be installed, a
178/// terminal I/O error, or the error from saving the history or the
179/// session context on exit.
180pub async fn run(no_banner: bool, settings: Settings, reload: Reload<'_>) -> Result<TuiExit> {
181    run_with(no_banner, None, settings, reload).await
182}
183
184/// [`run`], then `start` once the loop runs: `scope monitor <args>` opens
185/// the TUI with the monitor wizard (#47).
186///
187/// # Errors
188///
189/// As [`run`].
190pub async fn run_with(
191    no_banner: bool,
192    start: Option<crate::cli::Commands>,
193    settings: Settings,
194    reload: Reload<'_>,
195) -> Result<TuiExit> {
196    let signals = signal_stream()?;
197    // A second stream gets the same signals (tokio gives each one to
198    // every listener). Only `scope tui` installs these handlers; a plain
199    // CLI run keeps the default Ctrl-C.
200    tokio::spawn(cancel_prompts_on(signal_stream()?, &prompt::CANCEL));
201    // The crash hook, then raw mode before the loads, so keys typed
202    // during startup are not read in cooked mode (issue #63). A failed
203    // `enter` has given the screen back already. Nothing after it can
204    // return an error before `session`, which restores the terminal.
205    crash::install_panic_hook();
206    let mut host = RealHost {
207        // The mouse wheel scrolls the output. Selecting text with the mouse
208        // then takes Shift+drag (Ctrl-Y copies the last output).
209        terminal: screen::enter()?,
210        events: None,
211        signals,
212        on_screen: true,
213    };
214    let mut state = TuiState::new(
215        SessionContext::load(),
216        Journal::in_data_dir(),
217        vocab::Vocab::load(&settings.config),
218    );
219    welcome(&mut state, no_banner);
220    state.start = start;
221    session(
222        &mut host,
223        &mut state,
224        settings,
225        reload,
226        SessionContext::save,
227    )
228    .await
229}
230
231/// Runs the event loop, then restores the terminal and saves, also after
232/// a signal. `save_ctx` saves the session context.
233async fn session<H: Host>(
234    host: &mut H,
235    state: &mut TuiState,
236    settings: Settings,
237    reload: Reload<'_>,
238    save_ctx: impl FnOnce(&SessionContext) -> Result<()>,
239) -> Result<TuiExit> {
240    let result = event_loop(host, state, settings, reload).await;
241    // The CLI after the TUI, also after an error or a signal, uses the
242    // real width.
243    host.set_width(None);
244    host.restore();
245
246    // Save both even if one fails, then report the first failure.
247    let saved_history = state.journal().save_history(&state.history);
248    let saved_ctx = save_ctx(&state.ctx);
249    let exit = result?;
250    saved_history?;
251    saved_ctx?;
252    Ok(exit)
253}
254
255/// The signals the TUI listens for, as one stream. Installing a handler
256/// replaces the default action, so these no longer kill the process.
257#[cfg(unix)]
258fn signal_stream() -> Result<BoxStream<'static, Signal>> {
259    use tokio::signal::unix::{SignalKind, signal};
260    let kinds = [
261        (SignalKind::terminate(), Signal::Term),
262        (SignalKind::hangup(), Signal::Hup),
263        (SignalKind::interrupt(), Signal::Int),
264        (SignalKind::quit(), Signal::Quit),
265        (SignalKind::from_raw(libc::SIGTSTP), Signal::Tstp),
266        (SignalKind::from_raw(libc::SIGCONT), Signal::Cont),
267    ];
268    let mut streams = Vec::with_capacity(kinds.len());
269    for (kind, sig) in kinds {
270        let mut handler = signal(kind).map_err(|e| {
271            ScopeError::io_context(e, format!("cannot install the SIG{} handler", sig.name()))
272        })?;
273        streams.push(futures::stream::poll_fn(move |cx| {
274            handler.poll_recv(cx).map(|got| got.map(|()| sig))
275        }));
276    }
277    Ok(futures::stream::select_all(streams).boxed())
278}
279
280/// Ctrl-C at the console, as INT. The TUI's raw mode reads Ctrl-C as a
281/// key, so this fires only while a suspend-mode command has the cooked
282/// console. Installing the handler replaces the default, which ends the
283/// process.
284#[cfg(windows)]
285fn signal_stream() -> Result<BoxStream<'static, Signal>> {
286    let mut handler = tokio::signal::windows::ctrl_c()
287        .map_err(|e| ScopeError::io_context(e, "cannot install the Ctrl-C handler"))?;
288    Ok(futures::stream::poll_fn(move |cx| {
289        handler.poll_recv(cx).map(|got| got.map(|()| Signal::Int))
290    })
291    .boxed())
292}
293
294/// No signal handling off unix and Windows.
295#[cfg(not(any(unix, windows)))]
296fn signal_stream() -> Result<BoxStream<'static, Signal>> {
297    Ok(futures::stream::pending().boxed())
298}
299
300fn welcome(state: &mut TuiState, no_banner: bool) {
301    if !no_banner {
302        state.push_ansi(BANNER);
303    }
304    state.push_note("Type `help` for commands. `exit` or Ctrl-D leaves.");
305    // First steps (#66): a few commands to try, and the two keys that find
306    // the rest.
307    state.push_note(
308        "Try: `market summary USDC --venue binance` · `venues list` · `insights <address>`",
309    );
310    state.push_note("Tab completes a command or flag; Ctrl-K opens the command palette.");
311}
312
313/// Serializes tests that redirect the process-global diagnostics sink
314/// (every test that calls `TuiState::start`). Without it, parallel tests
315/// swap each other's sink. tokio's Mutex, so async tests can hold it
316/// across `.await` without `clippy::await_holding_lock`.
317#[cfg(test)]
318pub(crate) static DIAG_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
319
320/// The terminal side of the event loop. Production uses the real terminal;
321/// tests use a `TestBackend` and a scripted key stream.
322pub(crate) trait Host {
323    type B: ratatui::backend::Backend;
324    type Events: futures::Stream<Item = io::Result<Event>> + Unpin;
325    type Signals: futures::Stream<Item = Signal> + Unpin;
326    fn terminal(&mut self) -> &mut ratatui::Terminal<Self::B>;
327    /// The terminal events and the process signals. One method, so the
328    /// event loop can wait on both at once.
329    fn streams(&mut self) -> (&mut Self::Events, &mut Self::Signals);
330    /// Gives the terminal back for a suspend-mode command.
331    fn suspend(&mut self);
332    /// Takes the terminal again after a suspend-mode command.
333    ///
334    /// # Errors
335    ///
336    /// The terminal cannot be taken ([`screen::not_taken`]).
337    fn resume(&mut self) -> Result<()>;
338    /// Gives the terminal back for good when the TUI ends.
339    fn restore(&mut self);
340    /// Stops the process until the shell continues it (`fg`). Call it
341    /// after `suspend`, so the shell gets a clean terminal. The kernel
342    /// sends SIGCONT when the process continues.
343    fn stop_self(&mut self) -> io::Result<()>;
344    /// Clears the screen, so the next draw repaints all of it.
345    fn clear(&mut self) -> io::Result<()>;
346    /// The output a suspend-mode command writes to.
347    fn suspended_output(&self) -> Output;
348    /// Writes `text` to the terminal as is (the OSC 52 copy sequence).
349    fn write_raw(&mut self, text: &str) -> io::Result<()>;
350    /// Sets the width that command output fits (`None`: the real terminal
351    /// width). Production sets the process-wide width override.
352    fn set_width(&mut self, width: Option<usize>);
353}
354
355/// The real terminal.
356struct RealHost {
357    terminal: ratatui::DefaultTerminal,
358    /// `None` while a suspend-mode command owns stdin: the event reader
359    /// must not consume the keys that command reads.
360    events: Option<EventStream>,
361    signals: BoxStream<'static, Signal>,
362    /// True while the TUI owns the screen (raw mode, alternate screen).
363    on_screen: bool,
364}
365
366impl Host for RealHost {
367    type B = ratatui::backend::CrosstermBackend<io::Stdout>;
368    type Events = EventStream;
369    type Signals = BoxStream<'static, Signal>;
370
371    fn terminal(&mut self) -> &mut ratatui::Terminal<Self::B> {
372        &mut self.terminal
373    }
374    fn streams(&mut self) -> (&mut EventStream, &mut Self::Signals) {
375        (
376            self.events.get_or_insert_with(EventStream::new),
377            &mut self.signals,
378        )
379    }
380    fn suspend(&mut self) {
381        self.events = None;
382        self.restore();
383    }
384    fn resume(&mut self) -> Result<()> {
385        self.terminal = screen::enter()?;
386        self.on_screen = true;
387        Ok(())
388    }
389    /// Does nothing when the screen is already given back: leaving the
390    /// alternate screen twice can move the shell's cursor.
391    fn restore(&mut self) {
392        if std::mem::take(&mut self.on_screen) {
393            screen::restore();
394        }
395    }
396    /// SIGSTOP, not SIGTSTP: the TUI handles SIGTSTP itself, and SIGSTOP
397    /// cannot be caught. The call returns when the process continues.
398    #[cfg(unix)]
399    fn stop_self(&mut self) -> io::Result<()> {
400        // SAFETY: kill takes plain integers and has no memory effects.
401        let rc = unsafe { libc::kill(libc::getpid(), libc::SIGSTOP) };
402        if rc == 0 {
403            Ok(())
404        } else {
405            Err(io::Error::last_os_error())
406        }
407    }
408    #[cfg(not(unix))]
409    fn stop_self(&mut self) -> io::Result<()> {
410        Err(io::Error::other("job control needs a unix host"))
411    }
412    fn clear(&mut self) -> io::Result<()> {
413        self.terminal.clear()
414    }
415    fn suspended_output(&self) -> Output {
416        Output::stdio()
417    }
418    fn write_raw(&mut self, text: &str) -> io::Result<()> {
419        let mut out = io::stdout();
420        out.write_all(text.as_bytes())?;
421        out.flush()
422    }
423    fn set_width(&mut self, width: Option<usize>) {
424        scope::display::terminal::set_width_override(width);
425    }
426}
427
428/// The output of a command that runs outside the TUI (suspend mode).
429struct Outside {
430    /// The plain terminal. The header goes only here: it is stale once
431    /// the TUI is back.
432    terminal: Output,
433    /// What the command writes to: the terminal, and a copy the TUI shows
434    /// in the scrollback when it returns. Its hints use TUI wording,
435    /// because they are read in the TUI.
436    out: Output,
437    copy: Captured,
438}
439
440impl Outside {
441    fn new(terminal: Output) -> Self {
442        let (out, copy) = terminal.teed(OUTSIDE_COPY_LIMIT);
443        Self {
444            terminal,
445            out,
446            copy,
447        }
448    }
449}
450
451type PaneFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
452
453/// Rows one notch of the mouse wheel scrolls.
454const WHEEL_ROWS: isize = 3;
455
456/// After `setup` in the TUI: the session read the config again.
457const SETUP_RELOADED_NOTE: &str = "The TUI now uses the saved settings.";
458
459/// After `setup`, when the config could not be read again.
460fn setup_reload_failed_note(e: &ScopeError) -> String {
461    format!(
462        "The settings could not be read again ({e}). Leave the TUI (exit) and start it again to use them."
463    )
464}
465
466async fn event_loop<H: Host>(
467    host: &mut H,
468    state: &mut TuiState,
469    settings: Settings,
470    reload: Reload<'_>,
471) -> Result<TuiExit> {
472    // Each command holds its own handle on the settings, so `setup` can
473    // swap in new ones between commands.
474    let mut settings = std::rc::Rc::new(settings);
475    const LEFT: TuiExit = TuiExit { code: 0 };
476    // Library warnings between commands land here and join the scrollback.
477    let idle_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
478    let mut idle_diag = Some(scope::diag::redirect(idle_buf.clone()));
479    let mut pending: Option<PaneFuture<'_>> = None;
480    // A running watchlist (#47). It runs next to the pane command, so the
481    // shell stays usable while the grid ticks.
482    let mut watch_pending: Option<PaneFuture<'_>> = None;
483    if let Some(command) = state.start.take() {
484        watch_pending = open_monitor(state, &settings, command);
485    }
486    let mut tick = tokio::time::interval(LIVE_REDRAW);
487    // After a stop (Ctrl-Z, then `fg`), fire once, not once per missed
488    // tick: the tick arm comes before the keys in the biased select.
489    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
490    // True after the TUI stopped itself: the SIGCONT that continues it
491    // needs no second set-up.
492    let mut cont_expected = false;
493    // True after an event that changed nothing (a mouse move): the screen
494    // is not drawn again for it.
495    let mut unchanged = false;
496
497    loop {
498        drain_into(&idle_buf, state);
499        state.poll_watch();
500        if state.take_bell() {
501            // A new breach: one bell. A failed write only loses the sound.
502            let _ = host.write_raw("\x07");
503        }
504        // A question from the running command opens as a list. The live
505        // tick wakes the loop while a command runs, so it shows within one.
506        state.poll_ask();
507        if !std::mem::take(&mut unchanged) {
508            host.terminal()
509                .draw(|f| {
510                    state.render(f);
511                    crate::cli::plain::finish_frame(f);
512                })
513                // The backend's error type is generic and need not be Send,
514                // so only its text can travel.
515                .map_err(|e| {
516                    ScopeError::io_context(io::Error::other(e.to_string()), "terminal draw failed")
517                })?;
518        }
519        // Command output fits the pane. Set after each draw, so it follows
520        // a resize and comes back after the TUI leaves the terminal.
521        host.set_width(state.pane_width());
522
523        // Biased: a finished command is handled before the next key, so
524        // the key that follows it sees the command as done. A signal comes
525        // before the keys.
526        let (events, signals) = host.streams();
527        tokio::select! {
528            biased;
529            res = async { pending.as_mut().expect("guarded by precondition").await }, if pending.is_some() => {
530                pending = None;
531                state.finish(Ending::Finished(res));
532            }
533            res = async { watch_pending.as_mut().expect("guarded by precondition").await }, if watch_pending.is_some() => {
534                watch_pending = None;
535                state.watch_finished(res);
536            }
537            _ = tick.tick(), if state.running.is_some() || state.watch_running() => {}
538            Some(sig) = signals.next() => {
539                if let Some(code) = sig.exit_code() {
540                    // Drop the command future first: that cancels it.
541                    drop(pending.take());
542                    state.finish(Ending::Cancelled);
543                    state.record_signal(sig.name(), "session", "", &[]);
544                    return Ok(TuiExit { code });
545                }
546                // Merge window: the kernel keeps one pending SIGCONT, and
547                // the handler stream can merge signals too. So a second
548                // SIGCONT that comes before this arm runs arrives as one,
549                // and is taken as ours. That is safe: the screen was
550                // already taken back and cleared after the stop.
551                if sig == Signal::Tstp {
552                    cont_expected = stop_and_return(host, state)?;
553                } else if !std::mem::take(&mut cont_expected) {
554                    // SIGCONT after an outside SIGSTOP: the shell reset the
555                    // terminal, so set it up again from scratch.
556                    leave(host);
557                    retake(host)?;
558                }
559            }
560            ev = events.next() => {
561                let key = match ev {
562                    Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => k,
563                    Some(Ok(Event::Paste(text))) => {
564                        state.paste(&text);
565                        continue;
566                    }
567                    // The wheel scrolls the output; a click or a move does
568                    // nothing, and is not worth a redraw.
569                    Some(Ok(Event::Mouse(m))) => {
570                        match m.kind {
571                            MouseEventKind::ScrollUp => state.wheel(WHEEL_ROWS),
572                            MouseEventKind::ScrollDown => state.wheel(-WHEEL_ROWS),
573                            _ => unchanged = true,
574                        }
575                        continue;
576                    }
577                    Some(Ok(_)) => continue,
578                    Some(Err(e)) => return Err(ScopeError::IoError(e)),
579                    None => return Ok(LEFT),
580                };
581                match state.handle_key(key) {
582                    Action::None => {}
583                    Action::Exit => return Ok(LEFT),
584                    Action::Suspend => cont_expected = stop_and_return(host, state)?,
585                    Action::Copy(text) => {
586                        let result = host.write_raw(&clip::osc52(&text));
587                        state.copied(text.lines().count(), result);
588                    }
589                    Action::StartWatch(list) => {
590                        if let Some(run) = begin_watch(state, &settings, *list) {
591                            watch_pending = Some(run);
592                        }
593                    }
594                    Action::StopWatch => {
595                        // Dropping the future stops the run at its next await.
596                        watch_pending = None;
597                        state.stop_watch();
598                    }
599                    Action::Cancel => {
600                        // Dropping the future cancels the handler at its
601                        // next await point.
602                        pending = None;
603                        state.finish(Ending::Cancelled);
604                    }
605                    Action::Submit(line) => match state.submit(&line, &settings.config) {
606                        Plan::Done => {}
607                        Plan::Exit => return Ok(LEFT),
608                        Plan::Watch(cmd) => match cmd {
609                            WatchCommand::Open(list) => {
610                                if let Some(run) = begin_watch(state, &settings, *list) {
611                                    watch_pending = Some(run);
612                                }
613                            }
614                            WatchCommand::Stop => {
615                                if state.watch_running() {
616                                    watch_pending = None;
617                                    state.stop_watch();
618                                } else {
619                                    state.push_note("No watchlist runs.");
620                                }
621                            }
622                            WatchCommand::Show => state.show_grid(),
623                        },
624                        Plan::Run(inv) => match inv.route {
625                            Route::Pane => {
626                                let out = state.start(&line, &inv);
627                                let s = settings.clone();
628                                pending = Some(Box::pin(async move {
629                                    run_invocation(*inv, &s.config, &*s.clients, &out).await
630                                }));
631                            }
632                            Route::Suspend => {
633                                drop(idle_diag.take());
634                                leave(host);
635                                let outside = Outside::new(host.suspended_output());
636                                let argv = inv.argv.clone();
637                                let command =
638                                    run_invocation(*inv, &settings.config, &*settings.clients, &outside.out);
639                                let ended =
640                                    run_suspended(host, &line, &argv, &outside, command, state)
641                                        .await;
642                                if let Some(exit) = ended {
643                                    // The terminal stays cooked; session()
644                                    // restores and saves.
645                                    return Ok(exit);
646                                }
647                                if argv.get(1).is_some_and(|c| c == "setup") {
648                                    // Before the screen is taken back, so a
649                                    // notice from the reload shows outside.
650                                    match reload() {
651                                        Ok(new) => {
652                                            state.set_vocab(vocab::Vocab::load(&new.config));
653                                            settings = std::rc::Rc::new(new);
654                                            state.push_note(SETUP_RELOADED_NOTE);
655                                            state.settings_reloaded();
656                                        }
657                                        Err(e) => state.push_note(&setup_reload_failed_note(&e)),
658                                    }
659                                }
660                                host.resume()?;
661                                idle_diag.replace(scope::diag::redirect(idle_buf.clone()));
662                            }
663                            Route::Wizard => {
664                                if let Some(run) = open_monitor(state, &settings, inv.command) {
665                                    watch_pending = Some(run);
666                                }
667                            }
668                            Route::Refuse(reason) => {
669                                // exec::plan writes refusals and never
670                                // returns them; keep this loud anyway.
671                                state.push_note(&format!("Not available here: {}.", reason));
672                            }
673                        },
674                    },
675                }
676            }
677        }
678    }
679}
680
681/// `watch open` while a watchlist runs.
682const WATCH_BUSY_NOTE: &str =
683    "A watchlist runs already. Stop it first (`watch stop`, or Esc in the grid).";
684
685/// Starts `list` in the grid, unless a watchlist runs already.
686fn begin_watch(
687    state: &mut TuiState,
688    settings: &std::rc::Rc<Settings>,
689    list: scope::watch::Watchlist,
690) -> Option<PaneFuture<'static>> {
691    if state.watch_running() {
692        state.push_note(WATCH_BUSY_NOTE);
693        return None;
694    }
695    // A finished grid makes room for the new one.
696    state.close_grid();
697    let (out, sink, tx) = state.start_watch(list.clone());
698    let s = settings.clone();
699    Some(Box::pin(scope::diag::scoped(sink, async move {
700        run_watch(list, &s, &out, tx).await
701    })))
702}
703
704/// `monitor <args>` in the TUI: the wizard, filled from the arguments, or
705/// with `--yes` the run at once (and the file first with `--save`).
706fn open_monitor(
707    state: &mut TuiState,
708    settings: &std::rc::Rc<Settings>,
709    command: crate::cli::Commands,
710) -> Option<PaneFuture<'static>> {
711    let crate::cli::Commands::Monitor(args) = command else {
712        return None;
713    };
714    match crate::cli::watch::monitor::watchlist_from_args(&args, &settings.config) {
715        Err(e) => {
716            state.push_styled_note(&format!("monitor: {e}"), crate::cli::theme::ERR);
717            None
718        }
719        Ok((list, notes)) if args.watch.yes => {
720            for n in &notes {
721                state.push_note(n);
722            }
723            if args.watch.save.is_some() {
724                let path = scope::watch::file::path_in(&scope::paths::watchlists_dir(), &list.name);
725                match scope::watch::file::save(&path, &list) {
726                    Ok(()) => {
727                        state.push_note(&format!("Saved the watchlist to {}.", path.display()))
728                    }
729                    Err(e) => state.push_note(&format!("Could not save {}: {e}", path.display())),
730                }
731            }
732            begin_watch(state, settings, list)
733        }
734        Ok((list, notes)) => {
735            state.open_wizard(list, notes);
736            None
737        }
738    }
739}
740
741/// Runs a watchlist for the grid: resolves its targets once, then ticks on
742/// its schedule. Each step goes to the grid through `tx`; a closed grid
743/// only drops the messages.
744async fn run_watch(
745    list: scope::watch::Watchlist,
746    settings: &Settings,
747    out: &Output,
748    tx: std::sync::mpsc::Sender<grid::GridMsg>,
749) -> Result<()> {
750    let src = crate::cli::watch::Sources {
751        config: &settings.config,
752        clients: &*settings.clients,
753    };
754    let ready = crate::cli::watch::prepare(&list, src, out).await?;
755    let _ = tx.send(grid::GridMsg::Ready(ready.clone()));
756    crate::cli::watch::run(&ready, src, out, |run, tick, events| {
757        let _ = tx.send(grid::GridMsg::Tick {
758            run,
759            tick: Box::new(tick.clone()),
760            events: events.to_vec(),
761        });
762    })
763    .await
764    .map(|_| ())
765}
766
767/// Gives the terminal back and stops the process (Ctrl-Z, SIGTSTP). When
768/// the shell continues it, takes the screen again. A running pane command
769/// is kept; it is not polled while the process is stopped. Returns true
770/// when the process stopped, so a SIGCONT follows.
771fn stop_and_return<H: Host>(host: &mut H, state: &mut TuiState) -> Result<bool> {
772    leave(host);
773    let stopped = match host.stop_self() {
774        Ok(()) => true,
775        Err(e) => {
776            state.push_note(&format!("Could not stop the process: {}", e));
777            false
778        }
779    };
780    retake(host)?;
781    Ok(stopped)
782}
783
784/// Gives the terminal back. Output written while the TUI is away (a
785/// suspend-mode command, the shell after Ctrl-Z) uses the real width.
786fn leave<H: Host>(host: &mut H) {
787    host.set_width(None);
788    host.suspend();
789}
790
791/// Takes the terminal again and clears it. The next draw then repaints
792/// all of the screen, also what the shell wrote over it.
793fn retake<H: Host>(host: &mut H) -> Result<()> {
794    host.resume()?;
795    host.clear()
796        .map_err(|e| ScopeError::io_context(e, "terminal clear failed"))
797}
798
799/// Runs one command outside the TUI. It returns to the TUI when the
800/// command ends, or when INT (Ctrl-C) cancels it. `command` writes to
801/// `outside.out`; when it ends, its output and error text join the
802/// scrollback, above the `Ran outside the TUI` note. Returns the exit when
803/// TERM, HUP or QUIT ends the session first; the command is dropped and
804/// the signal recorded.
805async fn run_suspended<H: Host>(
806    host: &mut H,
807    line: &str,
808    argv: &[String],
809    outside: &Outside,
810    command: impl Future<Output = Result<()>>,
811    state: &mut TuiState,
812) -> Option<TuiExit> {
813    let started = Instant::now();
814    let started_at = chrono::Utc::now().to_rfc3339();
815    // A cancel from an earlier command must not stop this one's prompts.
816    prompt::CANCEL.store(false, Ordering::SeqCst);
817    let mut terminal_errors = Vec::new();
818    crate::note!(outside.terminal, "\n── scope {} ──", line);
819    crate::note!(
820        outside.terminal,
821        "Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
822    );
823    let ended = tokio::select! {
824        biased;
825        Some(sig) = ending_signal(host.streams().1) => Err(sig),
826        result = command => Ok(result),
827    };
828    // A prompt blocks this task, so the select can miss a signal that
829    // came meanwhile. The prompt watcher saw it and set the flag; the
830    // stream has it too, because each listener gets every signal. Take it
831    // from the stream, or the TUI would see it later. The wait has a
832    // limit, so a broken invariant cannot hang the TUI on a cooked
833    // terminal; it then counts as a cancel.
834    let ended = match ended {
835        Ok(_) if prompt::CANCEL.load(Ordering::SeqCst) => {
836            let queued =
837                tokio::time::timeout(PROMPT_SIGNAL_WAIT, ending_signal(host.streams().1)).await;
838            Err(queued.ok().flatten().unwrap_or(Signal::Int))
839        }
840        ended => ended,
841    };
842    let ending = match ended {
843        Ok(result) => Ending::Finished(result),
844        Err(Signal::Int) => Ending::Cancelled,
845        Err(sig) => return Some(end_suspended(state, line, argv, sig)),
846    };
847    let duration = started.elapsed();
848    if let Ending::Finished(Err(e)) = &ending
849        && let Err(write_err) = crate::cli::errors::display_error(e, &outside.out)
850    {
851        terminal_errors.push(write_err.to_string());
852    }
853    state.show_outside(&outside.copy, &ending);
854    state.record_suspended(line, argv, duration, started_at, &ending);
855    for e in terminal_errors {
856        state.push_note(&format!("Terminal I/O error while outside the TUI: {}", e));
857    }
858    if matches!(ending, Ending::Cancelled)
859        && let Some(sig) = drain_extra_ints(host.streams().1)
860    {
861        return Some(end_suspended(state, line, argv, sig));
862    }
863    None
864}
865
866/// Takes the signals already queued after a cancel, without waiting. A
867/// second Ctrl-C must not reach the TUI and end it. Drops INT, TSTP and
868/// CONT; returns the first TERM, HUP or QUIT, which still ends the
869/// session.
870fn drain_extra_ints<S: futures::Stream<Item = Signal> + Unpin>(signals: &mut S) -> Option<Signal> {
871    use futures::FutureExt;
872    while let Some(Some(sig)) = signals.next().now_or_never() {
873        if sig.ends_suspended() && sig != Signal::Int {
874            return Some(sig);
875        }
876    }
877    None
878}
879
880/// Waits for a signal that ends a suspend-mode command: INT cancels it;
881/// TERM, HUP and QUIT end the session. It drops TSTP and CONT, because
882/// the command owns the terminal.
883async fn ending_signal<S: futures::Stream<Item = Signal> + Unpin>(
884    signals: &mut S,
885) -> Option<Signal> {
886    while let Some(sig) = signals.next().await {
887        if sig.ends_suspended() {
888            return Some(sig);
889        }
890    }
891    None
892}
893
894/// Records that `sig` ended the suspend-mode command `line`; returns the
895/// exit for it.
896fn end_suspended(state: &mut TuiState, line: &str, argv: &[String], sig: Signal) -> TuiExit {
897    state.record_signal(sig.name(), "suspend", line, argv);
898    TuiExit {
899        code: sig.exit_code().expect("TERM, HUP and QUIT have exit codes"),
900    }
901}
902
903/// Sets `flag` on each signal that ends a suspend-mode command, so a
904/// prompt read stops (see [`prompt`]). It runs as its own task: while a
905/// prompt blocks, the event loop's task cannot see the signals.
906async fn cancel_prompts_on<S: futures::Stream<Item = Signal> + Unpin>(
907    mut signals: S,
908    flag: &AtomicBool,
909) {
910    while let Some(sig) = signals.next().await {
911        if sig.ends_suspended() {
912            flag.store(true, Ordering::SeqCst);
913        }
914    }
915}
916
917/// Moves buffered library warnings into the scrollback.
918fn drain_into(buf: &Mutex<Vec<u8>>, state: &mut TuiState) {
919    let bytes = std::mem::take(&mut *buf.lock().unwrap_or_else(|p| p.into_inner()));
920    if !bytes.is_empty() {
921        state.push_ansi(&String::from_utf8_lossy(&bytes));
922    }
923}
924
925// ============================================================================
926// Unit Tests
927// ============================================================================
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932
933    use crate::cli::Cli;
934    use ansi_to_tui::IntoText;
935    use clap::Parser;
936    use scope::chains::mocks::MockClientFactory;
937
938    #[test]
939    fn test_tui_requires_a_terminal() {
940        assert!(tui_terminal_ok(true, true, Some("xterm-256color")).is_ok());
941        assert!(tui_terminal_ok(true, true, None).is_ok());
942        for (i, o, t) in [
943            (false, true, Some("xterm")),
944            (true, false, Some("xterm")),
945            (true, true, Some("dumb")),
946        ] {
947            let e = tui_terminal_ok(i, o, t).unwrap_err();
948            assert!(
949                e.starts_with("scope tui needs an interactive terminal"),
950                "{e}"
951            );
952        }
953    }
954
955    const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
956    const TX: &str = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
957
958    /// Plain text of ANSI output, as the pane shows it.
959    fn plain(ansi: &str) -> String {
960        ansi.into_text()
961            .unwrap()
962            .lines
963            .iter()
964            .map(|l| {
965                l.spans
966                    .iter()
967                    .map(|s| s.content.as_ref())
968                    .collect::<String>()
969            })
970            .collect::<Vec<_>>()
971            .join("\n")
972    }
973
974    /// Runs a line through the TUI path; returns the pane text.
975    async fn via_tui(line: &str, factory: &MockClientFactory) -> String {
976        let config = Config::default();
977        let mut s = TuiState::new(
978            SessionContext::default(),
979            Journal::default(),
980            vocab::Vocab::default(),
981        );
982        let Plan::Run(inv) = s.submit(line, &config) else {
983            panic!("`{}` did not plan a run", line)
984        };
985        assert_eq!(inv.route, Route::Pane);
986        let out = s.start(line, &inv);
987        let res = run_invocation(*inv, &config, factory, &out).await;
988        s.finish(Ending::Finished(res));
989        s.scrollback_text()
990    }
991
992    /// Runs the same line through the CLI path, with the CLI's own
993    /// (non-TUI) output; returns the data, then the diagnostics and the
994    /// error display, as the terminal would show them.
995    async fn via_cli(line: &str, factory: &MockClientFactory) -> String {
996        let mut argv = vec!["scope".to_string()];
997        argv.extend(shlex::split(line).unwrap());
998        let cli = Cli::try_parse_from(argv).unwrap();
999        let (o, cap) = Output::capture();
1000        assert!(!o.is_tui());
1001        let res =
1002            crate::cli::dispatch::dispatch_with(cli.command, &Config::default(), factory, &o).await;
1003        if let Err(e) = &res {
1004            crate::cli::errors::display_error(e, &o).unwrap();
1005        }
1006        format!("{}\n{}", plain(&cap.out()), plain(&cap.err()))
1007    }
1008
1009    /// Issue #47, requirement 1: a TUI command calls the same handler as the
1010    /// CLI and shows the same result.
1011    async fn assert_same_result(line: &str) {
1012        let _lock = DIAG_LOCK.lock().await;
1013        let factory = MockClientFactory::new();
1014        let cli = via_cli(line, &factory).await;
1015        let tui = via_tui(line, &factory).await;
1016        assert!(!cli.trim().is_empty(), "`{}` printed nothing", line);
1017        for row in cli.lines().filter(|r| !r.trim().is_empty()) {
1018            assert!(
1019                tui.contains(row),
1020                "`{}`: the TUI pane is missing the CLI line {:?}\n--- TUI ---\n{}",
1021                line,
1022                row,
1023                tui
1024            );
1025        }
1026    }
1027
1028    #[tokio::test]
1029    async fn test_address_same_as_cli() {
1030        assert_same_result(&format!("address {}", ADDR)).await;
1031    }
1032
1033    #[tokio::test]
1034    async fn test_tx_same_as_cli() {
1035        assert_same_result(&format!("tx {}", TX)).await;
1036    }
1037
1038    #[tokio::test]
1039    async fn test_market_summary_dex_same_as_cli() {
1040        assert_same_result("market summary USDC --venue eth").await;
1041    }
1042
1043    #[tokio::test]
1044    async fn test_venues_list_json_same_as_cli() {
1045        assert_same_result("venues list --format json").await;
1046    }
1047
1048    #[tokio::test]
1049    async fn test_failed_command_shows_same_error_as_cli() {
1050        // Error text and remediation hints must match the CLI too.
1051        assert_same_result("venues validate /nonexistent/venue.yaml").await;
1052    }
1053
1054    // ---- Event loop, driven through a scripted host ----
1055
1056    use crossterm::event::{KeyCode, KeyEvent, KeyEventState, KeyModifiers};
1057    use futures::channel::mpsc;
1058    use futures::stream::BoxStream;
1059    use ratatui::Terminal;
1060    use ratatui::backend::TestBackend;
1061    use std::task::Poll;
1062
1063    /// A host with a `TestBackend` screen, a scripted key stream and a
1064    /// scripted signal stream.
1065    struct TestHost {
1066        terminal: Terminal<TestBackend>,
1067        keys: BoxStream<'static, io::Result<Event>>,
1068        signals: mpsc::UnboundedReceiver<Signal>,
1069        signal_tx: mpsc::UnboundedSender<Signal>,
1070        /// `suspend` sends these signals: they arrive while a
1071        /// suspend-mode command runs.
1072        suspend_signals: Vec<Signal>,
1073        suspended: Output,
1074        suspends: usize,
1075        resumes: usize,
1076        restores: usize,
1077        stops: usize,
1078        clears: usize,
1079        /// Calls to `terminal()`: one per loop pass (the draw).
1080        draws: usize,
1081        /// `draws` when the last `stop_self` ran.
1082        draws_at_stop: usize,
1083        /// `stop_self` fails, as `kill` could.
1084        stop_fails: bool,
1085        /// Everything `write_raw` got.
1086        raw: String,
1087        /// `write_raw` fails.
1088        raw_fails: bool,
1089        /// `resume` fails, as for a terminal that went away.
1090        resume_fails: bool,
1091        /// The width override at each draw, suspend and restore, in order.
1092        widths: Vec<(&'static str, Option<usize>)>,
1093        /// The width the loop set last.
1094        width: Option<usize>,
1095    }
1096
1097    impl Host for TestHost {
1098        type B = TestBackend;
1099        type Events = BoxStream<'static, io::Result<Event>>;
1100        type Signals = mpsc::UnboundedReceiver<Signal>;
1101        fn terminal(&mut self) -> &mut Terminal<TestBackend> {
1102            self.draws += 1;
1103            self.widths.push(("draw", self.width));
1104            &mut self.terminal
1105        }
1106        fn streams(&mut self) -> (&mut Self::Events, &mut Self::Signals) {
1107            (&mut self.keys, &mut self.signals)
1108        }
1109        fn suspend(&mut self) {
1110            self.suspends += 1;
1111            self.widths.push(("suspend", self.width));
1112            for s in self.suspend_signals.drain(..) {
1113                self.signal_tx.unbounded_send(s).unwrap();
1114            }
1115        }
1116        fn resume(&mut self) -> Result<()> {
1117            self.resumes += 1;
1118            if self.resume_fails {
1119                return Err(crate::cli::screen::not_taken(io::Error::other("no tty")));
1120            }
1121            Ok(())
1122        }
1123        fn restore(&mut self) {
1124            self.restores += 1;
1125            self.widths.push(("restore", self.width));
1126        }
1127        /// Does not stop the test process. It sends SIGCONT, as the kernel
1128        /// does when the shell continues a stopped process.
1129        fn stop_self(&mut self) -> io::Result<()> {
1130            self.stops += 1;
1131            self.draws_at_stop = self.draws;
1132            if self.stop_fails {
1133                return Err(io::Error::other("kill failed"));
1134            }
1135            self.signal_tx.unbounded_send(Signal::Cont).unwrap();
1136            Ok(())
1137        }
1138        fn clear(&mut self) -> io::Result<()> {
1139            self.clears += 1;
1140            self.terminal.clear().map_err(|never| match never {})
1141        }
1142        fn suspended_output(&self) -> Output {
1143            self.suspended.clone()
1144        }
1145        fn write_raw(&mut self, text: &str) -> io::Result<()> {
1146            self.raw.push_str(text);
1147            if self.raw_fails {
1148                return Err(io::Error::other("tty gone"));
1149            }
1150            Ok(())
1151        }
1152        /// Records the width; tests never set the process-wide override.
1153        fn set_width(&mut self, width: Option<usize>) {
1154            self.width = width;
1155        }
1156    }
1157
1158    /// What a driven session leaves: its result, state, host and the
1159    /// suspend-mode output.
1160    type Driven = (
1161        Result<TuiExit>,
1162        TuiState,
1163        TestHost,
1164        crate::cli::output::Captured,
1165    );
1166
1167    fn press(code: KeyCode) -> io::Result<Event> {
1168        Ok(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)))
1169    }
1170
1171    /// A mouse event of `kind` at the top-left cell.
1172    fn mouse(kind: MouseEventKind) -> io::Result<Event> {
1173        Ok(Event::Mouse(crossterm::event::MouseEvent {
1174            kind,
1175            column: 0,
1176            row: 0,
1177            modifiers: KeyModifiers::NONE,
1178        }))
1179    }
1180
1181    #[tokio::test]
1182    async fn test_the_wheel_scrolls_and_a_mouse_move_draws_nothing() {
1183        let _lock = DIAG_LOCK.lock().await;
1184        // `help` is longer than the test screen and opens at its top.
1185        let mut events = script(&["help"]);
1186        events.push(mouse(MouseEventKind::ScrollDown));
1187        let (res, state, host, _) = drive(events).await;
1188        res.unwrap();
1189        let after_one_notch = host.draws;
1190        let scrolled = state.scrolled();
1191
1192        let mut events = script(&["help"]);
1193        events.push(mouse(MouseEventKind::ScrollDown));
1194        for _ in 0..20 {
1195            events.push(mouse(MouseEventKind::Moved));
1196        }
1197        let (res, state2, host2, _) = drive(events).await;
1198        res.unwrap();
1199        assert_eq!(state2.scrolled(), scrolled, "a move scrolls nothing");
1200        assert_eq!(host2.draws, after_one_notch, "and draws nothing");
1201
1202        // Down, down, up: one notch down from the top, as after one.
1203        let mut events = script(&["help"]);
1204        events.push(mouse(MouseEventKind::ScrollDown));
1205        events.push(mouse(MouseEventKind::ScrollDown));
1206        events.push(mouse(MouseEventKind::ScrollUp));
1207        let (res, state3, _, _) = drive(events).await;
1208        res.unwrap();
1209        assert_eq!(
1210            state3.scrolled(),
1211            scrolled,
1212            "a notch is {WHEEL_ROWS} rows each way"
1213        );
1214    }
1215
1216    /// Key events that type each line and press Enter. `<esc>` presses Esc.
1217    fn script(lines: &[&str]) -> Vec<io::Result<Event>> {
1218        let mut events = Vec::new();
1219        for line in lines {
1220            if *line == "<esc>" {
1221                events.push(press(KeyCode::Esc));
1222                continue;
1223            }
1224            events.extend(line.chars().map(|c| press(KeyCode::Char(c))));
1225            events.push(press(KeyCode::Enter));
1226        }
1227        events
1228    }
1229
1230    /// Runs the event loop over the scripted events. The stream ends after
1231    /// them, which ends the loop.
1232    async fn drive(events: Vec<io::Result<Event>>) -> Driven {
1233        drive_with(Journal::default(), events, Vec::new()).await
1234    }
1235
1236    /// Runs a whole session (loop, restore, save) with `journal`. The
1237    /// signals arrive after the last key; then the key stream ends. The
1238    /// session context is not saved, because its real path is the user's.
1239    async fn drive_with(
1240        journal: Journal,
1241        events: Vec<io::Result<Event>>,
1242        signals: Vec<Signal>,
1243    ) -> Driven {
1244        let (host, captured) = test_host(events, signals);
1245        drive_host(journal, host, captured).await
1246    }
1247
1248    /// A test host over the scripted keys, with `signals` after the last key.
1249    fn test_host(
1250        events: Vec<io::Result<Event>>,
1251        signals: Vec<Signal>,
1252    ) -> (TestHost, crate::cli::output::Captured) {
1253        let (sig_tx, sig_rx) = mpsc::unbounded();
1254        let mut then = Some((signals, sig_tx.clone()));
1255        // After the last key, send the signals and stay pending. The send
1256        // wakes the loop, and its biased select takes the signals before
1257        // it polls the key stream again and sees the end.
1258        let tail = futures::stream::poll_fn(move |_| match then.take() {
1259            Some((sigs, tx)) if !sigs.is_empty() => {
1260                for s in sigs {
1261                    tx.unbounded_send(s).unwrap();
1262                }
1263                Poll::Pending
1264            }
1265            _ => Poll::Ready(None),
1266        });
1267        let (suspended, captured) = Output::capture_merged();
1268        let host = TestHost {
1269            terminal: Terminal::new(TestBackend::new(100, 30)).unwrap(),
1270            keys: futures::stream::iter(events).chain(tail).boxed(),
1271            signals: sig_rx,
1272            signal_tx: sig_tx,
1273            suspend_signals: Vec::new(),
1274            suspended,
1275            suspends: 0,
1276            resumes: 0,
1277            restores: 0,
1278            stops: 0,
1279            clears: 0,
1280            draws: 0,
1281            draws_at_stop: 0,
1282            stop_fails: false,
1283            raw: String::new(),
1284            raw_fails: false,
1285            resume_fails: false,
1286            widths: Vec::new(),
1287            width: None,
1288        };
1289        (host, captured)
1290    }
1291
1292    /// Runs a whole session on `host` with `journal`.
1293    async fn drive_host(
1294        journal: Journal,
1295        host: TestHost,
1296        captured: crate::cli::output::Captured,
1297    ) -> Driven {
1298        drive_host_on(journal, host, captured, Box::new(MockClientFactory::new())).await
1299    }
1300
1301    /// Settings with the default config and `clients`.
1302    fn settings_with(clients: Box<dyn ChainClientFactory>) -> Settings {
1303        Settings {
1304            config: Config::default(),
1305            clients,
1306        }
1307    }
1308
1309    /// [`drive_host`] with the client factory `clients`. A reload after
1310    /// `setup` gives the mock factory.
1311    async fn drive_host_on(
1312        journal: Journal,
1313        host: TestHost,
1314        captured: crate::cli::output::Captured,
1315        clients: Box<dyn ChainClientFactory>,
1316    ) -> Driven {
1317        let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
1318        drive_host_reloading(journal, host, captured, settings_with(clients), &reload).await
1319    }
1320
1321    /// [`drive_host`] with `settings`, and `reload` after `setup`.
1322    async fn drive_host_reloading(
1323        journal: Journal,
1324        mut host: TestHost,
1325        captured: crate::cli::output::Captured,
1326        settings: Settings,
1327        reload: Reload<'_>,
1328    ) -> Driven {
1329        let mut state = TuiState::new(SessionContext::default(), journal, vocab::Vocab::default());
1330        welcome(&mut state, true);
1331        let res = session(&mut host, &mut state, settings, reload, |_| Ok(())).await;
1332        (res, state, host, captured)
1333    }
1334
1335    /// A DEX source whose every lookup waits forever: a command that uses
1336    /// it stays running until it is cancelled, with no network (#39).
1337    struct PendingDex;
1338
1339    #[async_trait::async_trait]
1340    impl scope::chains::DexDataSource for PendingDex {
1341        async fn get_token_price(&self, _: &str, _: &str) -> Option<f64> {
1342            std::future::pending().await
1343        }
1344        async fn get_native_token_price(&self, _: &str) -> Option<f64> {
1345            std::future::pending().await
1346        }
1347        async fn get_token_data(
1348            &self,
1349            _: &str,
1350            _: &str,
1351        ) -> scope::Result<scope::chains::dex::DexTokenData> {
1352            std::future::pending().await
1353        }
1354        async fn search_tokens(
1355            &self,
1356            _: &str,
1357            _: Option<&str>,
1358        ) -> scope::Result<Vec<scope::chains::dex::TokenSearchResult>> {
1359            std::future::pending().await
1360        }
1361    }
1362
1363    /// The mock factory with a [`PendingDex`].
1364    struct PendingFactory(MockClientFactory);
1365
1366    impl ChainClientFactory for PendingFactory {
1367        fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
1368            scope::http::test_transport()
1369        }
1370        fn create_chain_client(
1371            &self,
1372            chain: &str,
1373        ) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
1374            self.0.create_chain_client(chain)
1375        }
1376        fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
1377            Box::new(PendingDex)
1378        }
1379    }
1380
1381    /// The command the cancel tests start: a repeat run whose first
1382    /// lookup never finishes ([`PendingDex`]).
1383    const PENDING_REPEAT: &str = "market summary USDC --venue eth --every 30s --duration 1h";
1384
1385    /// Types `lines`, then delivers `signals`, with the DEX lookups pending.
1386    async fn drive_pending(events: Vec<io::Result<Event>>, signals: Vec<Signal>) -> Driven {
1387        let (host, captured) = test_host(events, signals);
1388        let clients = PendingFactory(MockClientFactory::new());
1389        drive_host_on(Journal::default(), host, captured, Box::new(clients)).await
1390    }
1391
1392    /// Types `lines`, then delivers `signal`.
1393    async fn drive_signal_after(lines: &[&str], signal: Signal) -> Driven {
1394        drive_with(Journal::default(), script(lines), vec![signal]).await
1395    }
1396
1397    #[tokio::test]
1398    async fn test_paste_event_fills_the_input_and_does_not_run_it() {
1399        // A paste that ends in a newline must not submit the line.
1400        let _lock = DIAG_LOCK.lock().await;
1401        let (res, state, _, _) = drive(vec![Ok(Event::Paste("ctx\nhelp\n".into()))]).await;
1402        assert_eq!(res.unwrap().code, 0);
1403        assert_eq!(state.input, "ctx help");
1404        assert!(state.history.is_empty(), "nothing was submitted");
1405    }
1406
1407    #[tokio::test]
1408    async fn test_term_signal_saves_restores_and_journals() {
1409        // A killed TUI must not leave a raw terminal or lose the history.
1410        let _lock = DIAG_LOCK.lock().await;
1411        let dir = tempfile::tempdir().unwrap();
1412        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1413        let (res, _, host, _) = drive_with(journal, script(&["ctx"]), vec![Signal::Term]).await;
1414        assert_eq!(res.unwrap().code, 143);
1415        assert_eq!(host.restores, 1, "terminal restored once");
1416        let ev = std::fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
1417        assert!(ev.contains("\"outcome\":\"signal:TERM\""), "{ev}");
1418        let history = std::fs::read_to_string(dir.path().join("h")).unwrap();
1419        assert!(history.contains("ctx"), "{history}");
1420    }
1421
1422    #[tokio::test]
1423    async fn test_term_signal_cancels_running_pane_command() {
1424        // A repeat command sleeps between runs; SIGTERM must end it at
1425        // once and say so, not wait for the command.
1426        let _lock = DIAG_LOCK.lock().await;
1427        let (res, state, _, _) = drive_pending(script(&[PENDING_REPEAT]), vec![Signal::Term]).await;
1428        assert_eq!(res.unwrap().code, 143);
1429        assert!(state.scrollback_text().contains("Cancelled"));
1430        assert!(state.running.is_none());
1431    }
1432
1433    #[tokio::test]
1434    async fn test_each_fatal_signal_exits_with_128_plus_its_number() {
1435        // Shells and supervisors read 128+N as "ended by signal N".
1436        let _lock = DIAG_LOCK.lock().await;
1437        for (sig, code) in [
1438            (Signal::Hup, 129),
1439            (Signal::Int, 130),
1440            (Signal::Quit, 131),
1441            (Signal::Term, 143),
1442        ] {
1443            let (res, _, host, _) = drive_signal_after(&[], sig).await;
1444            assert_eq!(res.unwrap().code, code, "{sig:?}");
1445            assert_eq!(host.restores, 1, "{sig:?}");
1446        }
1447    }
1448
1449    #[tokio::test]
1450    async fn test_stop_and_continue_signals_do_not_end_the_tui() {
1451        // SIGTSTP and SIGCONT must not end the session: the loop ends only
1452        // when the keys end, with code 0.
1453        let _lock = DIAG_LOCK.lock().await;
1454        for sig in [Signal::Tstp, Signal::Cont] {
1455            let (res, _, host, _) = drive_signal_after(&[], sig).await;
1456            assert_eq!(res.unwrap().code, 0, "{sig:?}");
1457            assert_eq!(host.restores, 1, "{sig:?}");
1458        }
1459    }
1460
1461    fn ctrl_z() -> io::Result<Event> {
1462        Ok(Event::Key(KeyEvent::new(
1463            KeyCode::Char('z'),
1464            KeyModifiers::CONTROL,
1465        )))
1466    }
1467
1468    /// The text on the test screen.
1469    fn screen_text(host: &TestHost) -> String {
1470        let buf = host.terminal.backend().buffer().clone();
1471        buf.content().iter().map(|c| c.symbol()).collect()
1472    }
1473
1474    /// The width override at each `stage` (draw, suspend, restore).
1475    fn widths_at(host: &TestHost, stage: &str) -> Vec<Option<usize>> {
1476        host.widths
1477            .iter()
1478            .filter(|(s, _)| *s == stage)
1479            .map(|(_, w)| *w)
1480            .collect()
1481    }
1482
1483    #[tokio::test]
1484    async fn test_width_override_is_the_pane_and_is_removed_when_the_tui_leaves() {
1485        // Output fits the pane while the TUI owns the screen. A suspend-mode
1486        // command, the shell after Ctrl-Z and the CLI after exit use the
1487        // real width (review focus 5).
1488        let _lock = DIAG_LOCK.lock().await;
1489        let mut events = script(&["setup --key nosuchkey"]);
1490        events.push(ctrl_z());
1491        events.push(press(KeyCode::Char('x')));
1492        let (res, _, host, _) = drive(events).await;
1493        res.unwrap();
1494        // Suspend-mode command, then Ctrl-Z: the override is off both times.
1495        assert_eq!(widths_at(&host, "suspend"), vec![None, None]);
1496        assert_eq!(widths_at(&host, "restore"), vec![None]);
1497        // The pane of the 100-column test screen has no side borders.
1498        let draws = widths_at(&host, "draw");
1499        assert_eq!(draws.last(), Some(&Some(100)), "set again after Ctrl-Z");
1500    }
1501
1502    /// The mock factory, counting the DEX clients it makes.
1503    struct CountingFactory(Arc<std::sync::atomic::AtomicUsize>);
1504
1505    impl ChainClientFactory for CountingFactory {
1506        fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
1507            scope::http::test_transport()
1508        }
1509        fn create_chain_client(
1510            &self,
1511            chain: &str,
1512        ) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
1513            MockClientFactory::new().create_chain_client(chain)
1514        }
1515        fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
1516            self.0.fetch_add(1, Ordering::SeqCst);
1517            MockClientFactory::new().create_dex_client()
1518        }
1519    }
1520
1521    #[tokio::test]
1522    async fn test_after_setup_the_next_command_uses_the_reloaded_clients() {
1523        // #99: a key saved by `setup` works without a restart (a stale key
1524        // gave "Invalid API Key" right after a successful setup).
1525        let _lock = DIAG_LOCK.lock().await;
1526        let first = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1527        let reloaded = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1528        let reloads = std::cell::Cell::new(0);
1529        let reload = || {
1530            reloads.set(reloads.get() + 1);
1531            Ok(settings_with(Box::new(CountingFactory(reloaded.clone()))))
1532        };
1533        let (host, captured) = test_host(script(&["setup --key nosuchkey", "crawl USDC"]), vec![]);
1534        let (res, state, _, _) = drive_host_reloading(
1535            Journal::default(),
1536            host,
1537            captured,
1538            settings_with(Box::new(CountingFactory(first.clone()))),
1539            &reload,
1540        )
1541        .await;
1542        res.unwrap();
1543        assert_eq!(reloads.get(), 1, "setup reads the settings once");
1544        assert!(state.scrollback_text().contains(SETUP_RELOADED_NOTE));
1545        assert_eq!(
1546            first.load(Ordering::SeqCst),
1547            0,
1548            "the old clients are not used"
1549        );
1550        assert!(
1551            reloaded.load(Ordering::SeqCst) > 0,
1552            "crawl used the new clients"
1553        );
1554    }
1555
1556    #[tokio::test]
1557    async fn test_a_failed_reload_keeps_the_settings_and_says_to_restart() {
1558        let _lock = DIAG_LOCK.lock().await;
1559        let reload = || Err(ScopeError::Other("bad yaml".into()));
1560        let (host, captured) = test_host(script(&["setup --key nosuchkey"]), vec![]);
1561        let (res, state, _, _) = drive_host_reloading(
1562            Journal::default(),
1563            host,
1564            captured,
1565            settings_with(Box::new(MockClientFactory::new())),
1566            &reload,
1567        )
1568        .await;
1569        res.unwrap();
1570        let text = state.scrollback_text();
1571        assert!(text.contains("could not be read again"), "{text}");
1572        assert!(text.contains("bad yaml"), "{text}");
1573        assert!(!text.contains(SETUP_RELOADED_NOTE), "{text}");
1574    }
1575
1576    #[tokio::test]
1577    async fn test_other_commands_do_not_reload() {
1578        let _lock = DIAG_LOCK.lock().await;
1579        let reloads = std::cell::Cell::new(0);
1580        let reload = || {
1581            reloads.set(reloads.get() + 1);
1582            Ok(settings_with(Box::new(MockClientFactory::new())))
1583        };
1584        let (host, captured) = test_host(script(&["crawl USDC", "venues list"]), vec![]);
1585        let (res, state, _, _) = drive_host_reloading(
1586            Journal::default(),
1587            host,
1588            captured,
1589            settings_with(Box::new(MockClientFactory::new())),
1590            &reload,
1591        )
1592        .await;
1593        res.unwrap();
1594        assert_eq!(reloads.get(), 0);
1595        assert!(!state.scrollback_text().contains(SETUP_RELOADED_NOTE));
1596    }
1597
1598    #[tokio::test]
1599    async fn test_ctrl_z_stops_then_redraws_with_the_draft() {
1600        // Ctrl-Z must give the shell a clean terminal, stop the process,
1601        // and on `fg` take the screen back, clear what the shell wrote,
1602        // and redraw with the draft. The SIGCONT that `fg` sends must not
1603        // take the screen a second time.
1604        let _lock = DIAG_LOCK.lock().await;
1605        let mut events: Vec<_> = "draf".chars().map(|c| press(KeyCode::Char(c))).collect();
1606        events.push(ctrl_z());
1607        let (res, state, host, _) = drive(events).await;
1608        assert_eq!(res.unwrap().code, 0, "Ctrl-Z does not end the TUI");
1609        assert_eq!(host.stops, 1);
1610        assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
1611        assert_eq!(state.input, "draf");
1612        assert!(screen_text(&host).contains("draf"));
1613    }
1614
1615    #[tokio::test]
1616    async fn test_tstp_signal_stops_like_ctrl_z() {
1617        // `kill -TSTP` must stop the TUI the same way; its handler replaced
1618        // the default stop, so without this the signal does nothing.
1619        let _lock = DIAG_LOCK.lock().await;
1620        let (res, _, host, _) = drive_signal_after(&[], Signal::Tstp).await;
1621        assert_eq!(res.unwrap().code, 0);
1622        assert_eq!(host.stops, 1);
1623        assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
1624    }
1625
1626    #[tokio::test]
1627    async fn test_cont_without_our_stop_retakes_and_clears_the_screen() {
1628        // After an outside SIGSTOP, the shell reset the terminal and wrote
1629        // on it. SIGCONT must set raw mode again and repaint everything.
1630        let _lock = DIAG_LOCK.lock().await;
1631        let (res, _, host, _) = drive_signal_after(&[], Signal::Cont).await;
1632        assert_eq!(res.unwrap().code, 0);
1633        assert_eq!(host.stops, 0, "SIGCONT does not stop the process");
1634        assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
1635    }
1636
1637    #[tokio::test]
1638    async fn test_ctrl_z_keeps_a_running_pane_command() {
1639        // Review Focus 2: a command running in the pane survives Ctrl-Z,
1640        // and after `fg` the screen shows its running footer again.
1641        let _lock = DIAG_LOCK.lock().await;
1642        let mut events = script(&[PENDING_REPEAT]);
1643        events.push(ctrl_z());
1644        let (res, state, host, _) = drive_pending(events, Vec::new()).await;
1645        assert_eq!(res.unwrap().code, 0);
1646        assert_eq!(host.stops, 1);
1647        assert!(state.running.is_some(), "the command still runs");
1648        assert!(!state.scrollback_text().contains("Cancelled"));
1649        let screen = screen_text(&host);
1650        assert!(screen.contains("Esc cancels"), "{screen}");
1651    }
1652
1653    #[tokio::test(start_paused = true)]
1654    async fn test_a_long_stop_does_not_replay_the_missed_redraw_ticks() {
1655        // While the process is stopped the clock runs on, but nothing polls
1656        // the 100 ms redraw tick. On `fg` the missed ticks must not fire
1657        // one by one: the tick arm comes first in the biased select, so
1658        // they would hold back SIGCONT and the keys (10 per second stopped).
1659        const STOPPED: Duration = Duration::from_secs(120);
1660        const MAX_DRAWS_AFTER_STOP: usize = 10;
1661        let _lock = DIAG_LOCK.lock().await;
1662        let (mut host, _) = test_host(Vec::new(), Vec::new());
1663        // The clock jumps after the stop, before the next key is read.
1664        let after_stop = futures::stream::once(async {
1665            tokio::time::advance(STOPPED).await;
1666            press(KeyCode::Char('x'))
1667        });
1668        host.keys = futures::stream::iter(vec![ctrl_z()])
1669            .chain(after_stop)
1670            .boxed();
1671        // A command shown as running turns the tick on. A real command
1672        // would need the network, and the paused clock fires its timeouts
1673        // at once, so the test marks one as running without a future.
1674        let mut state = TuiState::new(
1675            SessionContext::default(),
1676            Journal::default(),
1677            vocab::Vocab::default(),
1678        );
1679        let config = Config::default();
1680        let Plan::Run(inv) = state.submit("venues list", &config) else {
1681            panic!("`venues list` did not plan a run")
1682        };
1683        let _out = state.start("venues list", &inv);
1684        let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
1685        let settings = settings_with(Box::new(MockClientFactory::new()));
1686        let res = session(&mut host, &mut state, settings, &reload, |_| Ok(())).await;
1687        assert_eq!(res.unwrap().code, 0);
1688        assert!(state.running.is_some(), "the tick stayed on");
1689        assert_eq!(host.stops, 1);
1690        assert_eq!(state.input, "x", "the key after the stop is handled");
1691        let draws = host.draws - host.draws_at_stop;
1692        assert!(
1693            draws <= MAX_DRAWS_AFTER_STOP,
1694            "{draws} draws between the stop and the end"
1695        );
1696    }
1697
1698    #[tokio::test]
1699    async fn test_a_failed_stop_says_so_and_keeps_the_tui() {
1700        // If kill fails, the TUI must say so and take the screen back. No
1701        // SIGCONT follows, so a later outside SIGCONT must still repaint.
1702        let _lock = DIAG_LOCK.lock().await;
1703        let (mut host, captured) = test_host(vec![ctrl_z()], vec![Signal::Cont]);
1704        host.stop_fails = true;
1705        let (res, state, host, _) = drive_host(Journal::default(), host, captured).await;
1706        assert_eq!(res.unwrap().code, 0);
1707        assert!(
1708            state
1709                .scrollback_text()
1710                .contains("Could not stop the process: kill failed")
1711        );
1712        assert_eq!(host.stops, 1);
1713        // One set for the failed stop, one for the outside SIGCONT.
1714        assert_eq!((host.suspends, host.resumes, host.clears), (2, 2, 2));
1715    }
1716
1717    #[tokio::test]
1718    async fn test_a_terminal_that_cannot_be_taken_back_ends_the_session_with_the_terminal_error() {
1719        // After Ctrl-Z and `fg`, the terminal may be gone. The TUI must
1720        // end with the same error the monitor shows, not panic, and still
1721        // restore and save.
1722        let _lock = DIAG_LOCK.lock().await;
1723        let (mut host, captured) = test_host(vec![ctrl_z()], vec![]);
1724        host.resume_fails = true;
1725        let (res, _, host, _) = drive_host(Journal::default(), host, captured).await;
1726        assert_eq!(
1727            res.unwrap_err().to_string(),
1728            "I/O error: cannot take the terminal: no tty"
1729        );
1730        assert_eq!(host.resumes, 1);
1731        assert_eq!(
1732            host.restores, 1,
1733            "the session still gives the terminal back"
1734        );
1735    }
1736
1737    /// Awaits `fut`, failing the test after 10 s: a swallowed signal must
1738    /// fail the test, not hang it.
1739    async fn within_limit<T>(fut: impl Future<Output = T>) -> T {
1740        tokio::time::timeout(Duration::from_secs(10), fut)
1741            .await
1742            .expect("the signal did not end the wait")
1743    }
1744
1745    /// The journal records in `e.jsonl` under `dir`, parsed.
1746    fn records(dir: &tempfile::TempDir) -> Vec<serde_json::Value> {
1747        std::fs::read_to_string(dir.path().join("e.jsonl"))
1748            .unwrap()
1749            .lines()
1750            .map(|l| serde_json::from_str(l).unwrap())
1751            .collect()
1752    }
1753
1754    #[tokio::test]
1755    async fn test_term_signal_during_a_suspended_command_ends_it() {
1756        // A suspend-mode command can run for hours (the monitor). On
1757        // develop SIGTERM killed it; the handler must not swallow it.
1758        let _lock = DIAG_LOCK.lock().await;
1759        let dir = tempfile::tempdir().unwrap();
1760        let mut state = TuiState::new(
1761            SessionContext::default(),
1762            Journal::at(dir.path().join("h"), dir.path().join("e.jsonl")),
1763            vocab::Vocab::default(),
1764        );
1765        let (mut host, _) = test_host(Vec::new(), Vec::new());
1766        host.signal_tx.unbounded_send(Signal::Term).unwrap();
1767        let outside = Outside::new(host.suspended_output());
1768        let argv = vec!["monitor".to_string()];
1769        let never = futures::future::pending::<Result<()>>();
1770        let run = run_suspended(&mut host, "monitor", &argv, &outside, never, &mut state);
1771        let exit = within_limit(run).await;
1772        assert_eq!(exit, Some(TuiExit { code: 143 }));
1773        let rec = records(&dir);
1774        assert_eq!(rec.len(), 1, "{rec:?}");
1775        assert_eq!(rec[0]["outcome"], "signal:TERM");
1776        assert_eq!(rec[0]["route"], "suspend");
1777        assert_eq!(rec[0]["input"], "monitor");
1778    }
1779
1780    /// A state over a journal in `dir`.
1781    fn journaled_state(dir: &tempfile::TempDir) -> TuiState {
1782        TuiState::new(
1783            SessionContext::default(),
1784            Journal::at(dir.path().join("h"), dir.path().join("e.jsonl")),
1785            vocab::Vocab::default(),
1786        )
1787    }
1788
1789    /// The scrollback note `run_suspended` leaves for `line`, checked
1790    /// against the spec format with the outcome word `outcome`.
1791    fn assert_ran_note(state: &TuiState, line: &str, outcome: &str) {
1792        let text = state.scrollback_text();
1793        let note = text
1794            .lines()
1795            .find_map(|l| {
1796                l.strip_prefix("ℹ ")
1797                    .filter(|n| n.starts_with("Ran outside the TUI:"))
1798            })
1799            .unwrap_or_else(|| panic!("no note in {text}"));
1800        let pattern = format!(
1801            r"^Ran outside the TUI: {} \({}, \d+\.\ds\)\.$",
1802            regex::escape(line),
1803            outcome
1804        );
1805        assert!(
1806            regex::Regex::new(&pattern).unwrap().is_match(note),
1807            "{note:?} does not match {pattern}"
1808        );
1809    }
1810
1811    #[tokio::test]
1812    async fn test_suspended_command_is_cancelled_by_sigint() {
1813        // Ctrl-C outside the TUI must stop a command that never ends (a
1814        // hung crawl) and return to the TUI, not kill the process.
1815        let _lock = DIAG_LOCK.lock().await;
1816        let dir = tempfile::tempdir().unwrap();
1817        let mut state = journaled_state(&dir);
1818        let (mut host, captured) = test_host(Vec::new(), Vec::new());
1819        host.signal_tx.unbounded_send(Signal::Int).unwrap();
1820        let outside = Outside::new(host.suspended_output());
1821        let argv = vec!["crawl".to_string(), "USDC".to_string()];
1822        let never = futures::future::pending::<Result<()>>();
1823        let run = run_suspended(&mut host, "crawl USDC", &argv, &outside, never, &mut state);
1824        assert_eq!(within_limit(run).await, None, "the session goes on");
1825        assert_ran_note(&state, "crawl USDC", "cancelled");
1826        let rec = records(&dir);
1827        assert_eq!(rec.len(), 1, "{rec:?}");
1828        assert_eq!(rec[0]["outcome"], "cancelled");
1829        assert_eq!(rec[0]["route"], "suspend");
1830        assert!(captured.out().contains(
1831            "Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
1832        ));
1833    }
1834
1835    #[tokio::test]
1836    async fn test_sigint_cancel_returns_to_a_working_loop() {
1837        // After the cancel the TUI takes the screen back and runs the next
1838        // line.
1839        let _lock = DIAG_LOCK.lock().await;
1840        let dir = tempfile::tempdir().unwrap();
1841        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1842        let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
1843        host.suspend_signals = vec![Signal::Int];
1844        let (res, state, host, _) = within_limit(drive_host(journal, host, captured)).await;
1845        assert_eq!(res.unwrap().code, 0);
1846        assert_eq!((host.suspends, host.resumes), (1, 1));
1847        assert_ran_note(&state, "setup --key nosuchkey", "cancelled");
1848        assert!(state.scrollback_text().contains("Current Context"));
1849        let rec = records(&dir);
1850        assert_eq!(rec[0]["outcome"], "cancelled", "{rec:?}");
1851        assert_eq!(rec[1]["input"], "ctx", "{rec:?}");
1852    }
1853
1854    /// Runs a suspend-mode `setup` whose prompt a signal stops: the
1855    /// command sets the cancel flag and sends `sig`, as the prompt watcher
1856    /// and the signal stream see one signal, and returns `result` (a
1857    /// prompt error, or Ok when the command swallowed it). `None` sends no
1858    /// signal. Returns the exit, the state, the host and the journal dir.
1859    ///
1860    /// `prompt::CANCEL` is process-global, so every test that touches it
1861    /// or calls `run_suspended` must hold `DIAG_LOCK`. This helper resets
1862    /// the flag before it returns.
1863    async fn prompt_stopped_by(
1864        sig: Option<Signal>,
1865        result: Result<()>,
1866    ) -> (Option<TuiExit>, TuiState, TestHost, tempfile::TempDir) {
1867        let dir = tempfile::tempdir().unwrap();
1868        let mut state = journaled_state(&dir);
1869        let (mut host, _) = test_host(Vec::new(), Vec::new());
1870        let outside = Outside::new(host.suspended_output());
1871        let tx = host.signal_tx.clone();
1872        let argv = vec!["setup".to_string()];
1873        // The prompt blocks the task, so the select polled the signals
1874        // before the signal came.
1875        let prompt_stopped = async move {
1876            crate::cli::prompt::CANCEL.store(true, Ordering::SeqCst);
1877            if let Some(sig) = sig {
1878                tx.unbounded_send(sig).unwrap();
1879            }
1880            result
1881        };
1882        let run = run_suspended(
1883            &mut host,
1884            "setup",
1885            &argv,
1886            &outside,
1887            prompt_stopped,
1888            &mut state,
1889        );
1890        let exit = within_limit(run).await;
1891        crate::cli::prompt::CANCEL.store(false, Ordering::SeqCst);
1892        (exit, state, host, dir)
1893    }
1894
1895    #[tokio::test]
1896    async fn test_a_cancelled_prompt_is_recorded_as_cancelled_and_its_int_used_up() {
1897        // Ctrl-C at a prompt: the cancel must read as a cancel, not a
1898        // failure, and its INT must not reach the TUI later and end the
1899        // session with 130.
1900        let _lock = DIAG_LOCK.lock().await;
1901        for result in [
1902            Err(ScopeError::IoError(io::Error::other("cancelled"))),
1903            Ok(()),
1904        ] {
1905            let (exit, state, mut host, dir) = prompt_stopped_by(Some(Signal::Int), result).await;
1906            assert_eq!(exit, None);
1907            assert_ran_note(&state, "setup", "cancelled");
1908            assert_eq!(records(&dir)[0]["outcome"], "cancelled");
1909            assert!(host.signals.try_recv().is_err(), "the INT is used up");
1910        }
1911    }
1912
1913    #[tokio::test]
1914    async fn test_term_at_a_prompt_ends_the_session_from_suspend_mode() {
1915        let _lock = DIAG_LOCK.lock().await;
1916        let failed = Err(ScopeError::IoError(io::Error::other("cancelled")));
1917        let (exit, _, _, dir) = prompt_stopped_by(Some(Signal::Term), failed).await;
1918        assert_eq!(exit, Some(TuiExit { code: 143 }));
1919        let rec = records(&dir);
1920        assert_eq!(rec.len(), 1, "{rec:?}");
1921        assert_eq!(rec[0]["outcome"], "signal:TERM");
1922        assert_eq!(rec[0]["route"], "suspend");
1923    }
1924
1925    #[tokio::test]
1926    async fn test_a_suspended_command_starts_with_the_cancel_flag_clear() {
1927        // A Ctrl-C from an earlier command must not cancel the next one's
1928        // first prompt.
1929        let _lock = DIAG_LOCK.lock().await;
1930        let dir = tempfile::tempdir().unwrap();
1931        let mut state = journaled_state(&dir);
1932        let (mut host, _) = test_host(Vec::new(), Vec::new());
1933        let outside = Outside::new(host.suspended_output());
1934        crate::cli::prompt::CANCEL.store(true, Ordering::SeqCst);
1935        let saw_clear = async {
1936            assert!(!crate::cli::prompt::CANCEL.load(Ordering::SeqCst));
1937            Ok(())
1938        };
1939        let run = run_suspended(&mut host, "setup", &[], &outside, saw_clear, &mut state);
1940        assert_eq!(within_limit(run).await, None);
1941        assert_ran_note(&state, "setup", "ok");
1942        crate::cli::prompt::CANCEL.store(false, Ordering::SeqCst);
1943    }
1944
1945    /// The scrollback lines from `first` up to the `Ran outside the TUI`
1946    /// note; panics when either is missing or out of order.
1947    fn lines_before_note(state: &TuiState, first: &str) -> Vec<String> {
1948        let text = state.scrollback_text();
1949        let lines: Vec<String> = text.lines().map(str::to_string).collect();
1950        let start = lines
1951            .iter()
1952            .position(|l| l.contains(first))
1953            .unwrap_or_else(|| panic!("{first:?} is not in the scrollback:\n{text}"));
1954        let note = lines
1955            .iter()
1956            .position(|l| l.contains("Ran outside the TUI:"))
1957            .unwrap_or_else(|| panic!("no note:\n{text}"));
1958        assert!(start < note, "the output comes before the note:\n{text}");
1959        lines[start..note].to_vec()
1960    }
1961
1962    #[tokio::test]
1963    async fn test_a_suspended_report_shows_in_the_scrollback_and_is_the_copy() {
1964        // The report of a command that ran outside the TUI must not be
1965        // hidden until the TUI exits; Ctrl-Y and save must have its data.
1966        let _lock = DIAG_LOCK.lock().await;
1967        let dir = tempfile::tempdir().unwrap();
1968        let mut state = journaled_state(&dir);
1969        let (mut host, captured) = test_host(Vec::new(), Vec::new());
1970        let outside = Outside::new(host.suspended_output());
1971        let out = &outside.out;
1972        let report = async {
1973            crate::outln!(out, "\x1b[1mToken Report\x1b[0m: USDC")?;
1974            crate::errln!(out, "warning: partial data")?;
1975            crate::outln!(out, "holders 42")?;
1976            Ok(())
1977        };
1978        let run = run_suspended(&mut host, "crawl USDC", &[], &outside, report, &mut state);
1979        assert_eq!(within_limit(run).await, None);
1980        let shown = lines_before_note(&state, "Token Report: USDC");
1981        assert_eq!(
1982            shown,
1983            ["Token Report: USDC", "warning: partial data", "holders 42"]
1984        );
1985        // The user saw it live on the terminal too.
1986        assert!(captured.out().contains("holders 42"));
1987        // Ctrl-Y sends the data only, without the warning or ANSI codes.
1988        let Action::Copy(copy) =
1989            state.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL))
1990        else {
1991            panic!("Ctrl-Y has nothing to copy")
1992        };
1993        assert_eq!(copy, "Token Report: USDC\nholders 42\n");
1994    }
1995
1996    #[tokio::test]
1997    async fn test_a_failed_suspended_command_shows_its_output_and_error() {
1998        // A failure outside the TUI must explain itself in the TUI, and must
1999        // not replace the copy of an earlier success.
2000        let _lock = DIAG_LOCK.lock().await;
2001        let dir = tempfile::tempdir().unwrap();
2002        let mut state = journaled_state(&dir);
2003        let (mut host, _) = test_host(Vec::new(), Vec::new());
2004        let outside = Outside::new(host.suspended_output());
2005        let out = &outside.out;
2006        let failed = async {
2007            crate::outln!(out, "searching USDC")?;
2008            Err(ScopeError::Other("crawl blew up".into()))
2009        };
2010        let run = run_suspended(&mut host, "crawl USDC", &[], &outside, failed, &mut state);
2011        assert_eq!(within_limit(run).await, None);
2012        assert_ran_note(&state, "crawl USDC", "failed");
2013        let shown = lines_before_note(&state, "searching USDC").join("\n");
2014        let error = shown.find("✗ crawl blew up").expect(&shown);
2015        assert!(shown.find("searching USDC").unwrap() < error, "{shown}");
2016        let copy = state.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
2017        assert!(matches!(copy, Action::None), "no copy after a failure");
2018    }
2019
2020    #[tokio::test(start_paused = true)]
2021    async fn test_a_set_flag_without_a_signal_does_not_hang_the_tui() {
2022        // The flag means a signal is on the stream. If that ever breaks,
2023        // the TUI must not wait forever on a cooked terminal.
2024        let _lock = DIAG_LOCK.lock().await;
2025        let started = tokio::time::Instant::now();
2026        let failed = Err(ScopeError::IoError(io::Error::other("cancelled")));
2027        let (exit, state, _, dir) = prompt_stopped_by(None, failed).await;
2028        assert_eq!(exit, None);
2029        assert!(started.elapsed() <= PROMPT_SIGNAL_WAIT + Duration::from_millis(100));
2030        assert_ran_note(&state, "setup", "cancelled");
2031        assert_eq!(records(&dir)[0]["outcome"], "cancelled");
2032    }
2033
2034    #[tokio::test]
2035    async fn test_a_second_ctrl_c_does_not_end_the_session() {
2036        // A user often presses Ctrl-C twice. The second INT must not
2037        // reach the TUI and end it with 130.
2038        let _lock = DIAG_LOCK.lock().await;
2039        let dir = tempfile::tempdir().unwrap();
2040        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2041        let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
2042        host.suspend_signals = vec![Signal::Int, Signal::Int];
2043        let (res, state, host, _) = within_limit(drive_host(journal, host, captured)).await;
2044        assert_eq!(res.unwrap().code, 0);
2045        assert_eq!((host.suspends, host.resumes), (1, 1));
2046        assert_ran_note(&state, "setup --key nosuchkey", "cancelled");
2047        assert!(state.scrollback_text().contains("Current Context"));
2048        let rec = records(&dir);
2049        assert_eq!(rec.len(), 2, "{rec:?}");
2050        assert_eq!(rec[1]["input"], "ctx", "{rec:?}");
2051    }
2052
2053    #[tokio::test]
2054    async fn test_a_term_queued_after_a_cancel_still_ends_the_session() {
2055        // Draining extra INTs must not drop a TERM behind them.
2056        let _lock = DIAG_LOCK.lock().await;
2057        let dir = tempfile::tempdir().unwrap();
2058        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2059        let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
2060        host.suspend_signals = vec![Signal::Int, Signal::Int, Signal::Term];
2061        let (res, _, host, _) = within_limit(drive_host(journal, host, captured)).await;
2062        assert_eq!(res.unwrap().code, 143);
2063        assert_eq!(host.resumes, 0);
2064        let rec = records(&dir);
2065        assert_eq!(rec[0]["outcome"], "cancelled", "{rec:?}");
2066        assert_eq!(rec[1]["outcome"], "signal:TERM", "{rec:?}");
2067        assert_eq!(rec[1]["route"], "suspend", "{rec:?}");
2068    }
2069
2070    #[tokio::test]
2071    async fn test_ending_signals_set_the_prompt_cancel_flag() {
2072        // A prompt blocks the task that watches the signals, so a separate
2073        // watcher must set the flag. TSTP and CONT do not cancel.
2074        for (sigs, set) in [
2075            (vec![Signal::Tstp, Signal::Cont], false),
2076            (vec![Signal::Int], true),
2077            (vec![Signal::Term], true),
2078            (vec![Signal::Hup], true),
2079            (vec![Signal::Quit], true),
2080        ] {
2081            let flag = AtomicBool::new(false);
2082            cancel_prompts_on(futures::stream::iter(sigs.clone()), &flag).await;
2083            assert_eq!(flag.load(Ordering::SeqCst), set, "{sigs:?}");
2084        }
2085    }
2086
2087    /// A writer whose terminal is gone.
2088    struct BrokenTty;
2089
2090    impl io::Write for BrokenTty {
2091        fn write(&mut self, _: &[u8]) -> io::Result<usize> {
2092            Err(io::Error::other("tty gone"))
2093        }
2094        fn flush(&mut self) -> io::Result<()> {
2095            Ok(())
2096        }
2097    }
2098
2099    #[tokio::test]
2100    async fn test_terminal_errors_outside_the_tui_are_reported_after_it() {
2101        // Nothing outside the TUI can show a broken terminal; the pane
2102        // must say so when the TUI is back.
2103        let _lock = DIAG_LOCK.lock().await;
2104        let dir = tempfile::tempdir().unwrap();
2105        let mut state = journaled_state(&dir);
2106        let (mut host, _) = test_host(Vec::new(), Vec::new());
2107        let outside = Outside::new(Output::from_writers(BrokenTty, BrokenTty));
2108        let failed = async { Err(ScopeError::Other("boom".into())) };
2109        let run = run_suspended(&mut host, "setup", &[], &outside, failed, &mut state);
2110        assert_eq!(within_limit(run).await, None);
2111        assert_ran_note(&state, "setup", "failed");
2112        let text = state.scrollback_text();
2113        assert_eq!(
2114            text.matches("Terminal I/O error while outside the TUI: tty gone")
2115                .count(),
2116            1,
2117            "the error text; the header is a note, and a failed note is dropped: {text}"
2118        );
2119        assert_eq!(records(&dir)[0]["outcome"], "error");
2120    }
2121
2122    #[tokio::test]
2123    async fn test_suspended_command_ignores_tstp_and_cont() {
2124        // The command owns the terminal, so TSTP and CONT do not stop the
2125        // TUI or take the screen, and do not end the command.
2126        let _lock = DIAG_LOCK.lock().await;
2127        let (mut host, captured) = test_host(script(&["setup --key nosuchkey"]), Vec::new());
2128        host.suspend_signals = vec![Signal::Tstp, Signal::Cont];
2129        let (res, state, host, _) = drive_host(Journal::default(), host, captured).await;
2130        assert_eq!(res.unwrap().code, 0);
2131        assert_eq!((host.suspends, host.resumes), (1, 1));
2132        assert_eq!((host.stops, host.clears), (0, 0));
2133        assert_ran_note(&state, "setup --key nosuchkey", "ok");
2134    }
2135
2136    #[tokio::test]
2137    async fn test_event_stream_error_still_restores_once_and_saves() {
2138        // A broken terminal ends the loop with an error; the terminal is
2139        // still restored once and the history is still saved.
2140        let _lock = DIAG_LOCK.lock().await;
2141        let dir = tempfile::tempdir().unwrap();
2142        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2143        let mut events = script(&["ctx"]);
2144        events.push(Err(io::Error::other("tty gone")));
2145        let (res, _, host, _) = drive_with(journal, events, Vec::new()).await;
2146        assert!(res.unwrap_err().to_string().contains("tty gone"));
2147        assert_eq!(host.restores, 1);
2148        let history = std::fs::read_to_string(dir.path().join("h")).unwrap();
2149        assert!(history.contains("ctx"), "{history}");
2150    }
2151
2152    #[tokio::test]
2153    async fn test_normal_exit_restores_and_returns_code_zero() {
2154        let _lock = DIAG_LOCK.lock().await;
2155        let (res, _, host, _) = drive(script(&["exit"])).await;
2156        assert_eq!(res.unwrap().code, 0);
2157        assert_eq!(host.restores, 1);
2158    }
2159
2160    #[tokio::test]
2161    async fn test_loop_runs_session_and_pane_commands_then_exits() {
2162        let _lock = DIAG_LOCK.lock().await;
2163        let (res, state, host, _) =
2164            drive(script(&["chain base", "venues list", "exit", "never runs"])).await;
2165        res.unwrap();
2166        let text = state.scrollback_text();
2167        assert!(text.contains("Type `help` for commands"));
2168        assert!(text.contains("Chain pinned to: base"));
2169        assert!(text.contains("Available Venues"), "{}", text);
2170        assert!(text.contains("Done in"));
2171        assert!(!text.contains("never runs"), "nothing runs after exit");
2172        // The screen shows the pinned chain in the prompt.
2173        let buf = host.terminal.backend().buffer().clone();
2174        let screen: String = buf.content().iter().map(|c| c.symbol()).collect();
2175        assert!(screen.contains("scope:base>"));
2176    }
2177
2178    fn ctrl_y_event() -> io::Result<Event> {
2179        Ok(Event::Key(KeyEvent::new(
2180            KeyCode::Char('y'),
2181            KeyModifiers::CONTROL,
2182        )))
2183    }
2184
2185    #[tokio::test]
2186    async fn test_ctrl_y_sends_the_json_and_save_writes_it() {
2187        use base64::Engine as _;
2188        let _lock = DIAG_LOCK.lock().await;
2189        let dir = tempfile::tempdir().unwrap();
2190        let file = dir.path().join("one.json");
2191        let mut events = script(&[
2192            "venues list --format json",
2193            // Quoted: the line splits by shell rules, where `\` in a
2194            // Windows path is an escape.
2195            &format!("save '{}'", file.display()),
2196        ]);
2197        events.push(ctrl_y_event());
2198        let (res, state, host, _) = drive(events).await;
2199        res.unwrap();
2200        let body = std::fs::read_to_string(&file).unwrap();
2201        serde_json::from_str::<serde_json::Value>(&body).unwrap();
2202        let text = state.scrollback_text();
2203        assert!(text.contains("copied "), "{text}");
2204        let payload = host
2205            .raw
2206            .strip_prefix("\x1b]52;c;")
2207            .and_then(|r| r.strip_suffix('\x07'))
2208            .unwrap_or_else(|| panic!("not an OSC 52 sequence: {:?}", host.raw));
2209        let sent = base64::engine::general_purpose::STANDARD
2210            .decode(payload)
2211            .unwrap();
2212        assert_eq!(String::from_utf8(sent).unwrap(), body);
2213    }
2214
2215    #[tokio::test]
2216    async fn test_save_refuses_to_overwrite_then_force_replaces_and_both_are_journaled() {
2217        let _lock = DIAG_LOCK.lock().await;
2218        let dir = tempfile::tempdir().unwrap();
2219        let file = dir.path().join("two.json");
2220        std::fs::write(&file, "old").unwrap();
2221        // Quoted, as above.
2222        let save = format!("save '{}'", file.display());
2223        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2224        let (res, state, _, _) = drive_with(
2225            journal,
2226            script(&[
2227                "venues list --format json",
2228                &save,
2229                &format!("{save} --force"),
2230            ]),
2231            Vec::new(),
2232        )
2233        .await;
2234        res.unwrap();
2235        let text = state.scrollback_text();
2236        assert!(text.contains("exists; add --force"), "{text}");
2237        assert!(text.contains("saved "), "{text}");
2238        let body = std::fs::read_to_string(&file).unwrap();
2239        assert_ne!(body, "old", "--force replaced the file");
2240        serde_json::from_str::<serde_json::Value>(&body).unwrap();
2241        let recs = records(&dir);
2242        let refused = recs.iter().find(|r| r["outcome"] == "refused").unwrap();
2243        assert_eq!(refused["input"], save);
2244        assert!(refused["error"].as_str().unwrap().contains("exists"));
2245        assert!(
2246            recs.iter()
2247                .any(|r| r["input"] == format!("{save} --force") && r["outcome"] == "session")
2248        );
2249    }
2250
2251    #[tokio::test]
2252    async fn test_ctrl_y_results_are_journaled() {
2253        let _lock = DIAG_LOCK.lock().await;
2254        let dir = tempfile::tempdir().unwrap();
2255        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
2256        let mut events = vec![ctrl_y_event()];
2257        events.extend(script(&["venues list"]));
2258        events.push(ctrl_y_event());
2259        let (mut host, captured) = test_host(events, Vec::new());
2260        host.raw_fails = true;
2261        let (_, _, _, _) = drive_host(journal, host, captured).await;
2262        let recs = records(&dir);
2263        let ctrl_y: Vec<_> = recs.iter().filter(|r| r["input"] == "Ctrl-Y").collect();
2264        assert_eq!(ctrl_y.len(), 2, "{recs:?}");
2265        assert_eq!(ctrl_y[0]["outcome"], "refused");
2266        assert_eq!(ctrl_y[0]["error"], "nothing to copy yet");
2267        assert_eq!(ctrl_y[1]["outcome"], "error");
2268        assert!(ctrl_y[1]["error"].as_str().unwrap().contains("tty gone"));
2269    }
2270
2271    #[tokio::test]
2272    async fn test_a_failed_copy_is_noted() {
2273        let _lock = DIAG_LOCK.lock().await;
2274        let mut events = script(&["venues list"]);
2275        events.push(Ok(Event::Key(KeyEvent::new(
2276            KeyCode::Char('y'),
2277            KeyModifiers::CONTROL,
2278        ))));
2279        let (mut host, captured) = test_host(events, Vec::new());
2280        host.raw_fails = true;
2281        let (_, state, _, _) = drive_host(Journal::default(), host, captured).await;
2282        assert!(state.scrollback_text().contains("Could not copy: tty gone"));
2283    }
2284
2285    #[tokio::test]
2286    async fn test_watch_open_runs_the_grid_next_to_the_shell() {
2287        let _lock = DIAG_LOCK.lock().await;
2288        let dir = tempfile::tempdir().unwrap();
2289        let path = dir.path().join("tokens.yaml");
2290        std::fs::write(
2291            &path,
2292            "name: tokens\ninterval: 1h\ntargets:\n  - kind: token\n    id: \"0x1234567890123456789012345678901234567890\"\n    chain: ethereum\n    rules: [\"price_usd > 0\"]\n",
2293        )
2294        .unwrap();
2295        let line = format!(
2296            "watch open {}",
2297            path.display().to_string().replace('\\', "/")
2298        );
2299        let (res, mut state, host, _) = drive(script(&[&line])).await;
2300        res.unwrap();
2301        state.poll_watch();
2302        let g = state.grid.as_ref().expect("the grid opened");
2303        assert!(state.grid_shown);
2304        assert_eq!(g.list.name, "tokens");
2305        assert!(g.ready.is_some(), "the targets were resolved");
2306        let last = g.last.as_ref().expect("the first tick came");
2307        assert_eq!(
2308            last.snapshot.targets[0].status,
2309            scope::watch::TargetStatus::Passed
2310        );
2311        assert!(g.is_running(), "a 1h run goes on");
2312        assert!(
2313            screen_text(&host).contains("watchlist tokens"),
2314            "{}",
2315            screen_text(&host)
2316        );
2317        assert!(state.scrollback_text().contains("Watchlist tokens started"));
2318    }
2319
2320    #[tokio::test]
2321    async fn test_esc_in_the_grid_stops_the_run_and_writes_the_report() {
2322        let _lock = DIAG_LOCK.lock().await;
2323        let dir = tempfile::tempdir().unwrap();
2324        let path = dir.path().join("w.yaml");
2325        let report = dir.path().join("w.md");
2326        std::fs::write(
2327            &path,
2328            format!(
2329                "interval: 1h\noutputs:\n  report: '{}'\ntargets:\n  - kind: token\n    id: \"0x1234567890123456789012345678901234567890\"\n    rules: [\"price_usd > 0\"]\n",
2330                report.display()
2331            ),
2332        )
2333        .unwrap();
2334        let line = format!(
2335            "watch open {}",
2336            path.display().to_string().replace('\\', "/")
2337        );
2338        // A second, idle Enter lets the first tick land before Esc.
2339        let (res, mut state, _, _) = drive(script(&[&line, "", "<esc>"])).await;
2340        res.unwrap();
2341        let g = state.grid.as_ref().unwrap();
2342        assert!(!g.is_running());
2343        let (footer, _) = g.ended.clone().unwrap();
2344        assert!(footer.starts_with("Stopped after"), "{footer}");
2345        assert!(g.last.is_some(), "the first tick landed before Esc");
2346        assert!(footer.contains("Report saved to"), "{footer}");
2347        assert!(
2348            std::fs::read_to_string(&report)
2349                .unwrap()
2350                .contains("# Watchlist Report")
2351        );
2352        // Esc again closes the finished grid into the scrollback.
2353        state.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2354        assert!(state.grid.is_none() && !state.grid_shown);
2355        assert!(
2356            state.scrollback_text().contains("watchlist w:"),
2357            "{}",
2358            state.scrollback_text()
2359        );
2360    }
2361
2362    const TOKEN: &str = "0x1234567890123456789012345678901234567890";
2363
2364    fn ctrl_key(c: char) -> io::Result<Event> {
2365        Ok(Event::Key(KeyEvent::new(
2366            KeyCode::Char(c),
2367            KeyModifiers::CONTROL,
2368        )))
2369    }
2370
2371    #[tokio::test]
2372    async fn test_monitor_opens_the_wizard_filled_from_the_arguments() {
2373        let _lock = DIAG_LOCK.lock().await;
2374        let (res, state, host, _) = drive(script(&[
2375            "monitor USDN --venue weex --every 30s --duration 1h --layout feed",
2376        ]))
2377        .await;
2378        res.unwrap();
2379        let w = state.wizard.as_ref().expect("the wizard opened");
2380        assert_eq!(w.list.interval, std::time::Duration::from_secs(30));
2381        let t = &w.list.targets[0];
2382        assert_eq!(
2383            (t.id.as_str(), t.venues.as_slice()),
2384            ("USDN", ["weex".to_string()].as_slice())
2385        );
2386        assert_eq!(t.preset, Some(scope::watch::Preset::MarketHealth));
2387        assert!(state.grid.is_none(), "nothing runs before Start");
2388        let screen = screen_text(&host);
2389        assert!(screen.contains("monitor wizard"), "{screen}");
2390        assert!(screen.contains("[x] market health"), "{screen}");
2391        assert!(screen.contains("--layout has no effect"), "{screen}");
2392    }
2393
2394    #[tokio::test]
2395    async fn test_ctrl_r_in_the_wizard_starts_the_grid() {
2396        let _lock = DIAG_LOCK.lock().await;
2397        let mut events = script(&[&format!("monitor {TOKEN} --duration 1h")]);
2398        events.push(ctrl_key('r'));
2399        let (res, mut state, _, _) = drive(events).await;
2400        res.unwrap();
2401        state.poll_watch();
2402        assert!(state.wizard.is_none());
2403        let g = state.grid.as_ref().expect("the run started");
2404        assert!(g.is_running());
2405        let names: Vec<String> = g.list.targets[0]
2406            .rules
2407            .iter()
2408            .map(ToString::to_string)
2409            .collect();
2410        assert_eq!(names, ["price_usd > 0", "volume_24h >= 0"]);
2411    }
2412
2413    #[tokio::test]
2414    async fn test_monitor_yes_starts_at_once_and_a_bad_argument_says_why() {
2415        let _lock = DIAG_LOCK.lock().await;
2416        // The grid takes the keys: Ctrl-G goes back to the shell.
2417        let mut events = script(&[&format!("monitor {TOKEN} --yes")]);
2418        events.push(ctrl_key('g'));
2419        events.extend(script(&["monitor USDC --venue binance --every 0s"]));
2420        let (res, mut state, _, _) = drive(events).await;
2421        res.unwrap();
2422        state.poll_watch();
2423        assert!(state.wizard.is_none());
2424        assert!(state.grid.as_ref().is_some_and(|g| g.is_running()));
2425        assert!(
2426            state
2427                .scrollback_text()
2428                .contains("monitor: --every: Duration must be positive"),
2429            "{}",
2430            state.scrollback_text()
2431        );
2432    }
2433
2434    #[tokio::test]
2435    async fn test_scope_monitor_opens_the_tui_with_the_wizard() {
2436        let _lock = DIAG_LOCK.lock().await;
2437        let (mut host, _) = test_host(Vec::new(), Vec::new());
2438        let mut state = TuiState::new(
2439            SessionContext::default(),
2440            Journal::default(),
2441            vocab::Vocab::default(),
2442        );
2443        let cli = <crate::cli::Cli as clap::Parser>::try_parse_from([
2444            "scope", "monitor", "USDN", "--venue", "weex",
2445        ])
2446        .unwrap();
2447        state.start = Some(cli.command);
2448        let settings = settings_with(Box::new(MockClientFactory::new()));
2449        let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
2450        session(&mut host, &mut state, settings, &reload, |_| Ok(()))
2451            .await
2452            .unwrap();
2453        assert!(state.wizard.is_some(), "the wizard is open at the start");
2454        assert!(screen_text(&host).contains("monitor wizard"));
2455    }
2456
2457    /// A DEX source that writes a library warning on every call, then
2458    /// answers as the mock does.
2459    struct NoisyDex(scope::chains::mocks::MockDexSource);
2460
2461    const NOISE: &str = "noisy dex warning";
2462
2463    #[async_trait::async_trait]
2464    impl scope::chains::DexDataSource for NoisyDex {
2465        async fn get_token_price(&self, c: &str, a: &str) -> Option<f64> {
2466            scope::notice!("{NOISE}");
2467            self.0.get_token_price(c, a).await
2468        }
2469        async fn get_native_token_price(&self, c: &str) -> Option<f64> {
2470            scope::notice!("{NOISE}");
2471            self.0.get_native_token_price(c).await
2472        }
2473        async fn get_token_data(
2474            &self,
2475            c: &str,
2476            a: &str,
2477        ) -> scope::Result<scope::chains::dex::DexTokenData> {
2478            scope::notice!("{NOISE}");
2479            self.0.get_token_data(c, a).await
2480        }
2481        async fn search_tokens(
2482            &self,
2483            q: &str,
2484            c: Option<&str>,
2485        ) -> scope::Result<Vec<scope::chains::dex::TokenSearchResult>> {
2486            scope::notice!("{NOISE}");
2487            self.0.search_tokens(q, c).await
2488        }
2489    }
2490
2491    struct NoisyFactory(MockClientFactory);
2492
2493    impl ChainClientFactory for NoisyFactory {
2494        fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
2495            scope::http::test_transport()
2496        }
2497        fn create_chain_client(
2498            &self,
2499            chain: &str,
2500        ) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
2501            self.0.create_chain_client(chain)
2502        }
2503        fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
2504            Box::new(NoisyDex(scope::chains::mocks::MockDexSource::new()))
2505        }
2506    }
2507
2508    /// A one-target token watchlist that ticks once an hour, in `dir`.
2509    fn token_watchlist(dir: &tempfile::TempDir) -> String {
2510        let path = dir.path().join("noisy.yaml");
2511        std::fs::write(
2512            &path,
2513            format!("interval: 1h\ntargets:\n  - kind: token\n    id: \"{TOKEN}\"\n    rules: [\"price_usd > 0\"]\n"),
2514        )
2515        .unwrap();
2516        format!(
2517            "watch open {}",
2518            path.display().to_string().replace('\\', "/")
2519        )
2520    }
2521
2522    #[tokio::test]
2523    async fn test_a_watchlist_warning_stays_out_of_the_shell_and_comes_with_the_grid() {
2524        let _lock = DIAG_LOCK.lock().await;
2525        let dir = tempfile::tempdir().unwrap();
2526        let mut events = script(&[&token_watchlist(&dir)]);
2527        // Back to the shell, where a pane command runs next to the watchlist.
2528        events.push(ctrl_key('g'));
2529        events.extend(script(&["venues list"]));
2530        let (host, captured) = test_host(events, Vec::new());
2531        let (res, mut state, _, _) = drive_host_on(
2532            Journal::default(),
2533            host,
2534            captured,
2535            Box::new(NoisyFactory(MockClientFactory::new())),
2536        )
2537        .await;
2538        res.unwrap();
2539        state.poll_watch();
2540        assert!(
2541            state.grid.as_ref().is_some_and(|g| g.last.is_some()),
2542            "the watchlist ticked"
2543        );
2544        let shell = state.scrollback_text();
2545        assert!(shell.contains("binance"), "venues list ran: {shell}");
2546        assert!(
2547            !shell.contains(NOISE),
2548            "no watchlist warning in the shell: {shell}"
2549        );
2550        // The run's own output keeps it; closing the grid shows it.
2551        state.stop_watch();
2552        state.close_grid();
2553        assert!(state.scrollback_text().contains(NOISE));
2554    }
2555
2556    #[tokio::test]
2557    async fn test_a_setup_reload_says_the_running_watchlist_uses_the_old_settings() {
2558        let _lock = DIAG_LOCK.lock().await;
2559        let dir = tempfile::tempdir().unwrap();
2560        let mut events = script(&[&token_watchlist(&dir)]);
2561        events.push(ctrl_key('g'));
2562        events.extend(script(&["setup --key nosuchkey"]));
2563        let reload = || Ok(settings_with(Box::new(MockClientFactory::new())));
2564        let (host, captured) = test_host(events, Vec::new());
2565        let (res, state, host, _) = drive_host_reloading(
2566            Journal::default(),
2567            host,
2568            captured,
2569            settings_with(Box::new(MockClientFactory::new())),
2570            &reload,
2571        )
2572        .await;
2573        res.unwrap();
2574        let g = state.grid.as_ref().expect("the grid");
2575        assert!(g.stale, "the grid knows");
2576        assert!(state.scrollback_text().contains(grid::STALE_NOTE));
2577        assert!(
2578            screen_text(&host).contains("old settings"),
2579            "{}",
2580            screen_text(&host)
2581        );
2582    }
2583
2584    #[tokio::test]
2585    async fn test_ctrl_g_without_a_grid_says_how_to_start_one() {
2586        let _lock = DIAG_LOCK.lock().await;
2587        let (res, state, _, _) = drive(vec![Ok(Event::Key(KeyEvent::new(
2588            KeyCode::Char('g'),
2589            KeyModifiers::CONTROL,
2590        )))])
2591        .await;
2592        res.unwrap();
2593        assert!(state.scrollback_text().contains("No watchlist runs"));
2594    }
2595
2596    #[tokio::test]
2597    async fn test_loop_esc_cancels_a_running_repeat_command() {
2598        // `--every` sleeps between runs; Esc must stop it, not wait 1 minute.
2599        let _lock = DIAG_LOCK.lock().await;
2600        let (res, state, _, _) =
2601            drive_pending(script(&[PENDING_REPEAT, "<esc>"]), Vec::new()).await;
2602        res.unwrap();
2603        let text = state.scrollback_text();
2604        assert!(text.contains("Cancelled after"), "{}", text);
2605        assert!(state.running.is_none());
2606    }
2607
2608    #[tokio::test]
2609    async fn test_loop_suspend_mode_hands_over_the_terminal_and_records() {
2610        // A suspend-mode command must release the screen, run on the plain
2611        // terminal, take the screen back, and leave a trace in the pane.
2612        // `setup --key` with an unknown name returns before reading stdin.
2613        let _lock = DIAG_LOCK.lock().await;
2614        let (res, state, host, suspended) = drive(script(&["setup --key nosuchkey"])).await;
2615        res.unwrap();
2616        assert_eq!((host.suspends, host.resumes), (1, 1));
2617        assert!(
2618            suspended
2619                .out()
2620                .contains("── scope setup --key nosuchkey ──")
2621        );
2622        assert!(suspended.out().contains(
2623            "Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
2624        ));
2625        // It returns by itself: there is no Enter to wait for.
2626        assert!(!suspended.out().contains("Press Enter"));
2627        assert_ran_note(&state, "setup --key nosuchkey", "ok");
2628    }
2629
2630    #[tokio::test]
2631    async fn test_loop_ignores_non_press_keys_and_ends_on_stream_end() {
2632        let _lock = DIAG_LOCK.lock().await;
2633        let mut events = vec![Ok(Event::Resize(80, 24))];
2634        let mut release = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
2635        release.kind = KeyEventKind::Release;
2636        release.state = KeyEventState::NONE;
2637        events.push(Ok(Event::Key(release)));
2638        let (res, state, _, _) = drive(events).await;
2639        res.unwrap();
2640        assert_eq!(state.input, "", "a key release must not type");
2641    }
2642
2643    #[tokio::test]
2644    async fn test_loop_ctrl_d_exits() {
2645        let _lock = DIAG_LOCK.lock().await;
2646        let (res, _, _, _) = drive(vec![Ok(Event::Key(KeyEvent::new(
2647            KeyCode::Char('d'),
2648            KeyModifiers::CONTROL,
2649        )))])
2650        .await;
2651        res.unwrap();
2652    }
2653
2654    #[tokio::test]
2655    async fn test_loop_returns_event_stream_errors() {
2656        // A broken terminal must end the TUI with the error, not spin.
2657        let _lock = DIAG_LOCK.lock().await;
2658        let (res, _, _, _) = drive(vec![Err(io::Error::other("tty gone"))]).await;
2659        assert!(res.unwrap_err().to_string().contains("tty gone"));
2660    }
2661
2662    #[tokio::test]
2663    async fn test_library_warning_lands_in_the_running_command_output() {
2664        // scope-core warnings use diag::notice; while a pane command runs
2665        // they must join its output, not print over the TUI screen.
2666        let _lock = DIAG_LOCK.lock().await;
2667        let mut s = TuiState::new(
2668            SessionContext::default(),
2669            Journal::default(),
2670            vocab::Vocab::default(),
2671        );
2672        let Plan::Run(inv) = s.submit("venues list", &Config::default()) else {
2673            panic!()
2674        };
2675        let _out = s.start("venues list", &inv);
2676        scope::notice!("  ⚠ library warning");
2677        s.finish(Ending::Finished(Ok(())));
2678        assert!(s.scrollback_text().contains("⚠ library warning"));
2679    }
2680
2681    #[cfg(unix)]
2682    #[tokio::test]
2683    async fn test_signal_stream_installs_all_handlers() {
2684        // Installing handlers sends no signal. A failure to install one
2685        // must stop the TUI before it takes the terminal.
2686        assert!(signal_stream().is_ok());
2687    }
2688}