From d79374a8541fdf75c89292cc78da37b8481b9964 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Mon, 14 Sep 2026 13:25:18 -0500 Subject: [PATCH] fix(hush): clear no-TTY error for the interactive TUI instead of crossterm's Device-not-configured exit --- CHANGELOG.md | 4 ++++ bws-tui/src/tui/terminal.rs | 17 +++++++++++++++-- bws-tui/src/tui/tests.rs | 9 +++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8965a0..e47eacb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## [Unreleased] +### Fixed + +- hush: TUI now reports a clear "no TTY" error with subcommand guidance instead of crossterm's cryptic "Device not configured" failure (#57) + ### Added - agent-native hush: exec env injection, scoped get, list --json, audit log (#54) [#54] diff --git a/bws-tui/src/tui/terminal.rs b/bws-tui/src/tui/terminal.rs index 626e042..fca9ccd 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 { @@ -50,6 +50,13 @@ impl Drop for TerminalGuard { } 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 +67,12 @@ 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 +} + 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..f0be50b 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] @@ -65,3 +66,11 @@ fn terminal_cleanup_reports_every_failure() { assert!(error.contains("raw failed")); assert!(error.contains("screen failed")); } + +#[test] +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)); +}