1pub 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
64const PROMPT_SIGNAL_WAIT: Duration = Duration::from_secs(1);
67
68const LIVE_REDRAW: Duration = Duration::from_millis(100);
71
72const OUTSIDE_COPY_LIMIT: usize = app::SCROLLBACK_LIMIT * 200;
76
77const BANNER: &str = include_str!("../../../assets/banner.txt");
79
80const SIGNAL_EXIT_BASE: i32 = 128;
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct TuiExit {
87 pub code: i32,
89}
90
91#[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 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 fn ends_suspended(self) -> bool {
118 matches!(
119 self,
120 Signal::Int | Signal::Term | Signal::Hup | Signal::Quit
121 )
122 }
123
124 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
138pub 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
157pub async fn run(
165 no_banner: bool,
166 config: &Config,
167 clients: &dyn ChainClientFactory,
168) -> Result<TuiExit> {
169 let signals = signal_stream()?;
170 tokio::spawn(cancel_prompts_on(signal_stream()?, &prompt::CANCEL));
174 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
194async 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 host.set_width(None);
207 host.restore();
208
209 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#[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#[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 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#[cfg(test)]
267pub(crate) static DIAG_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
268
269pub(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 fn streams(&mut self) -> (&mut Self::Events, &mut Self::Signals);
279 fn suspend(&mut self);
281 fn resume(&mut self) -> Result<()>;
287 fn restore(&mut self);
289 fn stop_self(&mut self) -> io::Result<()>;
293 fn clear(&mut self) -> io::Result<()>;
295 fn suspended_output(&self) -> Output;
297 fn write_raw(&mut self, text: &str) -> io::Result<()>;
299 fn set_width(&mut self, width: Option<usize>);
302}
303
304struct RealHost {
306 terminal: ratatui::DefaultTerminal,
307 events: Option<EventStream>,
310 signals: BoxStream<'static, Signal>,
311 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 fn restore(&mut self) {
341 if std::mem::take(&mut self.on_screen) {
342 screen::restore();
343 }
344 }
345 #[cfg(unix)]
348 fn stop_self(&mut self) -> io::Result<()> {
349 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
377struct Outside {
379 terminal: Output,
382 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
402const 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 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 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
421 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 .map_err(|e| {
435 ScopeError::io_context(io::Error::other(e.to_string()), "terminal draw failed")
436 })?;
437 host.set_width(state.pane_width());
440
441 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(pending.take());
456 state.finish(Ending::Cancelled);
457 state.record_signal(sig.name(), "session", "", &[]);
458 return Ok(TuiExit { code });
459 }
460 if sig == Signal::Tstp {
466 cont_expected = stop_and_return(host, state)?;
467 } else if !std::mem::take(&mut cont_expected) {
468 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 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 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 state.push_note(SETUP_RESTART_NOTE);
528 }
529 }
530 Route::Refuse(reason) => {
531 state.push_note(&format!("Not available here: {}.", reason));
534 }
535 },
536 },
537 }
538 }
539 }
540 }
541}
542
543fn 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
560fn leave<H: Host>(host: &mut H) {
563 host.set_width(None);
564 host.suspend();
565}
566
567fn 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
575async 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 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 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
642fn 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
656async 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
670fn 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
679async 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
693fn 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#[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 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 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 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 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 assert_same_result("venues validate /nonexistent/venue.yaml").await;
828 }
829
830 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 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_signals: Vec<Signal>,
849 suspended: Output,
850 suspends: usize,
851 resumes: usize,
852 restores: usize,
853 stops: usize,
854 clears: usize,
855 draws: usize,
857 draws_at_stop: usize,
859 stop_fails: bool,
861 raw: String,
863 raw_fails: bool,
865 resume_fails: bool,
867 widths: Vec<(&'static str, Option<usize>)>,
869 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 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 fn set_width(&mut self, width: Option<usize>) {
930 self.width = width;
931 }
932 }
933
934 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 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 async fn drive(events: Vec<io::Result<Event>>) -> Driven {
964 drive_with(Journal::default(), events, Vec::new()).await
965 }
966
967 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 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 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 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 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 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 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 const PENDING_REPEAT: &str = "market summary USDC --venue eth --every 30s --duration 1h";
1094
1095 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 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 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 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 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 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 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 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 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 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 assert_eq!(widths_at(&host, "suspend"), vec![None, None]);
1206 assert_eq!(widths_at(&host, "restore"), vec![None]);
1207 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(captured.out().contains("holders 42"));
1615 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(signal_stream().is_ok());
2001 }
2002}