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, a
27//! token search with a selection prompt, the live monitor) runs in suspend
28//! mode: the TUI gives the terminal back while it runs. What it writes
29//! through its output also joins the 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 hint;
38pub mod journal;
39pub mod palette;
40pub(crate) mod repeat_view;
41pub mod vocab;
42
43use app::{Action, Ending, TuiState, run_invocation};
44use crossterm::event::{Event, EventStream, KeyEventKind};
45use exec::{Plan, Route};
46use futures::StreamExt;
47use futures::stream::BoxStream;
48use journal::Journal;
49use scope::chains::ChainClientFactory;
50use scope::config::Config;
51use scope::error::{Result, ScopeError};
52use std::future::Future;
53use std::io::{self, Write};
54use std::pin::Pin;
55use std::sync::atomic::{AtomicBool, Ordering};
56use std::sync::{Arc, Mutex};
57use std::time::{Duration, Instant};
58
59use crate::cli::interactive::SessionContext;
60use crate::cli::output::{Captured, Output};
61use crate::cli::prompt;
62use crate::cli::screen;
63
64/// How long `run_suspended` waits for the signal behind a stopped
65/// prompt. The signal is normally already queued.
66const PROMPT_SIGNAL_WAIT: Duration = Duration::from_secs(1);
67
68/// How often the screen redraws while a command runs, so its output and
69/// elapsed time stay current.
70const LIVE_REDRAW: Duration = Duration::from_millis(100);
71
72/// How many bytes of a suspend-mode command's output the TUI keeps to
73/// show after it returns: about the scrollback limit of 200-column lines.
74/// A long crawl cannot grow it further.
75const OUTSIDE_COPY_LIMIT: usize = app::SCROLLBACK_LIMIT * 200;
76
77/// The startup banner.
78const BANNER: &str = include_str!("../../../assets/banner.txt");
79
80/// The shell convention for the exit code after a fatal signal: 128 plus
81/// the signal number.
82const SIGNAL_EXIT_BASE: i32 = 128;
83
84/// How the TUI ended.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct TuiExit {
87    /// The process exit code: 0 when the user left, 128+N after signal N.
88    pub code: i32,
89}
90
91/// A process signal the TUI listens for.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub(crate) enum Signal {
94    Term,
95    Hup,
96    Int,
97    Quit,
98    Tstp,
99    Cont,
100}
101
102impl Signal {
103    /// The name without `SIG`, as the journal records it.
104    fn name(self) -> &'static str {
105        match self {
106            Signal::Term => "TERM",
107            Signal::Hup => "HUP",
108            Signal::Int => "INT",
109            Signal::Quit => "QUIT",
110            Signal::Tstp => "TSTP",
111            Signal::Cont => "CONT",
112        }
113    }
114
115    /// True for a signal that ends a suspend-mode command: INT cancels
116    /// it, TERM, HUP and QUIT end the session.
117    fn ends_suspended(self) -> bool {
118        matches!(
119            self,
120            Signal::Int | Signal::Term | Signal::Hup | Signal::Quit
121        )
122    }
123
124    /// The exit code for a signal that ends the TUI. `None` for a signal
125    /// that does not end it. The numbers are fixed by POSIX.
126    fn exit_code(self) -> Option<i32> {
127        let number = match self {
128            Signal::Hup => 1,
129            Signal::Int => 2,
130            Signal::Quit => 3,
131            Signal::Term => 15,
132            Signal::Tstp | Signal::Cont => return None,
133        };
134        Some(SIGNAL_EXIT_BASE + number)
135    }
136}
137
138/// Checks that the TUI has a terminal to draw on and read keys from.
139///
140/// `scope tui` needs stdin and stdout on a terminal, and a `TERM` that is
141/// not `dumb`. Pure: the caller passes the facts.
142///
143/// # Errors
144///
145/// Returns the message to print when any of them is missing.
146pub fn tui_terminal_ok(
147    stdin_tty: bool,
148    stdout_tty: bool,
149    term: Option<&str>,
150) -> std::result::Result<(), String> {
151    if stdin_tty && stdout_tty && term != Some("dumb") {
152        return Ok(());
153    }
154    Err("scope tui needs an interactive terminal; run the command directly, e.g. scope market summary …".to_string())
155}
156
157/// Runs the TUI until the user leaves or a signal ends it.
158///
159/// # Errors
160///
161/// Returns an error when the signal handlers cannot be installed, a
162/// terminal I/O error, or the error from saving the history or the
163/// session context on exit.
164pub async fn run(
165    no_banner: bool,
166    config: &Config,
167    clients: &dyn ChainClientFactory,
168) -> Result<TuiExit> {
169    let signals = signal_stream()?;
170    // A second stream gets the same signals (tokio gives each one to
171    // every listener). Only `scope tui` installs these handlers; a plain
172    // CLI run keeps the default Ctrl-C.
173    tokio::spawn(cancel_prompts_on(signal_stream()?, &prompt::CANCEL));
174    // The crash hook, then raw mode before the loads, so keys typed
175    // during startup are not read in cooked mode (issue #63). A failed
176    // `enter` has given the screen back already. Nothing after it can
177    // return an error before `session`, which restores the terminal.
178    crash::install_panic_hook();
179    let mut host = RealHost {
180        terminal: screen::enter(screen::Mouse::Off)?,
181        events: None,
182        signals,
183        on_screen: true,
184    };
185    let mut state = TuiState::new(
186        SessionContext::load(),
187        Journal::in_data_dir(),
188        vocab::Vocab::load(config),
189    );
190    welcome(&mut state, no_banner);
191    session(&mut host, &mut state, config, clients, SessionContext::save).await
192}
193
194/// Runs the event loop, then restores the terminal and saves, also after
195/// a signal. `save_ctx` saves the session context.
196async fn session<H: Host>(
197    host: &mut H,
198    state: &mut TuiState,
199    config: &Config,
200    clients: &dyn ChainClientFactory,
201    save_ctx: impl FnOnce(&SessionContext) -> Result<()>,
202) -> Result<TuiExit> {
203    let result = event_loop(host, state, config, clients).await;
204    // The CLI after the TUI, also after an error or a signal, uses the
205    // real width.
206    host.set_width(None);
207    host.restore();
208
209    // Save both even if one fails, then report the first failure.
210    let saved_history = state.journal().save_history(&state.history);
211    let saved_ctx = save_ctx(&state.ctx);
212    let exit = result?;
213    saved_history?;
214    saved_ctx?;
215    Ok(exit)
216}
217
218/// The signals the TUI listens for, as one stream. Installing a handler
219/// replaces the default action, so these no longer kill the process.
220#[cfg(unix)]
221fn signal_stream() -> Result<BoxStream<'static, Signal>> {
222    use tokio::signal::unix::{SignalKind, signal};
223    let kinds = [
224        (SignalKind::terminate(), Signal::Term),
225        (SignalKind::hangup(), Signal::Hup),
226        (SignalKind::interrupt(), Signal::Int),
227        (SignalKind::quit(), Signal::Quit),
228        (SignalKind::from_raw(libc::SIGTSTP), Signal::Tstp),
229        (SignalKind::from_raw(libc::SIGCONT), Signal::Cont),
230    ];
231    let mut streams = Vec::with_capacity(kinds.len());
232    for (kind, sig) in kinds {
233        let mut handler = signal(kind).map_err(|e| {
234            ScopeError::io_context(e, format!("cannot install the SIG{} handler", sig.name()))
235        })?;
236        streams.push(futures::stream::poll_fn(move |cx| {
237            handler.poll_recv(cx).map(|got| got.map(|()| sig))
238        }));
239    }
240    Ok(futures::stream::select_all(streams).boxed())
241}
242
243/// No signal handling off unix.
244#[cfg(not(unix))]
245fn signal_stream() -> Result<BoxStream<'static, Signal>> {
246    Ok(futures::stream::pending().boxed())
247}
248
249fn welcome(state: &mut TuiState, no_banner: bool) {
250    if !no_banner {
251        state.push_ansi(BANNER);
252    }
253    state.push_note("Type `help` for commands. `exit` or Ctrl-D leaves.");
254    // First steps (#66): a few commands to try, and the two keys that find
255    // the rest.
256    state.push_note(
257        "Try: `market summary USDC --venue binance` · `venues list` · `insights <address>`",
258    );
259    state.push_note("Tab completes a command or flag; Ctrl-K opens the command palette.");
260}
261
262/// Serializes tests that redirect the process-global diagnostics sink
263/// (every test that calls `TuiState::start`). Without it, parallel tests
264/// swap each other's sink. tokio's Mutex, so async tests can hold it
265/// across `.await` without `clippy::await_holding_lock`.
266#[cfg(test)]
267pub(crate) static DIAG_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
268
269/// The terminal side of the event loop. Production uses the real terminal;
270/// tests use a `TestBackend` and a scripted key stream.
271pub(crate) trait Host {
272    type B: ratatui::backend::Backend;
273    type Events: futures::Stream<Item = io::Result<Event>> + Unpin;
274    type Signals: futures::Stream<Item = Signal> + Unpin;
275    fn terminal(&mut self) -> &mut ratatui::Terminal<Self::B>;
276    /// The terminal events and the process signals. One method, so the
277    /// event loop can wait on both at once.
278    fn streams(&mut self) -> (&mut Self::Events, &mut Self::Signals);
279    /// Gives the terminal back for a suspend-mode command.
280    fn suspend(&mut self);
281    /// Takes the terminal again after a suspend-mode command.
282    ///
283    /// # Errors
284    ///
285    /// The terminal cannot be taken ([`screen::not_taken`]).
286    fn resume(&mut self) -> Result<()>;
287    /// Gives the terminal back for good when the TUI ends.
288    fn restore(&mut self);
289    /// Stops the process until the shell continues it (`fg`). Call it
290    /// after `suspend`, so the shell gets a clean terminal. The kernel
291    /// sends SIGCONT when the process continues.
292    fn stop_self(&mut self) -> io::Result<()>;
293    /// Clears the screen, so the next draw repaints all of it.
294    fn clear(&mut self) -> io::Result<()>;
295    /// The output a suspend-mode command writes to.
296    fn suspended_output(&self) -> Output;
297    /// Writes `text` to the terminal as is (the OSC 52 copy sequence).
298    fn write_raw(&mut self, text: &str) -> io::Result<()>;
299    /// Sets the width that command output fits (`None`: the real terminal
300    /// width). Production sets the process-wide width override.
301    fn set_width(&mut self, width: Option<usize>);
302}
303
304/// The real terminal.
305struct RealHost {
306    terminal: ratatui::DefaultTerminal,
307    /// `None` while a suspend-mode command owns stdin: the event reader
308    /// must not consume the keys that command reads.
309    events: Option<EventStream>,
310    signals: BoxStream<'static, Signal>,
311    /// True while the TUI owns the screen (raw mode, alternate screen).
312    on_screen: bool,
313}
314
315impl Host for RealHost {
316    type B = ratatui::backend::CrosstermBackend<io::Stdout>;
317    type Events = EventStream;
318    type Signals = BoxStream<'static, Signal>;
319
320    fn terminal(&mut self) -> &mut ratatui::Terminal<Self::B> {
321        &mut self.terminal
322    }
323    fn streams(&mut self) -> (&mut EventStream, &mut Self::Signals) {
324        (
325            self.events.get_or_insert_with(EventStream::new),
326            &mut self.signals,
327        )
328    }
329    fn suspend(&mut self) {
330        self.events = None;
331        self.restore();
332    }
333    fn resume(&mut self) -> Result<()> {
334        self.terminal = screen::enter(screen::Mouse::Off)?;
335        self.on_screen = true;
336        Ok(())
337    }
338    /// Does nothing when the screen is already given back: leaving the
339    /// alternate screen twice can move the shell's cursor.
340    fn restore(&mut self) {
341        if std::mem::take(&mut self.on_screen) {
342            screen::restore();
343        }
344    }
345    /// SIGSTOP, not SIGTSTP: the TUI handles SIGTSTP itself, and SIGSTOP
346    /// cannot be caught. The call returns when the process continues.
347    #[cfg(unix)]
348    fn stop_self(&mut self) -> io::Result<()> {
349        // SAFETY: kill takes plain integers and has no memory effects.
350        let rc = unsafe { libc::kill(libc::getpid(), libc::SIGSTOP) };
351        if rc == 0 {
352            Ok(())
353        } else {
354            Err(io::Error::last_os_error())
355        }
356    }
357    #[cfg(not(unix))]
358    fn stop_self(&mut self) -> io::Result<()> {
359        Err(io::Error::other("job control needs a unix host"))
360    }
361    fn clear(&mut self) -> io::Result<()> {
362        self.terminal.clear()
363    }
364    fn suspended_output(&self) -> Output {
365        Output::stdio()
366    }
367    fn write_raw(&mut self, text: &str) -> io::Result<()> {
368        let mut out = io::stdout();
369        out.write_all(text.as_bytes())?;
370        out.flush()
371    }
372    fn set_width(&mut self, width: Option<usize>) {
373        scope::display::terminal::set_width_override(width);
374    }
375}
376
377/// The output of a command that runs outside the TUI (suspend mode).
378struct Outside {
379    /// The plain terminal. The header goes only here: it is stale once
380    /// the TUI is back.
381    terminal: Output,
382    /// What the command writes to: the terminal, and a copy the TUI shows
383    /// in the scrollback when it returns. Its hints use TUI wording,
384    /// because they are read in the TUI.
385    out: Output,
386    copy: Captured,
387}
388
389impl Outside {
390    fn new(terminal: Output) -> Self {
391        let (out, copy) = terminal.teed(OUTSIDE_COPY_LIMIT);
392        Self {
393            terminal,
394            out,
395            copy,
396        }
397    }
398}
399
400type PaneFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
401
402/// After `setup` in the TUI: the session keeps the config it read at start.
403const SETUP_RESTART_NOTE: &str =
404    "Settings are saved. Leave the TUI (exit) and start it again to use them.";
405
406async fn event_loop<H: Host>(
407    host: &mut H,
408    state: &mut TuiState,
409    config: &Config,
410    clients: &dyn ChainClientFactory,
411) -> Result<TuiExit> {
412    const LEFT: TuiExit = TuiExit { code: 0 };
413    // Library warnings between commands land here and join the scrollback.
414    let idle_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
415    let mut idle_diag = Some(scope::diag::redirect(idle_buf.clone()));
416    let mut pending: Option<PaneFuture<'_>> = None;
417    let mut tick = tokio::time::interval(LIVE_REDRAW);
418    // After a stop (Ctrl-Z, then `fg`), fire once, not once per missed
419    // tick: the tick arm comes before the keys in the biased select.
420    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
421    // True after the TUI stopped itself: the SIGCONT that continues it
422    // needs no second set-up.
423    let mut cont_expected = false;
424
425    loop {
426        drain_into(&idle_buf, state);
427        host.terminal()
428            .draw(|f| {
429                state.render(f);
430                crate::cli::plain::finish_frame(f);
431            })
432            // The backend's error type is generic and need not be Send, so
433            // only its text can travel.
434            .map_err(|e| {
435                ScopeError::io_context(io::Error::other(e.to_string()), "terminal draw failed")
436            })?;
437        // Command output fits the pane. Set after each draw, so it follows
438        // a resize and comes back after the TUI leaves the terminal.
439        host.set_width(state.pane_width());
440
441        // Biased: a finished command is handled before the next key, so
442        // the key that follows it sees the command as done. A signal comes
443        // before the keys.
444        let (events, signals) = host.streams();
445        tokio::select! {
446            biased;
447            res = async { pending.as_mut().expect("guarded by precondition").await }, if pending.is_some() => {
448                pending = None;
449                state.finish(Ending::Finished(res));
450            }
451            _ = tick.tick(), if state.running.is_some() => {}
452            Some(sig) = signals.next() => {
453                if let Some(code) = sig.exit_code() {
454                    // Drop the command future first: that cancels it.
455                    drop(pending.take());
456                    state.finish(Ending::Cancelled);
457                    state.record_signal(sig.name(), "session", "", &[]);
458                    return Ok(TuiExit { code });
459                }
460                // Merge window: the kernel keeps one pending SIGCONT, and
461                // the handler stream can merge signals too. So a second
462                // SIGCONT that comes before this arm runs arrives as one,
463                // and is taken as ours. That is safe: the screen was
464                // already taken back and cleared after the stop.
465                if sig == Signal::Tstp {
466                    cont_expected = stop_and_return(host, state)?;
467                } else if !std::mem::take(&mut cont_expected) {
468                    // SIGCONT after an outside SIGSTOP: the shell reset the
469                    // terminal, so set it up again from scratch.
470                    leave(host);
471                    retake(host)?;
472                }
473            }
474            ev = events.next() => {
475                let key = match ev {
476                    Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => k,
477                    Some(Ok(Event::Paste(text))) => {
478                        state.paste(&text);
479                        continue;
480                    }
481                    Some(Ok(_)) => continue,
482                    Some(Err(e)) => return Err(ScopeError::IoError(e)),
483                    None => return Ok(LEFT),
484                };
485                match state.handle_key(key) {
486                    Action::None => {}
487                    Action::Exit => return Ok(LEFT),
488                    Action::Suspend => cont_expected = stop_and_return(host, state)?,
489                    Action::Copy(text) => {
490                        let result = host.write_raw(&clip::osc52(&text));
491                        state.copied(text.lines().count(), result);
492                    }
493                    Action::Cancel => {
494                        // Dropping the future cancels the handler at its
495                        // next await point.
496                        pending = None;
497                        state.finish(Ending::Cancelled);
498                    }
499                    Action::Submit(line) => match state.submit(&line, config) {
500                        Plan::Done => {}
501                        Plan::Exit => return Ok(LEFT),
502                        Plan::Run(inv) => match inv.route {
503                            Route::Pane => {
504                                let out = state.start(&line, &inv);
505                                pending = Some(Box::pin(async move {
506                                    run_invocation(*inv, config, clients, &out).await
507                                }));
508                            }
509                            Route::Suspend => {
510                                drop(idle_diag.take());
511                                leave(host);
512                                let outside = Outside::new(host.suspended_output());
513                                let argv = inv.argv.clone();
514                                let command = run_invocation(*inv, config, clients, &outside.out);
515                                let ended =
516                                    run_suspended(host, &line, &argv, &outside, command, state)
517                                        .await;
518                                if let Some(exit) = ended {
519                                    // The terminal stays cooked; session()
520                                    // restores and saves.
521                                    return Ok(exit);
522                                }
523                                host.resume()?;
524                                idle_diag.replace(scope::diag::redirect(idle_buf.clone()));
525                                if argv.get(1).is_some_and(|c| c == "setup") {
526                                    // The clients hold the config read at start.
527                                    state.push_note(SETUP_RESTART_NOTE);
528                                }
529                            }
530                            Route::Refuse(reason) => {
531                                // exec::plan writes refusals and never
532                                // returns them; keep this loud anyway.
533                                state.push_note(&format!("Not available here: {}.", reason));
534                            }
535                        },
536                    },
537                }
538            }
539        }
540    }
541}
542
543/// Gives the terminal back and stops the process (Ctrl-Z, SIGTSTP). When
544/// the shell continues it, takes the screen again. A running pane command
545/// is kept; it is not polled while the process is stopped. Returns true
546/// when the process stopped, so a SIGCONT follows.
547fn stop_and_return<H: Host>(host: &mut H, state: &mut TuiState) -> Result<bool> {
548    leave(host);
549    let stopped = match host.stop_self() {
550        Ok(()) => true,
551        Err(e) => {
552            state.push_note(&format!("Could not stop the process: {}", e));
553            false
554        }
555    };
556    retake(host)?;
557    Ok(stopped)
558}
559
560/// Gives the terminal back. Output written while the TUI is away (a
561/// suspend-mode command, the shell after Ctrl-Z) uses the real width.
562fn leave<H: Host>(host: &mut H) {
563    host.set_width(None);
564    host.suspend();
565}
566
567/// Takes the terminal again and clears it. The next draw then repaints
568/// all of the screen, also what the shell wrote over it.
569fn retake<H: Host>(host: &mut H) -> Result<()> {
570    host.resume()?;
571    host.clear()
572        .map_err(|e| ScopeError::io_context(e, "terminal clear failed"))
573}
574
575/// Runs one command outside the TUI. It returns to the TUI when the
576/// command ends, or when INT (Ctrl-C) cancels it. `command` writes to
577/// `outside.out`; when it ends, its output and error text join the
578/// scrollback, above the `Ran outside the TUI` note. Returns the exit when
579/// TERM, HUP or QUIT ends the session first; the command is dropped and
580/// the signal recorded.
581async fn run_suspended<H: Host>(
582    host: &mut H,
583    line: &str,
584    argv: &[String],
585    outside: &Outside,
586    command: impl Future<Output = Result<()>>,
587    state: &mut TuiState,
588) -> Option<TuiExit> {
589    let started = Instant::now();
590    let started_at = chrono::Utc::now().to_rfc3339();
591    // A cancel from an earlier command must not stop this one's prompts.
592    prompt::CANCEL.store(false, Ordering::SeqCst);
593    let mut terminal_errors = Vec::new();
594    crate::note!(outside.terminal, "\n── scope {} ──", line);
595    crate::note!(
596        outside.terminal,
597        "Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
598    );
599    let ended = tokio::select! {
600        biased;
601        Some(sig) = ending_signal(host.streams().1) => Err(sig),
602        result = command => Ok(result),
603    };
604    // A prompt blocks this task, so the select can miss a signal that
605    // came meanwhile. The prompt watcher saw it and set the flag; the
606    // stream has it too, because each listener gets every signal. Take it
607    // from the stream, or the TUI would see it later. The wait has a
608    // limit, so a broken invariant cannot hang the TUI on a cooked
609    // terminal; it then counts as a cancel.
610    let ended = match ended {
611        Ok(_) if prompt::CANCEL.load(Ordering::SeqCst) => {
612            let queued =
613                tokio::time::timeout(PROMPT_SIGNAL_WAIT, ending_signal(host.streams().1)).await;
614            Err(queued.ok().flatten().unwrap_or(Signal::Int))
615        }
616        ended => ended,
617    };
618    let ending = match ended {
619        Ok(result) => Ending::Finished(result),
620        Err(Signal::Int) => Ending::Cancelled,
621        Err(sig) => return Some(end_suspended(state, line, argv, sig)),
622    };
623    let duration = started.elapsed();
624    if let Ending::Finished(Err(e)) = &ending
625        && let Err(write_err) = crate::cli::errors::display_error(e, &outside.out)
626    {
627        terminal_errors.push(write_err.to_string());
628    }
629    state.show_outside(&outside.copy, &ending);
630    state.record_suspended(line, argv, duration, started_at, &ending);
631    for e in terminal_errors {
632        state.push_note(&format!("Terminal I/O error while outside the TUI: {}", e));
633    }
634    if matches!(ending, Ending::Cancelled)
635        && let Some(sig) = drain_extra_ints(host.streams().1)
636    {
637        return Some(end_suspended(state, line, argv, sig));
638    }
639    None
640}
641
642/// Takes the signals already queued after a cancel, without waiting. A
643/// second Ctrl-C must not reach the TUI and end it. Drops INT, TSTP and
644/// CONT; returns the first TERM, HUP or QUIT, which still ends the
645/// session.
646fn drain_extra_ints<S: futures::Stream<Item = Signal> + Unpin>(signals: &mut S) -> Option<Signal> {
647    use futures::FutureExt;
648    while let Some(Some(sig)) = signals.next().now_or_never() {
649        if sig.ends_suspended() && sig != Signal::Int {
650            return Some(sig);
651        }
652    }
653    None
654}
655
656/// Waits for a signal that ends a suspend-mode command: INT cancels it;
657/// TERM, HUP and QUIT end the session. It drops TSTP and CONT, because
658/// the command owns the terminal.
659async fn ending_signal<S: futures::Stream<Item = Signal> + Unpin>(
660    signals: &mut S,
661) -> Option<Signal> {
662    while let Some(sig) = signals.next().await {
663        if sig.ends_suspended() {
664            return Some(sig);
665        }
666    }
667    None
668}
669
670/// Records that `sig` ended the suspend-mode command `line`; returns the
671/// exit for it.
672fn end_suspended(state: &mut TuiState, line: &str, argv: &[String], sig: Signal) -> TuiExit {
673    state.record_signal(sig.name(), "suspend", line, argv);
674    TuiExit {
675        code: sig.exit_code().expect("TERM, HUP and QUIT have exit codes"),
676    }
677}
678
679/// Sets `flag` on each signal that ends a suspend-mode command, so a
680/// prompt read stops (see [`prompt`]). It runs as its own task: while a
681/// prompt blocks, the event loop's task cannot see the signals.
682async fn cancel_prompts_on<S: futures::Stream<Item = Signal> + Unpin>(
683    mut signals: S,
684    flag: &AtomicBool,
685) {
686    while let Some(sig) = signals.next().await {
687        if sig.ends_suspended() {
688            flag.store(true, Ordering::SeqCst);
689        }
690    }
691}
692
693/// Moves buffered library warnings into the scrollback.
694fn drain_into(buf: &Mutex<Vec<u8>>, state: &mut TuiState) {
695    let bytes = std::mem::take(&mut *buf.lock().unwrap_or_else(|p| p.into_inner()));
696    if !bytes.is_empty() {
697        state.push_ansi(&String::from_utf8_lossy(&bytes));
698    }
699}
700
701// ============================================================================
702// Unit Tests
703// ============================================================================
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    use crate::cli::Cli;
710    use ansi_to_tui::IntoText;
711    use clap::Parser;
712    use scope::chains::mocks::MockClientFactory;
713
714    #[test]
715    fn test_tui_requires_a_terminal() {
716        assert!(tui_terminal_ok(true, true, Some("xterm-256color")).is_ok());
717        assert!(tui_terminal_ok(true, true, None).is_ok());
718        for (i, o, t) in [
719            (false, true, Some("xterm")),
720            (true, false, Some("xterm")),
721            (true, true, Some("dumb")),
722        ] {
723            let e = tui_terminal_ok(i, o, t).unwrap_err();
724            assert!(
725                e.starts_with("scope tui needs an interactive terminal"),
726                "{e}"
727            );
728        }
729    }
730
731    const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
732    const TX: &str = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
733
734    /// Plain text of ANSI output, as the pane shows it.
735    fn plain(ansi: &str) -> String {
736        ansi.into_text()
737            .unwrap()
738            .lines
739            .iter()
740            .map(|l| {
741                l.spans
742                    .iter()
743                    .map(|s| s.content.as_ref())
744                    .collect::<String>()
745            })
746            .collect::<Vec<_>>()
747            .join("\n")
748    }
749
750    /// Runs a line through the TUI path; returns the pane text.
751    async fn via_tui(line: &str, factory: &MockClientFactory) -> String {
752        let config = Config::default();
753        let mut s = TuiState::new(
754            SessionContext::default(),
755            Journal::default(),
756            vocab::Vocab::default(),
757        );
758        let Plan::Run(inv) = s.submit(line, &config) else {
759            panic!("`{}` did not plan a run", line)
760        };
761        assert_eq!(inv.route, Route::Pane);
762        let out = s.start(line, &inv);
763        let res = run_invocation(*inv, &config, factory, &out).await;
764        s.finish(Ending::Finished(res));
765        s.scrollback_text()
766    }
767
768    /// Runs the same line through the CLI path, with the CLI's own
769    /// (non-TUI) output; returns the data, then the diagnostics and the
770    /// error display, as the terminal would show them.
771    async fn via_cli(line: &str, factory: &MockClientFactory) -> String {
772        let mut argv = vec!["scope".to_string()];
773        argv.extend(shlex::split(line).unwrap());
774        let cli = Cli::try_parse_from(argv).unwrap();
775        let (o, cap) = Output::capture();
776        assert!(!o.is_tui());
777        let res =
778            crate::cli::dispatch::dispatch_with(cli.command, &Config::default(), factory, &o).await;
779        if let Err(e) = &res {
780            crate::cli::errors::display_error(e, &o).unwrap();
781        }
782        format!("{}\n{}", plain(&cap.out()), plain(&cap.err()))
783    }
784
785    /// Issue #47, requirement 1: a TUI command calls the same handler as the
786    /// CLI and shows the same result.
787    async fn assert_same_result(line: &str) {
788        let _lock = DIAG_LOCK.lock().await;
789        let factory = MockClientFactory::new();
790        let cli = via_cli(line, &factory).await;
791        let tui = via_tui(line, &factory).await;
792        assert!(!cli.trim().is_empty(), "`{}` printed nothing", line);
793        for row in cli.lines().filter(|r| !r.trim().is_empty()) {
794            assert!(
795                tui.contains(row),
796                "`{}`: the TUI pane is missing the CLI line {:?}\n--- TUI ---\n{}",
797                line,
798                row,
799                tui
800            );
801        }
802    }
803
804    #[tokio::test]
805    async fn test_address_same_as_cli() {
806        assert_same_result(&format!("address {}", ADDR)).await;
807    }
808
809    #[tokio::test]
810    async fn test_tx_same_as_cli() {
811        assert_same_result(&format!("tx {}", TX)).await;
812    }
813
814    #[tokio::test]
815    async fn test_market_summary_dex_same_as_cli() {
816        assert_same_result("market summary USDC --venue eth").await;
817    }
818
819    #[tokio::test]
820    async fn test_venues_list_json_same_as_cli() {
821        assert_same_result("venues list --format json").await;
822    }
823
824    #[tokio::test]
825    async fn test_failed_command_shows_same_error_as_cli() {
826        // Error text and remediation hints must match the CLI too.
827        assert_same_result("venues validate /nonexistent/venue.yaml").await;
828    }
829
830    // ---- Event loop, driven through a scripted host ----
831
832    use crossterm::event::{KeyCode, KeyEvent, KeyEventState, KeyModifiers};
833    use futures::channel::mpsc;
834    use futures::stream::BoxStream;
835    use ratatui::Terminal;
836    use ratatui::backend::TestBackend;
837    use std::task::Poll;
838
839    /// A host with a `TestBackend` screen, a scripted key stream and a
840    /// scripted signal stream.
841    struct TestHost {
842        terminal: Terminal<TestBackend>,
843        keys: BoxStream<'static, io::Result<Event>>,
844        signals: mpsc::UnboundedReceiver<Signal>,
845        signal_tx: mpsc::UnboundedSender<Signal>,
846        /// `suspend` sends these signals: they arrive while a
847        /// suspend-mode command runs.
848        suspend_signals: Vec<Signal>,
849        suspended: Output,
850        suspends: usize,
851        resumes: usize,
852        restores: usize,
853        stops: usize,
854        clears: usize,
855        /// Calls to `terminal()`: one per loop pass (the draw).
856        draws: usize,
857        /// `draws` when the last `stop_self` ran.
858        draws_at_stop: usize,
859        /// `stop_self` fails, as `kill` could.
860        stop_fails: bool,
861        /// Everything `write_raw` got.
862        raw: String,
863        /// `write_raw` fails.
864        raw_fails: bool,
865        /// `resume` fails, as for a terminal that went away.
866        resume_fails: bool,
867        /// The width override at each draw, suspend and restore, in order.
868        widths: Vec<(&'static str, Option<usize>)>,
869        /// The width the loop set last.
870        width: Option<usize>,
871    }
872
873    impl Host for TestHost {
874        type B = TestBackend;
875        type Events = BoxStream<'static, io::Result<Event>>;
876        type Signals = mpsc::UnboundedReceiver<Signal>;
877        fn terminal(&mut self) -> &mut Terminal<TestBackend> {
878            self.draws += 1;
879            self.widths.push(("draw", self.width));
880            &mut self.terminal
881        }
882        fn streams(&mut self) -> (&mut Self::Events, &mut Self::Signals) {
883            (&mut self.keys, &mut self.signals)
884        }
885        fn suspend(&mut self) {
886            self.suspends += 1;
887            self.widths.push(("suspend", self.width));
888            for s in self.suspend_signals.drain(..) {
889                self.signal_tx.unbounded_send(s).unwrap();
890            }
891        }
892        fn resume(&mut self) -> Result<()> {
893            self.resumes += 1;
894            if self.resume_fails {
895                return Err(crate::cli::screen::not_taken(io::Error::other("no tty")));
896            }
897            Ok(())
898        }
899        fn restore(&mut self) {
900            self.restores += 1;
901            self.widths.push(("restore", self.width));
902        }
903        /// Does not stop the test process. It sends SIGCONT, as the kernel
904        /// does when the shell continues a stopped process.
905        fn stop_self(&mut self) -> io::Result<()> {
906            self.stops += 1;
907            self.draws_at_stop = self.draws;
908            if self.stop_fails {
909                return Err(io::Error::other("kill failed"));
910            }
911            self.signal_tx.unbounded_send(Signal::Cont).unwrap();
912            Ok(())
913        }
914        fn clear(&mut self) -> io::Result<()> {
915            self.clears += 1;
916            self.terminal.clear().map_err(|never| match never {})
917        }
918        fn suspended_output(&self) -> Output {
919            self.suspended.clone()
920        }
921        fn write_raw(&mut self, text: &str) -> io::Result<()> {
922            self.raw.push_str(text);
923            if self.raw_fails {
924                return Err(io::Error::other("tty gone"));
925            }
926            Ok(())
927        }
928        /// Records the width; tests never set the process-wide override.
929        fn set_width(&mut self, width: Option<usize>) {
930            self.width = width;
931        }
932    }
933
934    /// What a driven session leaves: its result, state, host and the
935    /// suspend-mode output.
936    type Driven = (
937        Result<TuiExit>,
938        TuiState,
939        TestHost,
940        crate::cli::output::Captured,
941    );
942
943    fn press(code: KeyCode) -> io::Result<Event> {
944        Ok(Event::Key(KeyEvent::new(code, KeyModifiers::NONE)))
945    }
946
947    /// Key events that type each line and press Enter. `<esc>` presses Esc.
948    fn script(lines: &[&str]) -> Vec<io::Result<Event>> {
949        let mut events = Vec::new();
950        for line in lines {
951            if *line == "<esc>" {
952                events.push(press(KeyCode::Esc));
953                continue;
954            }
955            events.extend(line.chars().map(|c| press(KeyCode::Char(c))));
956            events.push(press(KeyCode::Enter));
957        }
958        events
959    }
960
961    /// Runs the event loop over the scripted events. The stream ends after
962    /// them, which ends the loop.
963    async fn drive(events: Vec<io::Result<Event>>) -> Driven {
964        drive_with(Journal::default(), events, Vec::new()).await
965    }
966
967    /// Runs a whole session (loop, restore, save) with `journal`. The
968    /// signals arrive after the last key; then the key stream ends. The
969    /// session context is not saved, because its real path is the user's.
970    async fn drive_with(
971        journal: Journal,
972        events: Vec<io::Result<Event>>,
973        signals: Vec<Signal>,
974    ) -> Driven {
975        let (host, captured) = test_host(events, signals);
976        drive_host(journal, host, captured).await
977    }
978
979    /// A test host over the scripted keys, with `signals` after the last key.
980    fn test_host(
981        events: Vec<io::Result<Event>>,
982        signals: Vec<Signal>,
983    ) -> (TestHost, crate::cli::output::Captured) {
984        let (sig_tx, sig_rx) = mpsc::unbounded();
985        let mut then = Some((signals, sig_tx.clone()));
986        // After the last key, send the signals and stay pending. The send
987        // wakes the loop, and its biased select takes the signals before
988        // it polls the key stream again and sees the end.
989        let tail = futures::stream::poll_fn(move |_| match then.take() {
990            Some((sigs, tx)) if !sigs.is_empty() => {
991                for s in sigs {
992                    tx.unbounded_send(s).unwrap();
993                }
994                Poll::Pending
995            }
996            _ => Poll::Ready(None),
997        });
998        let (suspended, captured) = Output::capture_merged();
999        let host = TestHost {
1000            terminal: Terminal::new(TestBackend::new(100, 30)).unwrap(),
1001            keys: futures::stream::iter(events).chain(tail).boxed(),
1002            signals: sig_rx,
1003            signal_tx: sig_tx,
1004            suspend_signals: Vec::new(),
1005            suspended,
1006            suspends: 0,
1007            resumes: 0,
1008            restores: 0,
1009            stops: 0,
1010            clears: 0,
1011            draws: 0,
1012            draws_at_stop: 0,
1013            stop_fails: false,
1014            raw: String::new(),
1015            raw_fails: false,
1016            resume_fails: false,
1017            widths: Vec::new(),
1018            width: None,
1019        };
1020        (host, captured)
1021    }
1022
1023    /// Runs a whole session on `host` with `journal`.
1024    async fn drive_host(
1025        journal: Journal,
1026        host: TestHost,
1027        captured: crate::cli::output::Captured,
1028    ) -> Driven {
1029        drive_host_on(journal, host, captured, &MockClientFactory::new()).await
1030    }
1031
1032    /// [`drive_host`] with the client factory `clients`.
1033    async fn drive_host_on(
1034        journal: Journal,
1035        mut host: TestHost,
1036        captured: crate::cli::output::Captured,
1037        clients: &dyn ChainClientFactory,
1038    ) -> Driven {
1039        let mut state = TuiState::new(SessionContext::default(), journal, vocab::Vocab::default());
1040        welcome(&mut state, true);
1041        let res = session(&mut host, &mut state, &Config::default(), clients, |_| {
1042            Ok(())
1043        })
1044        .await;
1045        (res, state, host, captured)
1046    }
1047
1048    /// A DEX source whose every lookup waits forever: a command that uses
1049    /// it stays running until it is cancelled, with no network (#39).
1050    struct PendingDex;
1051
1052    #[async_trait::async_trait]
1053    impl scope::chains::DexDataSource for PendingDex {
1054        async fn get_token_price(&self, _: &str, _: &str) -> Option<f64> {
1055            std::future::pending().await
1056        }
1057        async fn get_native_token_price(&self, _: &str) -> Option<f64> {
1058            std::future::pending().await
1059        }
1060        async fn get_token_data(
1061            &self,
1062            _: &str,
1063            _: &str,
1064        ) -> scope::Result<scope::chains::dex::DexTokenData> {
1065            std::future::pending().await
1066        }
1067        async fn search_tokens(
1068            &self,
1069            _: &str,
1070            _: Option<&str>,
1071        ) -> scope::Result<Vec<scope::chains::dex::TokenSearchResult>> {
1072            std::future::pending().await
1073        }
1074    }
1075
1076    /// The mock factory with a [`PendingDex`].
1077    struct PendingFactory(MockClientFactory);
1078
1079    impl ChainClientFactory for PendingFactory {
1080        fn create_chain_client(
1081            &self,
1082            chain: &str,
1083        ) -> scope::Result<Box<dyn scope::chains::ChainClient>> {
1084            self.0.create_chain_client(chain)
1085        }
1086        fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
1087            Box::new(PendingDex)
1088        }
1089    }
1090
1091    /// The command the cancel tests start: a repeat run whose first
1092    /// lookup never finishes ([`PendingDex`]).
1093    const PENDING_REPEAT: &str = "market summary USDC --venue eth --every 30s --duration 1h";
1094
1095    /// Types `lines`, then delivers `signals`, with the DEX lookups pending.
1096    async fn drive_pending(events: Vec<io::Result<Event>>, signals: Vec<Signal>) -> Driven {
1097        let (host, captured) = test_host(events, signals);
1098        let clients = PendingFactory(MockClientFactory::new());
1099        drive_host_on(Journal::default(), host, captured, &clients).await
1100    }
1101
1102    /// Types `lines`, then delivers `signal`.
1103    async fn drive_signal_after(lines: &[&str], signal: Signal) -> Driven {
1104        drive_with(Journal::default(), script(lines), vec![signal]).await
1105    }
1106
1107    #[tokio::test]
1108    async fn test_paste_event_fills_the_input_and_does_not_run_it() {
1109        // A paste that ends in a newline must not submit the line.
1110        let _lock = DIAG_LOCK.lock().await;
1111        let (res, state, _, _) = drive(vec![Ok(Event::Paste("ctx\nhelp\n".into()))]).await;
1112        assert_eq!(res.unwrap().code, 0);
1113        assert_eq!(state.input, "ctx help");
1114        assert!(state.history.is_empty(), "nothing was submitted");
1115    }
1116
1117    #[tokio::test]
1118    async fn test_term_signal_saves_restores_and_journals() {
1119        // A killed TUI must not leave a raw terminal or lose the history.
1120        let _lock = DIAG_LOCK.lock().await;
1121        let dir = tempfile::tempdir().unwrap();
1122        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1123        let (res, _, host, _) = drive_with(journal, script(&["ctx"]), vec![Signal::Term]).await;
1124        assert_eq!(res.unwrap().code, 143);
1125        assert_eq!(host.restores, 1, "terminal restored once");
1126        let ev = std::fs::read_to_string(dir.path().join("e.jsonl")).unwrap();
1127        assert!(ev.contains("\"outcome\":\"signal:TERM\""), "{ev}");
1128        let history = std::fs::read_to_string(dir.path().join("h")).unwrap();
1129        assert!(history.contains("ctx"), "{history}");
1130    }
1131
1132    #[tokio::test]
1133    async fn test_term_signal_cancels_running_pane_command() {
1134        // A repeat command sleeps between runs; SIGTERM must end it at
1135        // once and say so, not wait for the command.
1136        let _lock = DIAG_LOCK.lock().await;
1137        let (res, state, _, _) = drive_pending(script(&[PENDING_REPEAT]), vec![Signal::Term]).await;
1138        assert_eq!(res.unwrap().code, 143);
1139        assert!(state.scrollback_text().contains("Cancelled"));
1140        assert!(state.running.is_none());
1141    }
1142
1143    #[tokio::test]
1144    async fn test_each_fatal_signal_exits_with_128_plus_its_number() {
1145        // Shells and supervisors read 128+N as "ended by signal N".
1146        let _lock = DIAG_LOCK.lock().await;
1147        for (sig, code) in [
1148            (Signal::Hup, 129),
1149            (Signal::Int, 130),
1150            (Signal::Quit, 131),
1151            (Signal::Term, 143),
1152        ] {
1153            let (res, _, host, _) = drive_signal_after(&[], sig).await;
1154            assert_eq!(res.unwrap().code, code, "{sig:?}");
1155            assert_eq!(host.restores, 1, "{sig:?}");
1156        }
1157    }
1158
1159    #[tokio::test]
1160    async fn test_stop_and_continue_signals_do_not_end_the_tui() {
1161        // SIGTSTP and SIGCONT must not end the session: the loop ends only
1162        // when the keys end, with code 0.
1163        let _lock = DIAG_LOCK.lock().await;
1164        for sig in [Signal::Tstp, Signal::Cont] {
1165            let (res, _, host, _) = drive_signal_after(&[], sig).await;
1166            assert_eq!(res.unwrap().code, 0, "{sig:?}");
1167            assert_eq!(host.restores, 1, "{sig:?}");
1168        }
1169    }
1170
1171    fn ctrl_z() -> io::Result<Event> {
1172        Ok(Event::Key(KeyEvent::new(
1173            KeyCode::Char('z'),
1174            KeyModifiers::CONTROL,
1175        )))
1176    }
1177
1178    /// The text on the test screen.
1179    fn screen_text(host: &TestHost) -> String {
1180        let buf = host.terminal.backend().buffer().clone();
1181        buf.content().iter().map(|c| c.symbol()).collect()
1182    }
1183
1184    /// The width override at each `stage` (draw, suspend, restore).
1185    fn widths_at(host: &TestHost, stage: &str) -> Vec<Option<usize>> {
1186        host.widths
1187            .iter()
1188            .filter(|(s, _)| *s == stage)
1189            .map(|(_, w)| *w)
1190            .collect()
1191    }
1192
1193    #[tokio::test]
1194    async fn test_width_override_is_the_pane_and_is_removed_when_the_tui_leaves() {
1195        // Output fits the pane while the TUI owns the screen. A suspend-mode
1196        // command, the shell after Ctrl-Z and the CLI after exit use the
1197        // real width (review focus 5).
1198        let _lock = DIAG_LOCK.lock().await;
1199        let mut events = script(&["setup --key nosuchkey"]);
1200        events.push(ctrl_z());
1201        events.push(press(KeyCode::Char('x')));
1202        let (res, _, host, _) = drive(events).await;
1203        res.unwrap();
1204        // Suspend-mode command, then Ctrl-Z: the override is off both times.
1205        assert_eq!(widths_at(&host, "suspend"), vec![None, None]);
1206        assert_eq!(widths_at(&host, "restore"), vec![None]);
1207        // The pane of the 100-column test screen has no side borders.
1208        let draws = widths_at(&host, "draw");
1209        assert_eq!(draws.last(), Some(&Some(100)), "set again after Ctrl-Z");
1210    }
1211
1212    #[tokio::test]
1213    async fn test_setup_in_the_tui_says_to_restart_and_other_commands_do_not() {
1214        // The session's clients keep the config read at start: after
1215        // `setup`, the user must restart to use a new key (a stale key
1216        // gave "Invalid API Key" right after a successful setup).
1217        let _lock = DIAG_LOCK.lock().await;
1218        let (res, state, _, _) = drive(script(&["setup --key nosuchkey"])).await;
1219        res.unwrap();
1220        assert!(state.scrollback_text().contains(SETUP_RESTART_NOTE));
1221
1222        let (res, state, _, _) = drive(script(&["crawl USDC"])).await;
1223        res.unwrap();
1224        assert!(!state.scrollback_text().contains(SETUP_RESTART_NOTE));
1225    }
1226
1227    #[tokio::test]
1228    async fn test_ctrl_z_stops_then_redraws_with_the_draft() {
1229        // Ctrl-Z must give the shell a clean terminal, stop the process,
1230        // and on `fg` take the screen back, clear what the shell wrote,
1231        // and redraw with the draft. The SIGCONT that `fg` sends must not
1232        // take the screen a second time.
1233        let _lock = DIAG_LOCK.lock().await;
1234        let mut events: Vec<_> = "draf".chars().map(|c| press(KeyCode::Char(c))).collect();
1235        events.push(ctrl_z());
1236        let (res, state, host, _) = drive(events).await;
1237        assert_eq!(res.unwrap().code, 0, "Ctrl-Z does not end the TUI");
1238        assert_eq!(host.stops, 1);
1239        assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
1240        assert_eq!(state.input, "draf");
1241        assert!(screen_text(&host).contains("draf"));
1242    }
1243
1244    #[tokio::test]
1245    async fn test_tstp_signal_stops_like_ctrl_z() {
1246        // `kill -TSTP` must stop the TUI the same way; its handler replaced
1247        // the default stop, so without this the signal does nothing.
1248        let _lock = DIAG_LOCK.lock().await;
1249        let (res, _, host, _) = drive_signal_after(&[], Signal::Tstp).await;
1250        assert_eq!(res.unwrap().code, 0);
1251        assert_eq!(host.stops, 1);
1252        assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
1253    }
1254
1255    #[tokio::test]
1256    async fn test_cont_without_our_stop_retakes_and_clears_the_screen() {
1257        // After an outside SIGSTOP, the shell reset the terminal and wrote
1258        // on it. SIGCONT must set raw mode again and repaint everything.
1259        let _lock = DIAG_LOCK.lock().await;
1260        let (res, _, host, _) = drive_signal_after(&[], Signal::Cont).await;
1261        assert_eq!(res.unwrap().code, 0);
1262        assert_eq!(host.stops, 0, "SIGCONT does not stop the process");
1263        assert_eq!((host.suspends, host.resumes, host.clears), (1, 1, 1));
1264    }
1265
1266    #[tokio::test]
1267    async fn test_ctrl_z_keeps_a_running_pane_command() {
1268        // Review Focus 2: a command running in the pane survives Ctrl-Z,
1269        // and after `fg` the screen shows its running footer again.
1270        let _lock = DIAG_LOCK.lock().await;
1271        let mut events = script(&[PENDING_REPEAT]);
1272        events.push(ctrl_z());
1273        let (res, state, host, _) = drive_pending(events, Vec::new()).await;
1274        assert_eq!(res.unwrap().code, 0);
1275        assert_eq!(host.stops, 1);
1276        assert!(state.running.is_some(), "the command still runs");
1277        assert!(!state.scrollback_text().contains("Cancelled"));
1278        let screen = screen_text(&host);
1279        assert!(screen.contains("Esc cancels"), "{screen}");
1280    }
1281
1282    #[tokio::test(start_paused = true)]
1283    async fn test_a_long_stop_does_not_replay_the_missed_redraw_ticks() {
1284        // While the process is stopped the clock runs on, but nothing polls
1285        // the 100 ms redraw tick. On `fg` the missed ticks must not fire
1286        // one by one: the tick arm comes first in the biased select, so
1287        // they would hold back SIGCONT and the keys (10 per second stopped).
1288        const STOPPED: Duration = Duration::from_secs(120);
1289        const MAX_DRAWS_AFTER_STOP: usize = 10;
1290        let _lock = DIAG_LOCK.lock().await;
1291        let (mut host, _) = test_host(Vec::new(), Vec::new());
1292        // The clock jumps after the stop, before the next key is read.
1293        let after_stop = futures::stream::once(async {
1294            tokio::time::advance(STOPPED).await;
1295            press(KeyCode::Char('x'))
1296        });
1297        host.keys = futures::stream::iter(vec![ctrl_z()])
1298            .chain(after_stop)
1299            .boxed();
1300        // A command shown as running turns the tick on. A real command
1301        // would need the network, and the paused clock fires its timeouts
1302        // at once, so the test marks one as running without a future.
1303        let mut state = TuiState::new(
1304            SessionContext::default(),
1305            Journal::default(),
1306            vocab::Vocab::default(),
1307        );
1308        let config = Config::default();
1309        let Plan::Run(inv) = state.submit("venues list", &config) else {
1310            panic!("`venues list` did not plan a run")
1311        };
1312        let _out = state.start("venues list", &inv);
1313        let factory = MockClientFactory::new();
1314        let res = session(&mut host, &mut state, &config, &factory, |_| Ok(())).await;
1315        assert_eq!(res.unwrap().code, 0);
1316        assert!(state.running.is_some(), "the tick stayed on");
1317        assert_eq!(host.stops, 1);
1318        assert_eq!(state.input, "x", "the key after the stop is handled");
1319        let draws = host.draws - host.draws_at_stop;
1320        assert!(
1321            draws <= MAX_DRAWS_AFTER_STOP,
1322            "{draws} draws between the stop and the end"
1323        );
1324    }
1325
1326    #[tokio::test]
1327    async fn test_a_failed_stop_says_so_and_keeps_the_tui() {
1328        // If kill fails, the TUI must say so and take the screen back. No
1329        // SIGCONT follows, so a later outside SIGCONT must still repaint.
1330        let _lock = DIAG_LOCK.lock().await;
1331        let (mut host, captured) = test_host(vec![ctrl_z()], vec![Signal::Cont]);
1332        host.stop_fails = true;
1333        let (res, state, host, _) = drive_host(Journal::default(), host, captured).await;
1334        assert_eq!(res.unwrap().code, 0);
1335        assert!(
1336            state
1337                .scrollback_text()
1338                .contains("Could not stop the process: kill failed")
1339        );
1340        assert_eq!(host.stops, 1);
1341        // One set for the failed stop, one for the outside SIGCONT.
1342        assert_eq!((host.suspends, host.resumes, host.clears), (2, 2, 2));
1343    }
1344
1345    #[tokio::test]
1346    async fn test_a_terminal_that_cannot_be_taken_back_ends_the_session_with_the_terminal_error() {
1347        // After Ctrl-Z and `fg`, the terminal may be gone. The TUI must
1348        // end with the same error the monitor shows, not panic, and still
1349        // restore and save.
1350        let _lock = DIAG_LOCK.lock().await;
1351        let (mut host, captured) = test_host(vec![ctrl_z()], vec![]);
1352        host.resume_fails = true;
1353        let (res, _, host, _) = drive_host(Journal::default(), host, captured).await;
1354        assert_eq!(
1355            res.unwrap_err().to_string(),
1356            "I/O error: cannot take the terminal: no tty"
1357        );
1358        assert_eq!(host.resumes, 1);
1359        assert_eq!(
1360            host.restores, 1,
1361            "the session still gives the terminal back"
1362        );
1363    }
1364
1365    /// Awaits `fut`, failing the test after 10 s: a swallowed signal must
1366    /// fail the test, not hang it.
1367    async fn within_limit<T>(fut: impl Future<Output = T>) -> T {
1368        tokio::time::timeout(Duration::from_secs(10), fut)
1369            .await
1370            .expect("the signal did not end the wait")
1371    }
1372
1373    /// The journal records in `e.jsonl` under `dir`, parsed.
1374    fn records(dir: &tempfile::TempDir) -> Vec<serde_json::Value> {
1375        std::fs::read_to_string(dir.path().join("e.jsonl"))
1376            .unwrap()
1377            .lines()
1378            .map(|l| serde_json::from_str(l).unwrap())
1379            .collect()
1380    }
1381
1382    #[tokio::test]
1383    async fn test_term_signal_during_a_suspended_command_ends_it() {
1384        // A suspend-mode command can run for hours (the monitor). On
1385        // develop SIGTERM killed it; the handler must not swallow it.
1386        let _lock = DIAG_LOCK.lock().await;
1387        let dir = tempfile::tempdir().unwrap();
1388        let mut state = TuiState::new(
1389            SessionContext::default(),
1390            Journal::at(dir.path().join("h"), dir.path().join("e.jsonl")),
1391            vocab::Vocab::default(),
1392        );
1393        let (mut host, _) = test_host(Vec::new(), Vec::new());
1394        host.signal_tx.unbounded_send(Signal::Term).unwrap();
1395        let outside = Outside::new(host.suspended_output());
1396        let argv = vec!["monitor".to_string()];
1397        let never = futures::future::pending::<Result<()>>();
1398        let run = run_suspended(&mut host, "monitor", &argv, &outside, never, &mut state);
1399        let exit = within_limit(run).await;
1400        assert_eq!(exit, Some(TuiExit { code: 143 }));
1401        let rec = records(&dir);
1402        assert_eq!(rec.len(), 1, "{rec:?}");
1403        assert_eq!(rec[0]["outcome"], "signal:TERM");
1404        assert_eq!(rec[0]["route"], "suspend");
1405        assert_eq!(rec[0]["input"], "monitor");
1406    }
1407
1408    /// A state over a journal in `dir`.
1409    fn journaled_state(dir: &tempfile::TempDir) -> TuiState {
1410        TuiState::new(
1411            SessionContext::default(),
1412            Journal::at(dir.path().join("h"), dir.path().join("e.jsonl")),
1413            vocab::Vocab::default(),
1414        )
1415    }
1416
1417    /// The scrollback note `run_suspended` leaves for `line`, checked
1418    /// against the spec format with the outcome word `outcome`.
1419    fn assert_ran_note(state: &TuiState, line: &str, outcome: &str) {
1420        let text = state.scrollback_text();
1421        let note = text
1422            .lines()
1423            .find_map(|l| {
1424                l.strip_prefix("ℹ ")
1425                    .filter(|n| n.starts_with("Ran outside the TUI:"))
1426            })
1427            .unwrap_or_else(|| panic!("no note in {text}"));
1428        let pattern = format!(
1429            r"^Ran outside the TUI: {} \({}, \d+\.\ds\)\.$",
1430            regex::escape(line),
1431            outcome
1432        );
1433        assert!(
1434            regex::Regex::new(&pattern).unwrap().is_match(note),
1435            "{note:?} does not match {pattern}"
1436        );
1437    }
1438
1439    #[tokio::test]
1440    async fn test_suspended_command_is_cancelled_by_sigint() {
1441        // Ctrl-C outside the TUI must stop a command that never ends (a
1442        // hung crawl) and return to the TUI, not kill the process.
1443        let _lock = DIAG_LOCK.lock().await;
1444        let dir = tempfile::tempdir().unwrap();
1445        let mut state = journaled_state(&dir);
1446        let (mut host, captured) = test_host(Vec::new(), Vec::new());
1447        host.signal_tx.unbounded_send(Signal::Int).unwrap();
1448        let outside = Outside::new(host.suspended_output());
1449        let argv = vec!["crawl".to_string(), "USDC".to_string()];
1450        let never = futures::future::pending::<Result<()>>();
1451        let run = run_suspended(&mut host, "crawl USDC", &argv, &outside, never, &mut state);
1452        assert_eq!(within_limit(run).await, None, "the session goes on");
1453        assert_ran_note(&state, "crawl USDC", "cancelled");
1454        let rec = records(&dir);
1455        assert_eq!(rec.len(), 1, "{rec:?}");
1456        assert_eq!(rec[0]["outcome"], "cancelled");
1457        assert_eq!(rec[0]["route"], "suspend");
1458        assert!(captured.out().contains(
1459            "Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
1460        ));
1461    }
1462
1463    #[tokio::test]
1464    async fn test_sigint_cancel_returns_to_a_working_loop() {
1465        // After the cancel the TUI takes the screen back and runs the next
1466        // line.
1467        let _lock = DIAG_LOCK.lock().await;
1468        let dir = tempfile::tempdir().unwrap();
1469        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1470        let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
1471        host.suspend_signals = vec![Signal::Int];
1472        let (res, state, host, _) = within_limit(drive_host(journal, host, captured)).await;
1473        assert_eq!(res.unwrap().code, 0);
1474        assert_eq!((host.suspends, host.resumes), (1, 1));
1475        assert_ran_note(&state, "setup --key nosuchkey", "cancelled");
1476        assert!(state.scrollback_text().contains("Current Context"));
1477        let rec = records(&dir);
1478        assert_eq!(rec[0]["outcome"], "cancelled", "{rec:?}");
1479        assert_eq!(rec[1]["input"], "ctx", "{rec:?}");
1480    }
1481
1482    /// Runs a suspend-mode `setup` whose prompt a signal stops: the
1483    /// command sets the cancel flag and sends `sig`, as the prompt watcher
1484    /// and the signal stream see one signal, and returns `result` (a
1485    /// prompt error, or Ok when the command swallowed it). `None` sends no
1486    /// signal. Returns the exit, the state, the host and the journal dir.
1487    ///
1488    /// `prompt::CANCEL` is process-global, so every test that touches it
1489    /// or calls `run_suspended` must hold `DIAG_LOCK`. This helper resets
1490    /// the flag before it returns.
1491    async fn prompt_stopped_by(
1492        sig: Option<Signal>,
1493        result: Result<()>,
1494    ) -> (Option<TuiExit>, TuiState, TestHost, tempfile::TempDir) {
1495        let dir = tempfile::tempdir().unwrap();
1496        let mut state = journaled_state(&dir);
1497        let (mut host, _) = test_host(Vec::new(), Vec::new());
1498        let outside = Outside::new(host.suspended_output());
1499        let tx = host.signal_tx.clone();
1500        let argv = vec!["setup".to_string()];
1501        // The prompt blocks the task, so the select polled the signals
1502        // before the signal came.
1503        let prompt_stopped = async move {
1504            crate::cli::prompt::CANCEL.store(true, Ordering::SeqCst);
1505            if let Some(sig) = sig {
1506                tx.unbounded_send(sig).unwrap();
1507            }
1508            result
1509        };
1510        let run = run_suspended(
1511            &mut host,
1512            "setup",
1513            &argv,
1514            &outside,
1515            prompt_stopped,
1516            &mut state,
1517        );
1518        let exit = within_limit(run).await;
1519        crate::cli::prompt::CANCEL.store(false, Ordering::SeqCst);
1520        (exit, state, host, dir)
1521    }
1522
1523    #[tokio::test]
1524    async fn test_a_cancelled_prompt_is_recorded_as_cancelled_and_its_int_used_up() {
1525        // Ctrl-C at a prompt: the cancel must read as a cancel, not a
1526        // failure, and its INT must not reach the TUI later and end the
1527        // session with 130.
1528        let _lock = DIAG_LOCK.lock().await;
1529        for result in [
1530            Err(ScopeError::IoError(io::Error::other("cancelled"))),
1531            Ok(()),
1532        ] {
1533            let (exit, state, mut host, dir) = prompt_stopped_by(Some(Signal::Int), result).await;
1534            assert_eq!(exit, None);
1535            assert_ran_note(&state, "setup", "cancelled");
1536            assert_eq!(records(&dir)[0]["outcome"], "cancelled");
1537            assert!(host.signals.try_next().is_err(), "the INT is used up");
1538        }
1539    }
1540
1541    #[tokio::test]
1542    async fn test_term_at_a_prompt_ends_the_session_from_suspend_mode() {
1543        let _lock = DIAG_LOCK.lock().await;
1544        let failed = Err(ScopeError::IoError(io::Error::other("cancelled")));
1545        let (exit, _, _, dir) = prompt_stopped_by(Some(Signal::Term), failed).await;
1546        assert_eq!(exit, Some(TuiExit { code: 143 }));
1547        let rec = records(&dir);
1548        assert_eq!(rec.len(), 1, "{rec:?}");
1549        assert_eq!(rec[0]["outcome"], "signal:TERM");
1550        assert_eq!(rec[0]["route"], "suspend");
1551    }
1552
1553    #[tokio::test]
1554    async fn test_a_suspended_command_starts_with_the_cancel_flag_clear() {
1555        // A Ctrl-C from an earlier command must not cancel the next one's
1556        // first prompt.
1557        let _lock = DIAG_LOCK.lock().await;
1558        let dir = tempfile::tempdir().unwrap();
1559        let mut state = journaled_state(&dir);
1560        let (mut host, _) = test_host(Vec::new(), Vec::new());
1561        let outside = Outside::new(host.suspended_output());
1562        crate::cli::prompt::CANCEL.store(true, Ordering::SeqCst);
1563        let saw_clear = async {
1564            assert!(!crate::cli::prompt::CANCEL.load(Ordering::SeqCst));
1565            Ok(())
1566        };
1567        let run = run_suspended(&mut host, "setup", &[], &outside, saw_clear, &mut state);
1568        assert_eq!(within_limit(run).await, None);
1569        assert_ran_note(&state, "setup", "ok");
1570        crate::cli::prompt::CANCEL.store(false, Ordering::SeqCst);
1571    }
1572
1573    /// The scrollback lines from `first` up to the `Ran outside the TUI`
1574    /// note; panics when either is missing or out of order.
1575    fn lines_before_note(state: &TuiState, first: &str) -> Vec<String> {
1576        let text = state.scrollback_text();
1577        let lines: Vec<String> = text.lines().map(str::to_string).collect();
1578        let start = lines
1579            .iter()
1580            .position(|l| l.contains(first))
1581            .unwrap_or_else(|| panic!("{first:?} is not in the scrollback:\n{text}"));
1582        let note = lines
1583            .iter()
1584            .position(|l| l.contains("Ran outside the TUI:"))
1585            .unwrap_or_else(|| panic!("no note:\n{text}"));
1586        assert!(start < note, "the output comes before the note:\n{text}");
1587        lines[start..note].to_vec()
1588    }
1589
1590    #[tokio::test]
1591    async fn test_a_suspended_report_shows_in_the_scrollback_and_is_the_copy() {
1592        // The report of a command that ran outside the TUI must not be
1593        // hidden until the TUI exits; Ctrl-Y and save must have its data.
1594        let _lock = DIAG_LOCK.lock().await;
1595        let dir = tempfile::tempdir().unwrap();
1596        let mut state = journaled_state(&dir);
1597        let (mut host, captured) = test_host(Vec::new(), Vec::new());
1598        let outside = Outside::new(host.suspended_output());
1599        let out = &outside.out;
1600        let report = async {
1601            crate::outln!(out, "\x1b[1mToken Report\x1b[0m: USDC")?;
1602            crate::errln!(out, "warning: partial data")?;
1603            crate::outln!(out, "holders 42")?;
1604            Ok(())
1605        };
1606        let run = run_suspended(&mut host, "crawl USDC", &[], &outside, report, &mut state);
1607        assert_eq!(within_limit(run).await, None);
1608        let shown = lines_before_note(&state, "Token Report: USDC");
1609        assert_eq!(
1610            shown,
1611            ["Token Report: USDC", "warning: partial data", "holders 42"]
1612        );
1613        // The user saw it live on the terminal too.
1614        assert!(captured.out().contains("holders 42"));
1615        // Ctrl-Y sends the data only, without the warning or ANSI codes.
1616        let Action::Copy(copy) =
1617            state.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL))
1618        else {
1619            panic!("Ctrl-Y has nothing to copy")
1620        };
1621        assert_eq!(copy, "Token Report: USDC\nholders 42\n");
1622    }
1623
1624    #[tokio::test]
1625    async fn test_a_failed_suspended_command_shows_its_output_and_error() {
1626        // A failure outside the TUI must explain itself in the TUI, and must
1627        // not replace the copy of an earlier success.
1628        let _lock = DIAG_LOCK.lock().await;
1629        let dir = tempfile::tempdir().unwrap();
1630        let mut state = journaled_state(&dir);
1631        let (mut host, _) = test_host(Vec::new(), Vec::new());
1632        let outside = Outside::new(host.suspended_output());
1633        let out = &outside.out;
1634        let failed = async {
1635            crate::outln!(out, "searching USDC")?;
1636            Err(ScopeError::Other("crawl blew up".into()))
1637        };
1638        let run = run_suspended(&mut host, "crawl USDC", &[], &outside, failed, &mut state);
1639        assert_eq!(within_limit(run).await, None);
1640        assert_ran_note(&state, "crawl USDC", "failed");
1641        let shown = lines_before_note(&state, "searching USDC").join("\n");
1642        let error = shown.find("✗ crawl blew up").expect(&shown);
1643        assert!(shown.find("searching USDC").unwrap() < error, "{shown}");
1644        let copy = state.handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
1645        assert!(matches!(copy, Action::None), "no copy after a failure");
1646    }
1647
1648    #[tokio::test(start_paused = true)]
1649    async fn test_a_set_flag_without_a_signal_does_not_hang_the_tui() {
1650        // The flag means a signal is on the stream. If that ever breaks,
1651        // the TUI must not wait forever on a cooked terminal.
1652        let _lock = DIAG_LOCK.lock().await;
1653        let started = tokio::time::Instant::now();
1654        let failed = Err(ScopeError::IoError(io::Error::other("cancelled")));
1655        let (exit, state, _, dir) = prompt_stopped_by(None, failed).await;
1656        assert_eq!(exit, None);
1657        assert!(started.elapsed() <= PROMPT_SIGNAL_WAIT + Duration::from_millis(100));
1658        assert_ran_note(&state, "setup", "cancelled");
1659        assert_eq!(records(&dir)[0]["outcome"], "cancelled");
1660    }
1661
1662    #[tokio::test]
1663    async fn test_a_second_ctrl_c_does_not_end_the_session() {
1664        // A user often presses Ctrl-C twice. The second INT must not
1665        // reach the TUI and end it with 130.
1666        let _lock = DIAG_LOCK.lock().await;
1667        let dir = tempfile::tempdir().unwrap();
1668        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1669        let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
1670        host.suspend_signals = vec![Signal::Int, Signal::Int];
1671        let (res, state, host, _) = within_limit(drive_host(journal, host, captured)).await;
1672        assert_eq!(res.unwrap().code, 0);
1673        assert_eq!((host.suspends, host.resumes), (1, 1));
1674        assert_ran_note(&state, "setup --key nosuchkey", "cancelled");
1675        assert!(state.scrollback_text().contains("Current Context"));
1676        let rec = records(&dir);
1677        assert_eq!(rec.len(), 2, "{rec:?}");
1678        assert_eq!(rec[1]["input"], "ctx", "{rec:?}");
1679    }
1680
1681    #[tokio::test]
1682    async fn test_a_term_queued_after_a_cancel_still_ends_the_session() {
1683        // Draining extra INTs must not drop a TERM behind them.
1684        let _lock = DIAG_LOCK.lock().await;
1685        let dir = tempfile::tempdir().unwrap();
1686        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1687        let (mut host, captured) = test_host(script(&["setup --key nosuchkey", "ctx"]), Vec::new());
1688        host.suspend_signals = vec![Signal::Int, Signal::Int, Signal::Term];
1689        let (res, _, host, _) = within_limit(drive_host(journal, host, captured)).await;
1690        assert_eq!(res.unwrap().code, 143);
1691        assert_eq!(host.resumes, 0);
1692        let rec = records(&dir);
1693        assert_eq!(rec[0]["outcome"], "cancelled", "{rec:?}");
1694        assert_eq!(rec[1]["outcome"], "signal:TERM", "{rec:?}");
1695        assert_eq!(rec[1]["route"], "suspend", "{rec:?}");
1696    }
1697
1698    #[tokio::test]
1699    async fn test_ending_signals_set_the_prompt_cancel_flag() {
1700        // A prompt blocks the task that watches the signals, so a separate
1701        // watcher must set the flag. TSTP and CONT do not cancel.
1702        for (sigs, set) in [
1703            (vec![Signal::Tstp, Signal::Cont], false),
1704            (vec![Signal::Int], true),
1705            (vec![Signal::Term], true),
1706            (vec![Signal::Hup], true),
1707            (vec![Signal::Quit], true),
1708        ] {
1709            let flag = AtomicBool::new(false);
1710            cancel_prompts_on(futures::stream::iter(sigs.clone()), &flag).await;
1711            assert_eq!(flag.load(Ordering::SeqCst), set, "{sigs:?}");
1712        }
1713    }
1714
1715    /// A writer whose terminal is gone.
1716    struct BrokenTty;
1717
1718    impl io::Write for BrokenTty {
1719        fn write(&mut self, _: &[u8]) -> io::Result<usize> {
1720            Err(io::Error::other("tty gone"))
1721        }
1722        fn flush(&mut self) -> io::Result<()> {
1723            Ok(())
1724        }
1725    }
1726
1727    #[tokio::test]
1728    async fn test_terminal_errors_outside_the_tui_are_reported_after_it() {
1729        // Nothing outside the TUI can show a broken terminal; the pane
1730        // must say so when the TUI is back.
1731        let _lock = DIAG_LOCK.lock().await;
1732        let dir = tempfile::tempdir().unwrap();
1733        let mut state = journaled_state(&dir);
1734        let (mut host, _) = test_host(Vec::new(), Vec::new());
1735        let outside = Outside::new(Output::from_writers(BrokenTty, BrokenTty));
1736        let failed = async { Err(ScopeError::Other("boom".into())) };
1737        let run = run_suspended(&mut host, "setup", &[], &outside, failed, &mut state);
1738        assert_eq!(within_limit(run).await, None);
1739        assert_ran_note(&state, "setup", "failed");
1740        let text = state.scrollback_text();
1741        assert_eq!(
1742            text.matches("Terminal I/O error while outside the TUI: tty gone")
1743                .count(),
1744            1,
1745            "the error text; the header is a note, and a failed note is dropped: {text}"
1746        );
1747        assert_eq!(records(&dir)[0]["outcome"], "error");
1748    }
1749
1750    #[tokio::test]
1751    async fn test_suspended_command_ignores_tstp_and_cont() {
1752        // The command owns the terminal, so TSTP and CONT do not stop the
1753        // TUI or take the screen, and do not end the command.
1754        let _lock = DIAG_LOCK.lock().await;
1755        let (mut host, captured) = test_host(script(&["setup --key nosuchkey"]), Vec::new());
1756        host.suspend_signals = vec![Signal::Tstp, Signal::Cont];
1757        let (res, state, host, _) = drive_host(Journal::default(), host, captured).await;
1758        assert_eq!(res.unwrap().code, 0);
1759        assert_eq!((host.suspends, host.resumes), (1, 1));
1760        assert_eq!((host.stops, host.clears), (0, 0));
1761        assert_ran_note(&state, "setup --key nosuchkey", "ok");
1762    }
1763
1764    #[tokio::test]
1765    async fn test_event_stream_error_still_restores_once_and_saves() {
1766        // A broken terminal ends the loop with an error; the terminal is
1767        // still restored once and the history is still saved.
1768        let _lock = DIAG_LOCK.lock().await;
1769        let dir = tempfile::tempdir().unwrap();
1770        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1771        let mut events = script(&["ctx"]);
1772        events.push(Err(io::Error::other("tty gone")));
1773        let (res, _, host, _) = drive_with(journal, events, Vec::new()).await;
1774        assert!(res.unwrap_err().to_string().contains("tty gone"));
1775        assert_eq!(host.restores, 1);
1776        let history = std::fs::read_to_string(dir.path().join("h")).unwrap();
1777        assert!(history.contains("ctx"), "{history}");
1778    }
1779
1780    #[tokio::test]
1781    async fn test_normal_exit_restores_and_returns_code_zero() {
1782        let _lock = DIAG_LOCK.lock().await;
1783        let (res, _, host, _) = drive(script(&["exit"])).await;
1784        assert_eq!(res.unwrap().code, 0);
1785        assert_eq!(host.restores, 1);
1786    }
1787
1788    #[tokio::test]
1789    async fn test_loop_runs_session_and_pane_commands_then_exits() {
1790        let _lock = DIAG_LOCK.lock().await;
1791        let (res, state, host, _) =
1792            drive(script(&["chain base", "venues list", "exit", "never runs"])).await;
1793        res.unwrap();
1794        let text = state.scrollback_text();
1795        assert!(text.contains("Type `help` for commands"));
1796        assert!(text.contains("Chain pinned to: base"));
1797        assert!(text.contains("Available Venues"), "{}", text);
1798        assert!(text.contains("Done in"));
1799        assert!(!text.contains("never runs"), "nothing runs after exit");
1800        // The screen shows the pinned chain in the prompt.
1801        let buf = host.terminal.backend().buffer().clone();
1802        let screen: String = buf.content().iter().map(|c| c.symbol()).collect();
1803        assert!(screen.contains("scope:base>"));
1804    }
1805
1806    fn ctrl_y_event() -> io::Result<Event> {
1807        Ok(Event::Key(KeyEvent::new(
1808            KeyCode::Char('y'),
1809            KeyModifiers::CONTROL,
1810        )))
1811    }
1812
1813    #[tokio::test]
1814    async fn test_ctrl_y_sends_the_json_and_save_writes_it() {
1815        use base64::Engine as _;
1816        let _lock = DIAG_LOCK.lock().await;
1817        let dir = tempfile::tempdir().unwrap();
1818        let file = dir.path().join("one.json");
1819        let mut events = script(&[
1820            "venues list --format json",
1821            &format!("save {}", file.display()),
1822        ]);
1823        events.push(ctrl_y_event());
1824        let (res, state, host, _) = drive(events).await;
1825        res.unwrap();
1826        let body = std::fs::read_to_string(&file).unwrap();
1827        serde_json::from_str::<serde_json::Value>(&body).unwrap();
1828        let text = state.scrollback_text();
1829        assert!(text.contains("copied "), "{text}");
1830        let payload = host
1831            .raw
1832            .strip_prefix("\x1b]52;c;")
1833            .and_then(|r| r.strip_suffix('\x07'))
1834            .unwrap_or_else(|| panic!("not an OSC 52 sequence: {:?}", host.raw));
1835        let sent = base64::engine::general_purpose::STANDARD
1836            .decode(payload)
1837            .unwrap();
1838        assert_eq!(String::from_utf8(sent).unwrap(), body);
1839    }
1840
1841    #[tokio::test]
1842    async fn test_save_refuses_to_overwrite_then_force_replaces_and_both_are_journaled() {
1843        let _lock = DIAG_LOCK.lock().await;
1844        let dir = tempfile::tempdir().unwrap();
1845        let file = dir.path().join("two.json");
1846        std::fs::write(&file, "old").unwrap();
1847        let save = format!("save {}", file.display());
1848        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1849        let (res, state, _, _) = drive_with(
1850            journal,
1851            script(&[
1852                "venues list --format json",
1853                &save,
1854                &format!("{save} --force"),
1855            ]),
1856            Vec::new(),
1857        )
1858        .await;
1859        res.unwrap();
1860        let text = state.scrollback_text();
1861        assert!(text.contains("exists; add --force"), "{text}");
1862        assert!(text.contains("saved "), "{text}");
1863        let body = std::fs::read_to_string(&file).unwrap();
1864        assert_ne!(body, "old", "--force replaced the file");
1865        serde_json::from_str::<serde_json::Value>(&body).unwrap();
1866        let recs = records(&dir);
1867        let refused = recs.iter().find(|r| r["outcome"] == "refused").unwrap();
1868        assert_eq!(refused["input"], save);
1869        assert!(refused["error"].as_str().unwrap().contains("exists"));
1870        assert!(
1871            recs.iter()
1872                .any(|r| r["input"] == format!("{save} --force") && r["outcome"] == "session")
1873        );
1874    }
1875
1876    #[tokio::test]
1877    async fn test_ctrl_y_results_are_journaled() {
1878        let _lock = DIAG_LOCK.lock().await;
1879        let dir = tempfile::tempdir().unwrap();
1880        let journal = Journal::at(dir.path().join("h"), dir.path().join("e.jsonl"));
1881        let mut events = vec![ctrl_y_event()];
1882        events.extend(script(&["venues list"]));
1883        events.push(ctrl_y_event());
1884        let (mut host, captured) = test_host(events, Vec::new());
1885        host.raw_fails = true;
1886        let (_, _, _, _) = drive_host(journal, host, captured).await;
1887        let recs = records(&dir);
1888        let ctrl_y: Vec<_> = recs.iter().filter(|r| r["input"] == "Ctrl-Y").collect();
1889        assert_eq!(ctrl_y.len(), 2, "{recs:?}");
1890        assert_eq!(ctrl_y[0]["outcome"], "refused");
1891        assert_eq!(ctrl_y[0]["error"], "nothing to copy yet");
1892        assert_eq!(ctrl_y[1]["outcome"], "error");
1893        assert!(ctrl_y[1]["error"].as_str().unwrap().contains("tty gone"));
1894    }
1895
1896    #[tokio::test]
1897    async fn test_a_failed_copy_is_noted() {
1898        let _lock = DIAG_LOCK.lock().await;
1899        let mut events = script(&["venues list"]);
1900        events.push(Ok(Event::Key(KeyEvent::new(
1901            KeyCode::Char('y'),
1902            KeyModifiers::CONTROL,
1903        ))));
1904        let (mut host, captured) = test_host(events, Vec::new());
1905        host.raw_fails = true;
1906        let (_, state, _, _) = drive_host(Journal::default(), host, captured).await;
1907        assert!(state.scrollback_text().contains("Could not copy: tty gone"));
1908    }
1909
1910    #[tokio::test]
1911    async fn test_loop_esc_cancels_a_running_repeat_command() {
1912        // `--every` sleeps between runs; Esc must stop it, not wait 1 minute.
1913        let _lock = DIAG_LOCK.lock().await;
1914        let (res, state, _, _) =
1915            drive_pending(script(&[PENDING_REPEAT, "<esc>"]), Vec::new()).await;
1916        res.unwrap();
1917        let text = state.scrollback_text();
1918        assert!(text.contains("Cancelled after"), "{}", text);
1919        assert!(state.running.is_none());
1920    }
1921
1922    #[tokio::test]
1923    async fn test_loop_suspend_mode_hands_over_the_terminal_and_records() {
1924        // A suspend-mode command must release the screen, run on the plain
1925        // terminal, take the screen back, and leave a trace in the pane.
1926        // `setup --key` with an unknown name returns before reading stdin.
1927        let _lock = DIAG_LOCK.lock().await;
1928        let (res, state, host, suspended) = drive(script(&["setup --key nosuchkey"])).await;
1929        res.unwrap();
1930        assert_eq!((host.suspends, host.resumes), (1, 1));
1931        assert!(
1932            suspended
1933                .out()
1934                .contains("── scope setup --key nosuchkey ──")
1935        );
1936        assert!(suspended.out().contains(
1937            "Running outside the TUI; it returns when the command ends (Ctrl-C cancels)."
1938        ));
1939        // It returns by itself: there is no Enter to wait for.
1940        assert!(!suspended.out().contains("Press Enter"));
1941        assert_ran_note(&state, "setup --key nosuchkey", "ok");
1942    }
1943
1944    #[tokio::test]
1945    async fn test_loop_ignores_non_press_keys_and_ends_on_stream_end() {
1946        let _lock = DIAG_LOCK.lock().await;
1947        let mut events = vec![Ok(Event::Resize(80, 24))];
1948        let mut release = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
1949        release.kind = KeyEventKind::Release;
1950        release.state = KeyEventState::NONE;
1951        events.push(Ok(Event::Key(release)));
1952        let (res, state, _, _) = drive(events).await;
1953        res.unwrap();
1954        assert_eq!(state.input, "", "a key release must not type");
1955    }
1956
1957    #[tokio::test]
1958    async fn test_loop_ctrl_d_exits() {
1959        let _lock = DIAG_LOCK.lock().await;
1960        let (res, _, _, _) = drive(vec![Ok(Event::Key(KeyEvent::new(
1961            KeyCode::Char('d'),
1962            KeyModifiers::CONTROL,
1963        )))])
1964        .await;
1965        res.unwrap();
1966    }
1967
1968    #[tokio::test]
1969    async fn test_loop_returns_event_stream_errors() {
1970        // A broken terminal must end the TUI with the error, not spin.
1971        let _lock = DIAG_LOCK.lock().await;
1972        let (res, _, _, _) = drive(vec![Err(io::Error::other("tty gone"))]).await;
1973        assert!(res.unwrap_err().to_string().contains("tty gone"));
1974    }
1975
1976    #[tokio::test]
1977    async fn test_library_warning_lands_in_the_running_command_output() {
1978        // scope-core warnings use diag::notice; while a pane command runs
1979        // they must join its output, not print over the TUI screen.
1980        let _lock = DIAG_LOCK.lock().await;
1981        let mut s = TuiState::new(
1982            SessionContext::default(),
1983            Journal::default(),
1984            vocab::Vocab::default(),
1985        );
1986        let Plan::Run(inv) = s.submit("venues list", &Config::default()) else {
1987            panic!()
1988        };
1989        let _out = s.start("venues list", &inv);
1990        scope::notice!("  ⚠ library warning");
1991        s.finish(Ending::Finished(Ok(())));
1992        assert!(s.scrollback_text().contains("⚠ library warning"));
1993    }
1994
1995    #[cfg(unix)]
1996    #[tokio::test]
1997    async fn test_signal_stream_installs_all_handlers() {
1998        // Installing handlers sends no signal. A failure to install one
1999        // must stop the TUI before it takes the terminal.
2000        assert!(signal_stream().is_ok());
2001    }
2002}