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