Skip to main content

clap_tui/
app.rs

1use std::error::Error as StdError;
2use std::ffi::OsString;
3use std::marker::PhantomData;
4use std::time::Duration;
5
6use clap::{Command, CommandFactory, Parser};
7use ratatui::Frame;
8
9use crate::config::TuiConfig;
10use crate::controller;
11use crate::error::TuiError;
12use crate::frame_snapshot::FrameSnapshot;
13use crate::input::AppState;
14use crate::runtime::{AppEvent, CrosstermRuntime, Runtime};
15use crate::ui;
16use crate::update::{self, Effect};
17
18/// Build and run a TUI from a hand-built [`clap::Command`].
19///
20/// Use [`crate::Tui`] for derive-based CLIs that want typed results.
21/// Use `TuiApp` when you are already building a [`clap::Command`] by hand, or when you want
22/// the untyped surface that returns argv or `ArgMatches`.
23pub struct TuiApp<R: Runtime = CrosstermRuntime> {
24    command: Command,
25    config: TuiConfig,
26    runtime: R,
27}
28
29/// Direct typed TUI execution for a derive-based `clap` parser.
30///
31/// `Tui::<T>::run()` is the primary explicit integration surface for applications that define
32/// their own `Command::Tui` dispatch branch and want the selected command value back as `T`.
33pub struct Tui<T, R: Runtime = CrosstermRuntime> {
34    inner: TuiApp<R>,
35    _parser: PhantomData<fn() -> T>,
36}
37
38/// A typed command invocation together with the canonical argv that produced it.
39#[derive(Debug, Clone, PartialEq, Eq)]
40#[non_exhaustive]
41pub struct TuiInvocation<T> {
42    /// The command value parsed from [`Self::argv`].
43    pub command: T,
44    /// Canonical argv, with the program name as its first element.
45    pub argv: Vec<OsString>,
46}
47
48impl TuiApp<CrosstermRuntime> {
49    /// Create a TUI from a hand-built [`clap::Command`].
50    #[must_use]
51    pub fn from_command(command: Command) -> Self {
52        Self {
53            command,
54            config: TuiConfig::default(),
55            runtime: CrosstermRuntime,
56        }
57    }
58}
59
60impl<R: Runtime> TuiApp<R> {
61    /// Apply configuration before the TUI starts.
62    #[must_use]
63    pub fn with_config(mut self, config: TuiConfig) -> Self {
64        self.config = config;
65        self
66    }
67
68    /// Replace the default runtime.
69    #[must_use]
70    pub fn with_runtime<NR: Runtime>(self, runtime: NR) -> TuiApp<NR> {
71        TuiApp {
72            command: self.command,
73            config: self.config,
74            runtime,
75        }
76    }
77
78    /// Run the TUI and return the selected canonical argv.
79    ///
80    /// The returned argv includes the program name as its first element. Preview and clipboard
81    /// text are rendered separately, using POSIX shell quoting on Unix platforms and `PowerShell`
82    /// quoting on Windows.
83    ///
84    /// Returns `Ok(Some(argv))` when the user runs a valid command and `Ok(None)` when the
85    /// user exits without running. Validation stays inside the TUI flow, so invalid form
86    /// state is surfaced in-app rather than returned as a clap error from this method.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error when terminal setup or event handling fails.
91    pub fn run(self) -> Result<Option<Vec<OsString>>, TuiError> {
92        match self.run_inner() {
93            Ok(argv) => Ok(Some(argv)),
94            Err(TuiError::Cancelled) => Ok(None),
95            Err(err) => Err(err),
96        }
97    }
98
99    /// Run the TUI and execute a custom handler with `ArgMatches`.
100    ///
101    /// Returns `Ok(())` when the user exits without running. When the user does run, this
102    /// method reparses the selected argv with the original [`clap::Command`] before calling
103    /// the handler.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error when terminal setup or event handling fails, when reparsing the
108    /// selected argv with clap fails, or when the runner callback fails.
109    pub fn run_with_matches<F, E>(self, runner: F) -> Result<(), TuiError>
110    where
111        F: FnOnce(clap::ArgMatches) -> Result<(), E>,
112        E: StdError + Send + Sync + 'static,
113    {
114        let command = self.command.clone();
115        let Some(argv) = self.run()? else {
116            return Ok(());
117        };
118        run_matches_handler(command, argv, runner)
119    }
120
121    fn run_inner(self) -> Result<Vec<OsString>, TuiError> {
122        let Self {
123            command,
124            config,
125            mut runtime,
126        } = self;
127        let terminal = runtime.init_terminal()?;
128        let mut session = TerminalSession::new(&mut runtime, terminal);
129        event_loop(&command, &config, &mut session)
130    }
131}
132
133impl<T> Tui<T, CrosstermRuntime>
134where
135    T: Parser + CommandFactory,
136{
137    /// Create a typed app from a derive-based parser.
138    #[must_use]
139    pub fn new() -> Self {
140        Self {
141            inner: TuiApp::from_command(T::command()),
142            _parser: PhantomData,
143        }
144    }
145}
146
147impl<T> Default for Tui<T, CrosstermRuntime>
148where
149    T: Parser + CommandFactory,
150{
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156impl<T, R: Runtime> Tui<T, R>
157where
158    T: Parser + CommandFactory,
159{
160    /// Hide a matching top-level entrypoint subcommand from the rendered TUI.
161    ///
162    /// This only changes the command tree used to build the TUI. Typed reparsing after submit
163    /// still uses the original clap parser type `T`.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`TuiError::UnknownEntrypoint`] when `name` does not match a canonical top-level
168    /// subcommand name on the render command.
169    pub fn hide_entrypoint(mut self, name: impl Into<String>) -> Result<Self, TuiError> {
170        let name = name.into();
171        hide_top_level_entrypoint(&mut self.inner.command, &name)?;
172        Ok(self)
173    }
174
175    /// Apply configuration before the TUI starts.
176    #[must_use]
177    pub fn with_config(self, config: TuiConfig) -> Self {
178        Self {
179            inner: self.inner.with_config(config),
180            _parser: PhantomData,
181        }
182    }
183
184    /// Replace the default runtime.
185    #[must_use]
186    pub fn with_runtime<NR: Runtime>(self, runtime: NR) -> Tui<T, NR> {
187        Tui {
188            inner: self.inner.with_runtime(runtime),
189            _parser: PhantomData,
190        }
191    }
192
193    /// Run the TUI and parse the submitted command into the bound parser type.
194    ///
195    /// Returns `Ok(Some(parsed))` when the user submits a valid command and `Ok(None)` when the
196    /// user exits without submitting. If clap reparsing produces help, version, or parse-display
197    /// behavior, this method returns `Err(TuiError::Clap(_))` without printing automatically or
198    /// calling `std::process::exit`.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error when terminal setup, rendering, runtime integration, or clap reparsing
203    /// fails.
204    pub fn run(self) -> Result<Option<T>, TuiError> {
205        self.run_with_argv()
206            .map(|invocation| invocation.map(|invocation| invocation.command))
207    }
208
209    /// Run the TUI and return the parsed command together with its canonical argv.
210    ///
211    /// Returns `Ok(Some(invocation))` when the user submits a valid command and `Ok(None)` when
212    /// the user exits without submitting. [`TuiInvocation::argv`] is the exact token sequence used
213    /// for clap reparsing.
214    ///
215    /// Use [`Self::run`] when only the parsed command is needed.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error when terminal setup, rendering, runtime integration, or clap reparsing
220    /// fails.
221    pub fn run_with_argv(self) -> Result<Option<TuiInvocation<T>>, TuiError> {
222        let Some(argv) = self.inner.run()? else {
223            return Ok(None);
224        };
225        let command = parse_result(T::try_parse_from(&argv))?;
226        Ok(Some(TuiInvocation { command, argv }))
227    }
228
229    /// Drop down to the untyped app surface when only argv or `ArgMatches` execution is needed.
230    #[must_use]
231    pub fn into_untyped(self) -> TuiApp<R> {
232        self.inner
233    }
234}
235
236fn parse_result<T>(result: Result<T, clap::Error>) -> Result<T, TuiError> {
237    result.map_err(TuiError::from)
238}
239
240fn hide_top_level_entrypoint(command: &mut Command, name: &str) -> Result<(), TuiError> {
241    let candidates = top_level_entrypoint_candidates(command);
242
243    for subcommand in command.get_subcommands_mut() {
244        if subcommand.get_name() == name {
245            *subcommand = subcommand.clone().hide(true);
246            return Ok(());
247        }
248    }
249
250    Err(TuiError::UnknownEntrypoint {
251        name: name.to_string(),
252        candidates,
253    })
254}
255
256fn top_level_entrypoint_candidates(command: &Command) -> Vec<String> {
257    command
258        .get_subcommands()
259        .map(|subcommand| subcommand.get_name().to_string())
260        .collect()
261}
262
263fn run_matches_handler<F, E>(
264    command: Command,
265    argv: Vec<OsString>,
266    runner: F,
267) -> Result<(), TuiError>
268where
269    F: FnOnce(clap::ArgMatches) -> Result<(), E>,
270    E: StdError + Send + Sync + 'static,
271{
272    let matches = parse_result(command.try_get_matches_from(argv))?;
273    runner(matches).map_err(|err| TuiError::Runner(Box::new(err)))
274}
275
276fn event_loop<R: Runtime>(
277    command: &Command,
278    config: &TuiConfig,
279    session: &mut TerminalSession<'_, R>,
280) -> Result<Vec<OsString>, TuiError> {
281    let mut observer = NoopDrawObserver;
282    event_loop_with_observer(command, config, session, &mut observer)
283}
284
285fn event_loop_with_observer<R, O>(
286    command: &Command,
287    config: &TuiConfig,
288    session: &mut TerminalSession<'_, R>,
289    observer: &mut O,
290) -> Result<Vec<OsString>, TuiError>
291where
292    R: Runtime,
293    O: DrawObserver<R::Backend>,
294{
295    let mut state = AppState::from_command(command);
296    if let Some(start) = config.start_command.clone() {
297        controller::navigation::apply_start_command(&mut state, &start);
298    }
299    let mut frame_snapshot = FrameSnapshot::default();
300    let mut needs_redraw = true;
301
302    loop {
303        if needs_redraw {
304            session.draw(|frame| {
305                frame_snapshot = render_frame(frame, &mut state, config);
306            })?;
307            observer.observe(session.backend(), &frame_snapshot)?;
308            needs_redraw = false;
309        }
310
311        if !session.poll_event(redraw_timeout(&state))? {
312            needs_redraw |= clear_expired_toast_and_request_redraw(&mut state);
313            continue;
314        }
315
316        match handle_app_event(
317            &session.read_event()?,
318            &mut state,
319            &frame_snapshot,
320            config,
321            session,
322        ) {
323            EventOutcome::Continue {
324                needs_redraw: redraw,
325            } => {
326                needs_redraw |= redraw;
327            }
328            EventOutcome::Exit => return Err(TuiError::Cancelled),
329            EventOutcome::Run(argv) => return Ok(argv),
330        }
331    }
332}
333
334fn redraw_timeout(state: &AppState) -> Duration {
335    state
336        .notifications
337        .toast
338        .as_ref()
339        .map_or(Duration::from_secs(60 * 60), |toast| {
340            toast
341                .expires_at
342                .saturating_duration_since(std::time::Instant::now())
343        })
344}
345
346fn clear_expired_toast_and_request_redraw(state: &mut AppState) -> bool {
347    let had_toast = state.notifications.toast.is_some();
348    state.notifications.clear_expired_toast();
349    had_toast && state.notifications.toast.is_none()
350}
351
352fn handle_effect<R: Runtime>(
353    effect: Effect,
354    state: &mut AppState,
355    session: &mut TerminalSession<'_, R>,
356) -> ActionOutcome {
357    match effect {
358        Effect::None => ActionOutcome::Continue,
359        Effect::Run(argv) => {
360            let validation = state.derived_validation();
361            if validation.is_valid {
362                ActionOutcome::Run(argv)
363            } else {
364                state.notifications.show_toast(
365                    validation
366                        .summary
367                        .unwrap_or_else(|| "Command is invalid".to_string()),
368                    Duration::from_secs(3),
369                    true,
370                );
371                ActionOutcome::Continue
372            }
373        }
374        Effect::CopyToClipboard(command) => {
375            let result = session.copy_to_clipboard(&command);
376            match result {
377                Ok(()) => {
378                    state.notifications.show_toast(
379                        "Copied command to clipboard",
380                        Duration::from_secs(2),
381                        false,
382                    );
383                }
384                Err(_) => state.notifications.show_toast(
385                    "Clipboard unavailable",
386                    Duration::from_secs(2),
387                    true,
388                ),
389            }
390            ActionOutcome::Continue
391        }
392        Effect::Exit => ActionOutcome::Exit,
393    }
394}
395
396fn handle_app_event<R: Runtime>(
397    event: &AppEvent,
398    state: &mut AppState,
399    frame_snapshot: &FrameSnapshot,
400    config: &TuiConfig,
401    session: &mut TerminalSession<'_, R>,
402) -> EventOutcome {
403    let mut needs_redraw = clear_expired_toast_and_request_redraw(state);
404
405    match event {
406        AppEvent::Key(key) => {
407            if let Some(action) = controller::handle_key_event(*key, state, frame_snapshot, config)
408            {
409                let effect = update::apply_action(&action, state, frame_snapshot);
410                match handle_effect(effect, state, session) {
411                    ActionOutcome::Continue => {
412                        needs_redraw |= true;
413                        needs_redraw |= clear_expired_toast_and_request_redraw(state);
414                        EventOutcome::Continue { needs_redraw }
415                    }
416                    ActionOutcome::Exit => EventOutcome::Exit,
417                    ActionOutcome::Run(argv) => EventOutcome::Run(argv),
418                }
419            } else {
420                needs_redraw |= clear_expired_toast_and_request_redraw(state);
421                EventOutcome::Continue { needs_redraw }
422            }
423        }
424        AppEvent::Mouse(mouse) => {
425            if let Some(action) =
426                controller::handle_mouse_event(*mouse, state, frame_snapshot, config)
427            {
428                let effect = update::apply_action(&action, state, frame_snapshot);
429                match handle_effect(effect, state, session) {
430                    ActionOutcome::Continue => {
431                        needs_redraw |= true;
432                        needs_redraw |= clear_expired_toast_and_request_redraw(state);
433                        EventOutcome::Continue { needs_redraw }
434                    }
435                    ActionOutcome::Exit => EventOutcome::Exit,
436                    ActionOutcome::Run(argv) => EventOutcome::Run(argv),
437                }
438            } else {
439                needs_redraw |= clear_expired_toast_and_request_redraw(state);
440                EventOutcome::Continue { needs_redraw }
441            }
442        }
443        AppEvent::Resize { .. } => {
444            needs_redraw = true;
445            needs_redraw |= clear_expired_toast_and_request_redraw(state);
446            EventOutcome::Continue { needs_redraw }
447        }
448        AppEvent::Paste(text) => {
449            let effect =
450                update::apply_action(&update::Action::Paste(text.clone()), state, frame_snapshot);
451            match handle_effect(effect, state, session) {
452                ActionOutcome::Continue => {
453                    needs_redraw |= true;
454                    needs_redraw |= clear_expired_toast_and_request_redraw(state);
455                    EventOutcome::Continue { needs_redraw }
456                }
457                ActionOutcome::Exit => EventOutcome::Exit,
458                ActionOutcome::Run(argv) => EventOutcome::Run(argv),
459            }
460        }
461        AppEvent::FocusGained | AppEvent::FocusLost | AppEvent::Unsupported => {
462            needs_redraw |= clear_expired_toast_and_request_redraw(state);
463            EventOutcome::Continue { needs_redraw }
464        }
465    }
466}
467
468fn render_frame(frame: &mut Frame<'_>, state: &mut AppState, config: &TuiConfig) -> FrameSnapshot {
469    ui::render(frame, state, config)
470}
471
472trait DrawObserver<B: ratatui::backend::Backend> {
473    fn observe(&mut self, _backend: &B, _frame_snapshot: &FrameSnapshot) -> Result<(), TuiError> {
474        Ok(())
475    }
476}
477
478struct NoopDrawObserver;
479
480impl<B: ratatui::backend::Backend> DrawObserver<B> for NoopDrawObserver {}
481
482enum ActionOutcome {
483    Continue,
484    Exit,
485    Run(Vec<OsString>),
486}
487
488enum EventOutcome {
489    Continue { needs_redraw: bool },
490    Exit,
491    Run(Vec<OsString>),
492}
493
494struct TerminalSession<'a, R: Runtime> {
495    runtime: &'a mut R,
496    terminal: Option<ratatui::Terminal<R::Backend>>,
497}
498
499impl<'a, R: Runtime> TerminalSession<'a, R> {
500    fn new(runtime: &'a mut R, terminal: ratatui::Terminal<R::Backend>) -> Self {
501        Self {
502            runtime,
503            terminal: Some(terminal),
504        }
505    }
506
507    fn draw<F>(&mut self, draw_fn: F) -> Result<(), TuiError>
508    where
509        F: FnOnce(&mut Frame<'_>),
510    {
511        self.terminal
512            .as_mut()
513            .expect("terminal session is active")
514            .draw(draw_fn)
515            .map(|_| ())
516            .map_err(|e| TuiError::Terminal(std::io::Error::other(e.to_string())))
517    }
518
519    fn backend(&self) -> &R::Backend {
520        self.terminal
521            .as_ref()
522            .expect("terminal session is active")
523            .backend()
524    }
525
526    fn poll_event(&mut self, timeout: Duration) -> Result<bool, TuiError> {
527        self.runtime.poll_event(timeout)
528    }
529
530    fn read_event(&mut self) -> Result<AppEvent, TuiError> {
531        self.runtime.read_event()
532    }
533
534    fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
535        self.runtime.copy_to_clipboard(text)
536    }
537}
538
539impl<R: Runtime> Drop for TerminalSession<'_, R> {
540    fn drop(&mut self) {
541        if let Some(mut terminal) = self.terminal.take() {
542            self.runtime.restore_terminal(&mut terminal);
543        }
544    }
545}
546
547#[cfg(test)]
548mod scripted;
549#[cfg(test)]
550mod scripted_tests;
551
552#[cfg(test)]
553mod tests {
554    use std::collections::VecDeque;
555    use std::ffi::OsString;
556    use std::time::{Duration, Instant};
557
558    use clap::error::ErrorKind;
559    use clap::{Arg, ArgAction, Command, Parser};
560    use ratatui::Terminal;
561    use ratatui::backend::TestBackend;
562
563    use super::{
564        ActionOutcome, EventOutcome, TerminalSession, event_loop, handle_app_event, handle_effect,
565        redraw_timeout,
566    };
567    use crate::frame_snapshot::FrameSnapshot;
568    use crate::input::{AppState, Toast};
569    use crate::pipeline;
570    use crate::runtime::{AppEvent, AppKeyCode, AppKeyEvent, AppKeyModifiers, Runtime};
571    use crate::spec::CommandSpec;
572    use crate::update::Effect;
573    use crate::{TuiConfig, TuiError};
574
575    fn os_vec(values: &[&str]) -> Vec<OsString> {
576        values.iter().map(OsString::from).collect()
577    }
578
579    #[derive(Debug)]
580    struct TestRuntime {
581        events: VecDeque<AppEvent>,
582        clipboard_result: Result<(), String>,
583        copied_text: Option<String>,
584    }
585
586    impl TestRuntime {
587        fn with_events(events: impl IntoIterator<Item = AppEvent>) -> Self {
588            Self {
589                events: events.into_iter().collect(),
590                clipboard_result: Ok(()),
591                copied_text: None,
592            }
593        }
594    }
595
596    impl Runtime for TestRuntime {
597        type Backend = TestBackend;
598
599        fn init_terminal(&mut self) -> Result<Terminal<Self::Backend>, TuiError> {
600            let Ok(terminal) = Terminal::new(TestBackend::new(80, 24));
601            Ok(terminal)
602        }
603
604        fn restore_terminal(&mut self, _terminal: &mut Terminal<Self::Backend>) {}
605
606        fn poll_event(&mut self, _timeout: Duration) -> Result<bool, TuiError> {
607            Ok(!self.events.is_empty())
608        }
609
610        fn read_event(&mut self) -> Result<AppEvent, TuiError> {
611            Ok(self.events.pop_front().expect("queued event"))
612        }
613
614        fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
615            self.copied_text = Some(text.to_string());
616            self.clipboard_result.clone()
617        }
618    }
619
620    fn terminal_session(runtime: &mut TestRuntime) -> TerminalSession<'_, TestRuntime> {
621        let terminal = runtime.init_terminal().expect("terminal");
622        TerminalSession::new(runtime, terminal)
623    }
624
625    fn app_state() -> AppState {
626        AppState::new(CommandSpec::from_command(&Command::new("tool")))
627    }
628
629    fn app_state_from_command(command: &Command) -> AppState {
630        AppState::from_command(command)
631    }
632
633    #[test]
634    fn event_loop_returns_cancelled_on_ctrl_c() {
635        let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
636            AppKeyCode::Char('c'),
637            AppKeyModifiers {
638                control: true,
639                alt: false,
640                shift: false,
641            },
642        ))]);
643        let terminal = runtime.init_terminal().expect("terminal");
644        let mut session = TerminalSession::new(&mut runtime, terminal);
645
646        let result = event_loop(&Command::new("tool"), &TuiConfig::default(), &mut session);
647
648        assert!(matches!(result, Err(TuiError::Cancelled)));
649    }
650
651    #[test]
652    fn event_loop_returns_built_argv_on_ctrl_enter() {
653        let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
654            AppKeyCode::Enter,
655            AppKeyModifiers {
656                control: true,
657                alt: false,
658                shift: false,
659            },
660        ))]);
661        let terminal = runtime.init_terminal().expect("terminal");
662        let mut session = TerminalSession::new(&mut runtime, terminal);
663        let command = Command::new("tool").arg(
664            Arg::new("verbose")
665                .long("verbose")
666                .action(ArgAction::SetTrue),
667        );
668
669        let result = event_loop(&command, &TuiConfig::default(), &mut session);
670
671        assert_eq!(result.expect("run result"), os_vec(&["tool"]));
672    }
673
674    #[test]
675    fn event_loop_returns_built_argv_on_ctrl_r() {
676        let mut runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
677            AppKeyCode::Char('r'),
678            AppKeyModifiers {
679                control: true,
680                alt: false,
681                shift: false,
682            },
683        ))]);
684        let terminal = runtime.init_terminal().expect("terminal");
685        let mut session = TerminalSession::new(&mut runtime, terminal);
686        let command = Command::new("tool").arg(
687            Arg::new("verbose")
688                .long("verbose")
689                .action(ArgAction::SetTrue),
690        );
691
692        let result = event_loop(&command, &TuiConfig::default(), &mut session);
693
694        assert_eq!(result.expect("run result"), os_vec(&["tool"]));
695    }
696
697    #[test]
698    fn copy_effect_success_shows_success_toast() {
699        let mut runtime = TestRuntime::with_events([]);
700        let mut session = terminal_session(&mut runtime);
701        let mut state = app_state();
702
703        let outcome = handle_effect(
704            Effect::CopyToClipboard("tool --verbose".to_string()),
705            &mut state,
706            &mut session,
707        );
708        drop(session);
709
710        assert!(matches!(outcome, ActionOutcome::Continue));
711        assert_eq!(runtime.copied_text.as_deref(), Some("tool --verbose"));
712        let toast = state.notifications.toast.as_ref().expect("toast");
713        assert_eq!(toast.message, "Copied command to clipboard");
714        assert!(!toast.is_error);
715    }
716
717    #[test]
718    fn copy_effect_failure_shows_error_toast() {
719        let mut runtime = TestRuntime::with_events([]);
720        runtime.clipboard_result = Err("clipboard unavailable".to_string());
721        let mut session = terminal_session(&mut runtime);
722        let mut state = app_state();
723
724        let outcome = handle_effect(
725            Effect::CopyToClipboard("tool --verbose".to_string()),
726            &mut state,
727            &mut session,
728        );
729        drop(session);
730
731        assert!(matches!(outcome, ActionOutcome::Continue));
732        assert_eq!(runtime.copied_text.as_deref(), Some("tool --verbose"));
733        let toast = state.notifications.toast.as_ref().expect("toast");
734        assert_eq!(toast.message, "Clipboard unavailable");
735        assert!(toast.is_error);
736    }
737
738    #[test]
739    fn invalid_run_effect_is_blocked_and_surfaces_validation_summary() {
740        let command = Command::new("tool").arg(
741            Arg::new("name")
742                .long("name")
743                .required(true)
744                .action(ArgAction::Set),
745        );
746        let mut runtime = TestRuntime::with_events([]);
747        let mut session = terminal_session(&mut runtime);
748        let mut state = app_state_from_command(&command);
749
750        let outcome = handle_effect(Effect::Run(os_vec(&["tool"])), &mut state, &mut session);
751
752        assert!(matches!(outcome, ActionOutcome::Continue));
753        let toast = state.notifications.toast.as_ref().expect("toast");
754        assert!(toast.is_error);
755        assert_eq!(toast.message, "Missing required argument: --name");
756    }
757
758    #[test]
759    fn run_uses_cached_validation_state_without_revalidating() {
760        pipeline::reset_validation_call_count();
761
762        let command = Command::new("tool").arg(
763            Arg::new("name")
764                .long("name")
765                .required(true)
766                .action(ArgAction::Set),
767        );
768        let mut runtime = TestRuntime::with_events([]);
769        let mut session = terminal_session(&mut runtime);
770        let mut state = app_state_from_command(&command);
771        let argv = state.authoritative_argv();
772
773        assert_eq!(pipeline::validation_call_count(), 1);
774
775        let outcome = handle_effect(Effect::Run(argv), &mut state, &mut session);
776
777        assert!(matches!(outcome, ActionOutcome::Continue));
778        assert_eq!(pipeline::validation_call_count(), 1);
779        let toast = state.notifications.toast.as_ref().expect("toast");
780        assert!(toast.is_error);
781        assert_eq!(toast.message, "Missing required argument: --name");
782    }
783
784    #[test]
785    fn run_matches_handler_returns_clap_display_errors_without_running_callback() {
786        let mut called = false;
787
788        let result = super::run_matches_handler(
789            Command::new("tool").version("1.2.3"),
790            os_vec(&["tool", "--version"]),
791            |_matches| {
792                called = true;
793                Ok::<_, std::io::Error>(())
794            },
795        );
796
797        let error = result.expect_err("version display should be returned");
798        assert!(
799            matches!(error, TuiError::Clap(ref clap_error) if clap_error.kind() == ErrorKind::DisplayVersion)
800        );
801        assert!(!called);
802    }
803
804    #[test]
805    fn tui_run_returns_typed_value_on_submit() {
806        #[derive(Debug, clap::Parser, PartialEq, Eq)]
807        #[command(name = "tool")]
808        struct Cli {
809            #[arg(long, default_value = "world")]
810            name: String,
811        }
812
813        let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
814            AppKeyCode::Char('r'),
815            AppKeyModifiers {
816                control: true,
817                ..AppKeyModifiers::default()
818            },
819        ))]);
820
821        let result = super::Tui::<Cli, _>::new().with_runtime(runtime).run();
822
823        assert_eq!(
824            result.expect("typed run should succeed"),
825            Some(Cli {
826                name: "world".to_string()
827            })
828        );
829    }
830
831    #[test]
832    fn tui_run_with_argv_returns_typed_value_and_exact_canonical_argv() {
833        #[derive(Debug, clap::Parser, PartialEq, Eq)]
834        #[command(name = "tool")]
835        struct Cli {
836            #[arg(long, default_value = "world")]
837            name: String,
838        }
839
840        let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
841            AppKeyCode::Char('r'),
842            AppKeyModifiers {
843                control: true,
844                ..AppKeyModifiers::default()
845            },
846        ))]);
847
848        let invocation = super::Tui::<Cli, _>::new()
849            .with_runtime(runtime)
850            .run_with_argv()
851            .expect("typed run should succeed")
852            .expect("run should produce an invocation");
853
854        assert_eq!(
855            invocation.command,
856            Cli {
857                name: "world".to_string()
858            }
859        );
860        assert_eq!(invocation.argv, os_vec(&["tool"]));
861    }
862
863    #[test]
864    fn hide_entrypoint_hides_a_matching_top_level_subcommand_from_the_render_tree() {
865        #[derive(Debug, clap::Parser, PartialEq, Eq)]
866        #[command(name = "tool")]
867        enum Cli {
868            Tui,
869            Hello {
870                #[arg(long, default_value = "world")]
871                name: String,
872            },
873        }
874
875        let app = super::Tui::<Cli>::new()
876            .hide_entrypoint("tui")
877            .expect("top-level entrypoint should exist");
878        let spec = crate::spec::CommandSpec::from_command(&app.inner.command);
879
880        assert!(
881            spec.subcommands
882                .iter()
883                .all(|subcommand| subcommand.name != "tui")
884        );
885        assert!(
886            app.inner
887                .command
888                .get_subcommands()
889                .all(|subcommand| subcommand.get_name() != "tui" || subcommand.is_hide_set())
890        );
891    }
892
893    #[test]
894    fn hide_entrypoint_returns_unknown_entrypoint_with_candidates() {
895        #[derive(Debug, clap::Parser, PartialEq, Eq)]
896        #[command(name = "tool")]
897        enum Cli {
898            Tui,
899            Build,
900            Serve,
901        }
902
903        let Err(error) = super::Tui::<Cli>::new().hide_entrypoint("missing") else {
904            panic!("missing entrypoint should fail");
905        };
906
907        assert!(matches!(
908            error,
909            TuiError::UnknownEntrypoint { ref name, ref candidates }
910                if name == "missing" && candidates == &vec![
911                    "tui".to_string(),
912                    "build".to_string(),
913                    "serve".to_string()
914                ]
915        ));
916    }
917
918    #[test]
919    fn hide_entrypoint_does_not_match_aliases() {
920        #[derive(Debug, clap::Parser, PartialEq, Eq)]
921        #[command(name = "tool")]
922        enum Cli {
923            #[command(visible_alias = "interactive")]
924            Tui,
925            Build,
926        }
927
928        let Err(error) = super::Tui::<Cli>::new().hide_entrypoint("interactive") else {
929            panic!("aliases should not match");
930        };
931
932        assert!(matches!(
933            error,
934            TuiError::UnknownEntrypoint { ref name, ref candidates }
935                if name == "interactive" && candidates == &vec![
936                    "tui".to_string(),
937                    "build".to_string()
938                ]
939        ));
940    }
941
942    #[test]
943    fn hide_entrypoint_can_be_applied_twice() {
944        #[derive(Debug, clap::Parser, PartialEq, Eq)]
945        #[command(name = "tool")]
946        enum Cli {
947            Tui,
948            Build,
949        }
950
951        let app = super::Tui::<Cli>::new()
952            .hide_entrypoint("tui")
953            .expect("first hide should succeed")
954            .hide_entrypoint("tui")
955            .expect("second hide should also succeed");
956
957        let hidden = app
958            .inner
959            .command
960            .get_subcommands()
961            .find(|subcommand| subcommand.get_name() == "tui")
962            .expect("tui subcommand should still exist");
963
964        assert!(hidden.is_hide_set());
965    }
966
967    #[test]
968    fn tui_run_returns_none_on_cancel() {
969        #[derive(Debug, clap::Parser, PartialEq, Eq)]
970        #[command(name = "tool", version = "1.2.3")]
971        struct Cli;
972
973        let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
974            AppKeyCode::Char('c'),
975            AppKeyModifiers {
976                control: true,
977                ..AppKeyModifiers::default()
978            },
979        ))]);
980
981        let result = super::Tui::<Cli, _>::new().with_runtime(runtime).run();
982
983        assert_eq!(result.expect("cancel should map to None"), None);
984    }
985
986    #[test]
987    fn tui_run_with_argv_returns_none_on_cancel() {
988        #[derive(Debug, clap::Parser, PartialEq, Eq)]
989        #[command(name = "tool")]
990        struct Cli;
991
992        let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
993            AppKeyCode::Char('c'),
994            AppKeyModifiers {
995                control: true,
996                ..AppKeyModifiers::default()
997            },
998        ))]);
999
1000        let result = super::Tui::<Cli, _>::new()
1001            .with_runtime(runtime)
1002            .run_with_argv();
1003
1004        assert_eq!(result.expect("cancel should map to None"), None);
1005    }
1006
1007    #[test]
1008    fn tui_run_returns_clap_display_errors_without_printing() {
1009        #[derive(Debug, clap::Parser, PartialEq, Eq)]
1010        #[command(name = "tool", version = "1.2.3")]
1011        struct Cli;
1012
1013        let error = super::parse_result(Cli::try_parse_from(os_vec(&["tool", "--version"])))
1014            .expect_err("version display should be returned");
1015        assert!(
1016            matches!(error, TuiError::Clap(ref clap_error) if clap_error.kind() == ErrorKind::DisplayVersion)
1017        );
1018    }
1019
1020    #[test]
1021    fn tui_run_with_argv_reparses_selected_command_after_hiding_entrypoint() {
1022        #[derive(Debug, clap::Parser, PartialEq, Eq)]
1023        #[command(name = "tool")]
1024        enum Cli {
1025            Tui,
1026            Hello {
1027                #[arg(long, default_value = "world")]
1028                name: String,
1029            },
1030        }
1031
1032        let runtime = TestRuntime::with_events([AppEvent::Key(AppKeyEvent::new(
1033            AppKeyCode::Char('r'),
1034            AppKeyModifiers {
1035                control: true,
1036                ..AppKeyModifiers::default()
1037            },
1038        ))]);
1039
1040        let config = TuiConfig {
1041            start_command: Some("hello".to_string()),
1042            ..TuiConfig::default()
1043        };
1044
1045        let result = super::Tui::<Cli, _>::new()
1046            .hide_entrypoint("tui")
1047            .expect("entrypoint should exist")
1048            .with_config(config)
1049            .with_runtime(runtime)
1050            .run_with_argv();
1051
1052        let invocation = result
1053            .expect("typed run should succeed")
1054            .expect("run should produce an invocation");
1055        assert_eq!(
1056            invocation.command,
1057            Cli::Hello {
1058                name: "world".to_string()
1059            }
1060        );
1061        assert_eq!(invocation.argv, os_vec(&["tool", "hello"]));
1062    }
1063
1064    #[test]
1065    fn tui_run_with_argv_propagates_runtime_failures() {
1066        #[derive(Debug, clap::Parser, PartialEq, Eq)]
1067        #[command(name = "tool")]
1068        struct Cli;
1069
1070        #[derive(Debug)]
1071        struct FailingRuntime;
1072
1073        impl Runtime for FailingRuntime {
1074            type Backend = TestBackend;
1075
1076            fn init_terminal(&mut self) -> Result<Terminal<Self::Backend>, TuiError> {
1077                Err(std::io::Error::other("boom").into())
1078            }
1079
1080            fn restore_terminal(&mut self, _terminal: &mut Terminal<Self::Backend>) {}
1081
1082            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, TuiError> {
1083                unreachable!("terminal initialization should fail first")
1084            }
1085
1086            fn read_event(&mut self) -> Result<AppEvent, TuiError> {
1087                unreachable!("terminal initialization should fail first")
1088            }
1089
1090            fn copy_to_clipboard(&mut self, _text: &str) -> Result<(), String> {
1091                unreachable!("terminal initialization should fail first")
1092            }
1093        }
1094
1095        let error = super::Tui::<Cli, _>::new()
1096            .with_runtime(FailingRuntime)
1097            .run_with_argv()
1098            .expect_err("runtime failure should propagate");
1099
1100        assert!(matches!(error, TuiError::Terminal(_)));
1101    }
1102
1103    #[test]
1104    fn help_style_invalid_run_toast_does_not_show_about_text() {
1105        let command = Command::new("tool")
1106            .about("Run the selected tool")
1107            .arg_required_else_help(true)
1108            .arg(Arg::new("path").required(true));
1109        let mut runtime = TestRuntime::with_events([]);
1110        let mut session = terminal_session(&mut runtime);
1111        let mut state = app_state_from_command(&command);
1112
1113        let outcome = handle_effect(Effect::Run(os_vec(&["tool"])), &mut state, &mut session);
1114
1115        assert!(matches!(outcome, ActionOutcome::Continue));
1116        let toast = state.notifications.toast.as_ref().expect("toast");
1117        assert!(toast.is_error);
1118        assert_eq!(toast.message, "Missing required argument: path");
1119        assert!(!toast.message.contains("Run the selected tool"));
1120    }
1121
1122    #[test]
1123    fn resize_event_requests_redraw() {
1124        let mut runtime = TestRuntime::with_events([]);
1125        let mut session = terminal_session(&mut runtime);
1126        let mut state = app_state();
1127
1128        let outcome = handle_app_event(
1129            &AppEvent::Resize {
1130                width: 120,
1131                height: 40,
1132            },
1133            &mut state,
1134            &FrameSnapshot::default(),
1135            &TuiConfig::default(),
1136            &mut session,
1137        );
1138
1139        assert!(matches!(
1140            outcome,
1141            EventOutcome::Continue { needs_redraw: true }
1142        ));
1143    }
1144
1145    #[test]
1146    fn paste_event_updates_focused_form_field() {
1147        let command = Command::new("tool").arg(Arg::new("path").long("path"));
1148        let mut runtime = TestRuntime::with_events([]);
1149        let mut session = terminal_session(&mut runtime);
1150        let mut state = app_state_from_command(&command);
1151        state.ui.focus_form();
1152
1153        let outcome = handle_app_event(
1154            &AppEvent::Paste("/tmp/foo".to_string()),
1155            &mut state,
1156            &FrameSnapshot::default(),
1157            &TuiConfig::default(),
1158            &mut session,
1159        );
1160
1161        assert!(matches!(
1162            outcome,
1163            EventOutcome::Continue { needs_redraw: true }
1164        ));
1165        let form = state.domain.current_form().expect("form");
1166        let arg = state.domain.arg_for_input("path").expect("path arg");
1167        assert_eq!(
1168            form.compatibility_value(arg),
1169            Some(crate::input::ArgValue::Text("/tmp/foo".to_string()))
1170        );
1171        let derived = crate::pipeline::derive(&state);
1172        assert_eq!(
1173            derived.authoritative_argv,
1174            vec![
1175                "tool".to_string(),
1176                "--path".to_string(),
1177                "/tmp/foo".to_string(),
1178            ]
1179        );
1180        assert!(derived.validation.is_valid);
1181    }
1182
1183    #[test]
1184    fn paste_event_updates_search_query_when_search_is_focused() {
1185        let mut runtime = TestRuntime::with_events([]);
1186        let mut session = terminal_session(&mut runtime);
1187        let mut state = app_state();
1188        state.ui.focus_search();
1189
1190        let outcome = handle_app_event(
1191            &AppEvent::Paste("build".to_string()),
1192            &mut state,
1193            &FrameSnapshot::default(),
1194            &TuiConfig::default(),
1195            &mut session,
1196        );
1197
1198        assert!(matches!(
1199            outcome,
1200            EventOutcome::Continue { needs_redraw: true }
1201        ));
1202        assert_eq!(state.ui.search_query, "build");
1203    }
1204
1205    #[test]
1206    fn toast_timeout_behavior_is_unchanged() {
1207        let mut state = app_state();
1208        state.notifications.show_toast(
1209            "Copied command to clipboard",
1210            Duration::from_millis(250),
1211            false,
1212        );
1213
1214        let timeout = redraw_timeout(&state);
1215
1216        assert!(timeout > Duration::ZERO);
1217        assert!(timeout <= Duration::from_millis(250));
1218    }
1219
1220    #[test]
1221    fn expired_toast_clears_during_continuous_key_input() {
1222        let mut runtime = TestRuntime::with_events([]);
1223        let mut session = terminal_session(&mut runtime);
1224        let mut state = app_state();
1225        state.notifications.toast = Some(Toast {
1226            message: "Copied command to clipboard".to_string(),
1227            expires_at: Instant::now()
1228                .checked_sub(Duration::from_millis(1))
1229                .expect("duration should be representable"),
1230            is_error: false,
1231        });
1232
1233        let outcome = handle_app_event(
1234            &AppEvent::Key(AppKeyEvent::new(
1235                AppKeyCode::Char('x'),
1236                AppKeyModifiers::default(),
1237            )),
1238            &mut state,
1239            &FrameSnapshot::default(),
1240            &TuiConfig::default(),
1241            &mut session,
1242        );
1243
1244        assert!(matches!(
1245            outcome,
1246            EventOutcome::Continue { needs_redraw: true }
1247        ));
1248        assert!(state.notifications.toast.is_none());
1249    }
1250}