diff --git a/CHANGELOG.md b/CHANGELOG.md index 90438a3..d2214f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ ### Fixed +- hush: TUI now reports a clear "no TTY" error with subcommand guidance instead of crossterm's cryptic "Device not configured" failure (#57) - dependencies: refresh ip-address, brace-expansion, protobufjs, and the docs starter security lockfiles; retain protobufjs 7.x compatibility (#56) - remediate merged-#35 CodeRabbit findings (#36) [#35] - post-merge remediation for #33 (validator hardening, packaging hygiene, ask-codex metadata) (#34) [#33] diff --git a/bws-tui/src/tui/terminal.rs b/bws-tui/src/tui/terminal.rs index 626e042..6aaa7e4 100644 --- a/bws-tui/src/tui/terminal.rs +++ b/bws-tui/src/tui/terminal.rs @@ -1,11 +1,11 @@ use super::{events, App}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal}; -use std::io::{self, Write}; +use std::io::{self, IsTerminal, Write}; #[derive(Default)] struct TerminalGuard { @@ -49,7 +49,16 @@ impl Drop for TerminalGuard { } } +/// Reject headless sessions before changing terminal state, then run the TUI +/// and report event-loop failures together with terminal restoration failures. pub(super) fn run(app: &mut App) -> Result<()> { + if !is_interactive(io::stdin().is_terminal(), io::stdout().is_terminal()) { + bail!( + "hush's interactive TUI needs a terminal session (no TTY found). \ + Run `hush` from an interactive terminal, or use the script-friendly \ + subcommands: `hush list`, `hush get --key `, `hush exec --key -- `" + ); + } let mut guard = TerminalGuard::default(); guard.enable_raw()?; let mut output = io::stdout(); @@ -60,6 +69,14 @@ pub(super) fn run(app: &mut App) -> Result<()> { finish_terminal(event, raw, screen) } +/// The TUI needs either side attached to a terminal; crossterm opens /dev/tty +/// directly for input when stdin is piped, so stdout alone is enough. +pub(super) fn is_interactive(stdin_tty: bool, stdout_tty: bool) -> bool { + stdin_tty || stdout_tty +} + +/// Preserve every event-loop and restoration error rather than masking one +/// failure with a later cleanup failure. pub(super) fn finish_terminal( event: Result<()>, raw: Result<()>, diff --git a/bws-tui/src/tui/tests.rs b/bws-tui/src/tui/tests.rs index 65c8cb1..82d4e5d 100644 --- a/bws-tui/src/tui/tests.rs +++ b/bws-tui/src/tui/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::tui::terminal::is_interactive; use anyhow::anyhow; #[test] @@ -52,6 +53,15 @@ fn edit_fields_cycle_without_invalid_numeric_states() { assert_eq!(EditField::Key.previous(), EditField::Note); } +#[test] +/// Cover both one-sided terminal sessions and the fully headless rejection. +fn tui_gate_accepts_a_tty_on_either_side() { + assert!(is_interactive(true, false)); + assert!(is_interactive(false, true)); + assert!(is_interactive(true, true)); + assert!(!is_interactive(false, false)); +} + #[test] fn terminal_cleanup_reports_every_failure() { let error = terminal::finish_terminal(