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