From c0633d3ce35320ee0065652daf947a86ab013e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 22:56:15 +0000 Subject: [PATCH 1/3] Start sweep of cli/src/ui Assisted-by: Claude Code:claude-opus-5-5 From 7b4af142c6d3d9400c614a99c50454d177d13fe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 23:01:04 +0000 Subject: [PATCH 2/3] Inline prompt wrappers and fold the VT probes confirm() now builds its Ask inline: the ask() and confirm_waits() wrappers are gone, and read_answer() is folded into confirm_with(). Ask loses non_interactive_answer, which every caller set to default_yes, and Ask, confirm_with and the note consts become private. select_one no longer re-checks the process quiet flag: its only caller passes the same flags ui::init saw, and --json returns earlier. ui/mod.rs replaces the four stdout_vt/stderr_vt fns with one vt() pair, drops the env_var wrapper, and fixes the severity comment that pointed at a moved get.rs fn. status.rs inlines term_is_dumb. Tests: drop the tautological wording test, the setup-style test of the removed field, the read_answer test that repeated the confirm_with cases, and the duplicate nobody-waits test. The TTY-dependent confirm_waits test becomes yes_or_json_answers_the_ default. status.rs drops a duplicate finish_with case. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/ui/mod.rs | 90 ++++------ crates/socket-patch-cli/src/ui/prompt.rs | 207 +++++------------------ crates/socket-patch-cli/src/ui/status.rs | 17 +- 3 files changed, 79 insertions(+), 235 deletions(-) diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index 7ff7f4a75..e9171d986 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -1,10 +1,9 @@ //! Terminal UI: everything that decides *how* human output looks. //! -//! - [`StatusLine`]: the one self-rewriting progress line. +//! - [`StatusLine`]: the self-rewriting progress line. //! - [`confirm`], [`select_one`]: prompts. -//! - [`print_json`]: the one `--json` document writer. -//! - [`plural`], [`truncate`], [`sentence_case`]: text shaping. -//! - [`next_steps`]: the one "Next steps:" block (hosted and vendored). +//! - [`print_json`]: the `--json` document writer. +//! - `text`: wording and shaping shared by several commands. //! - [`color_enabled`], [`paint`], [`severity`], [`pad`]: color policy and //! ANSI-aware column alignment. //! - [`init`] / [`quiet`]: the process-wide `--silent`/`--json` switch, @@ -46,7 +45,7 @@ pub(crate) fn global_scope_arg(common: &GlobalArgs) -> Option { /// (leading space, empty for a default run): `--cwd`, `--manifest-path`, /// `-g` / `--global-prefix` and `--ecosystems`, each only when it differs /// from its default. A remedy a command prints repeats them, so running it -/// verbatim acts on the tree the command just reported on (#464, #1219). +/// verbatim acts on the tree the command just reported on. pub(crate) fn scope_args(common: &GlobalArgs) -> String { let mut out = String::new(); if common.cwd != std::path::Path::new(".") { @@ -69,7 +68,6 @@ pub(crate) fn scope_args(common: &GlobalArgs) -> String { } /// Where a physical copy lives, relative to `cwd` when it is inside it. -/// Shared by `apply` and `rollback`. pub(crate) fn display_copy_path(package_path: &str, cwd: &std::path::Path) -> String { let path = std::path::Path::new(package_path); let canonical = std::fs::canonicalize(path).ok(); @@ -118,45 +116,25 @@ pub(crate) fn quiet() -> bool { socket_patch_core::utils::notice::is_quiet() } -/// Whether the Windows console behind stdout and stderr accepted VT -/// (escape-sequence) processing, probed once. console's color probe is -/// what switches VT on, so its answer is exactly "escapes will render". -/// `false` for a stream that is not a console (it is then not a terminal -/// either, and escapes in a pipe are the reader's business). -#[cfg(windows)] -fn vt_probe() -> (bool, bool) { - static VT: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new(); - *VT.get_or_init(|| { - ( - console::Term::stdout().features().colors_supported(), - console::Term::stderr().features().colors_supported(), - ) - }) -} - -/// Whether escape sequences written to a terminal on stdout render: on -/// Windows, whether VT processing could be enabled ([`vt_probe`]). -#[cfg(windows)] -fn stdout_vt() -> bool { - vt_probe().0 -} - -/// [`stdout_vt`] for stderr (also gates the live [`StatusLine`]). -#[cfg(windows)] -pub(crate) fn stderr_vt() -> bool { - vt_probe().1 -} - -/// Unix terminals always render escape sequences. -#[cfg(not(windows))] -fn stdout_vt() -> bool { - true -} - -/// Unix terminals always render escape sequences. -#[cfg(not(windows))] -pub(crate) fn stderr_vt() -> bool { - true +/// Whether escape sequences written to a terminal on (stdout, stderr) +/// render. Unix terminals always do. On Windows: whether the console +/// accepted VT processing, probed once; console's color probe is what +/// switches VT on, so its answer is exactly "escapes will render". `false` +/// for a stream that is not a console (it is then not a terminal either, +/// and escapes in a pipe are the reader's business). +pub(crate) fn vt() -> (bool, bool) { + #[cfg(windows)] + { + static VT: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new(); + return *VT.get_or_init(|| { + ( + console::Term::stdout().features().colors_supported(), + console::Term::stderr().features().colors_supported(), + ) + }); + } + #[cfg(not(windows))] + (true, true) } /// The color policy, pure over its inputs: @@ -180,22 +158,18 @@ pub(crate) fn color_enabled(is_tty: bool, env: impl Fn(&str) -> Option) set("TERM").is_none_or(|v| v != "dumb") } -fn env_var(k: &str) -> Option { - std::env::var(k).ok() -} - /// [`color_enabled`] for stdout; a terminal must also render escapes -/// ([`stdout_vt`]). +/// ([`vt`]). pub(crate) fn stdout_color() -> bool { let tty = std::io::stdout().is_terminal(); - color_enabled(tty, env_var) && (!tty || stdout_vt()) + color_enabled(tty, |k| std::env::var(k).ok()) && (!tty || vt().0) } /// [`color_enabled`] for stderr; a terminal must also render escapes -/// ([`stderr_vt`]). +/// ([`vt`]). pub(crate) fn stderr_color() -> bool { let tty = std::io::stderr().is_terminal(); - color_enabled(tty, env_var) && (!tty || stderr_vt()) + color_enabled(tty, |k| std::env::var(k).ok()) && (!tty || vt().1) } /// Columns of the terminal on stderr (status lines, prompts): its size, @@ -215,7 +189,13 @@ fn width_of(term: &console::Term) -> usize { term.size_checked() .map(|(_, cols)| cols as usize) .filter(|&c| c > 0) - .or_else(|| env_var("COLUMNS")?.parse().ok().filter(|&c| c > 0)) + .or_else(|| { + std::env::var("COLUMNS") + .ok()? + .parse() + .ok() + .filter(|&c| c > 0) + }) .unwrap_or(80) } @@ -235,7 +215,7 @@ pub fn severity(s: &str, on: bool) -> String { let code = match s.to_lowercase().as_str() { "critical" => "91", "high" => "31", - // GHSA emits `moderate`; same tier as medium (see get.rs severity_rank). + // GHSA emits `moderate`; same tier as medium (core `ranking::severity_order`). "medium" | "moderate" => "33", "low" => "36", _ => return s.to_string(), diff --git a/crates/socket-patch-cli/src/ui/prompt.rs b/crates/socket-patch-cli/src/ui/prompt.rs index 0eee32183..0dea7257e 100644 --- a/crates/socket-patch-cli/src/ui/prompt.rs +++ b/crates/socket-patch-cli/src/ui/prompt.rs @@ -5,15 +5,13 @@ use std::io::{self, BufRead, IsTerminal, Write}; use crate::args::GlobalArgs; -/// The one note printed (unless `--silent`) when a prompt is answered -/// automatically because stdin is not a terminal. -pub(crate) const NON_INTERACTIVE_PROCEED: &str = - "Non-interactive mode detected, proceeding automatically."; -/// Same, for a prompt whose automatic answer is "no". -pub(crate) const NON_INTERACTIVE_DECLINE: &str = - "Non-interactive mode detected, declining by default."; +/// Printed (unless `--silent`) when a prompt is answered automatically +/// because stdin is not a terminal. +const NON_INTERACTIVE_PROCEED: &str = "Non-interactive mode detected, proceeding automatically."; +/// Same, for a prompt whose default is "no". +const NON_INTERACTIVE_DECLINE: &str = "Non-interactive mode detected, declining by default."; /// Same, for [`select_one`], which takes the first option. -pub(crate) const NON_INTERACTIVE_SELECT_FIRST: &str = +const NON_INTERACTIVE_SELECT_FIRST: &str = "Non-interactive mode detected, selecting the first option."; /// Ask a yes/no question on stderr. Returns the answer. @@ -29,42 +27,27 @@ pub(crate) fn confirm(prompt: &str, default_yes: bool, common: &GlobalArgs) -> b if common.yes || common.json { return default_yes; } - ask( - prompt, - Ask { - default_yes, - non_interactive_answer: default_yes, - // The same question [`confirm_waits`] answers — asked through - // it, so the two cannot drift. - interactive: confirm_waits(common), - silent: common.silent, - }, - ) -} - -/// Whether [`confirm`] would stop and wait for a person to answer. -pub(crate) fn confirm_waits(common: &GlobalArgs) -> bool { - !(common.yes || common.json) && io::stdin().is_terminal() + let ask = Ask { + default_yes, + interactive: io::stdin().is_terminal(), + silent: common.silent, + }; + if ask.interactive { + discard_typeahead(); + } + confirm_with(&mut io::stdin().lock(), &mut io::stderr(), prompt, ask) } /// How a yes/no question is answered (see [`confirm_with`]). #[derive(Clone, Copy, Debug)] -pub(crate) struct Ask { - /// The answer to an empty line; also picks the `[Y/n]`/`[y/N]` hint. - pub default_yes: bool, - /// The answer when nobody can be asked (`interactive == false`). - pub non_interactive_answer: bool, +struct Ask { + /// The answer to an empty line or when nobody can be asked; also + /// picks the `[Y/n]`/`[y/N]` hint. + default_yes: bool, /// Whether stdin is a terminal a person is typing into. - pub interactive: bool, + interactive: bool, /// `--silent`: suppress the non-interactive note. - pub silent: bool, -} - -fn ask(prompt: &str, ask: Ask) -> bool { - if ask.interactive { - discard_typeahead(); - } - confirm_with(&mut io::stdin().lock(), &mut io::stderr(), prompt, ask) + silent: bool, } /// Drop keystrokes typed before the prompt appeared, so an Enter pressed @@ -90,36 +73,21 @@ fn discard_typeahead() { /// The testable core of [`confirm`]: reads one line from `input`, writes /// the prompt (and any note) to `out`. -pub(crate) fn confirm_with( - input: &mut impl BufRead, - out: &mut impl Write, - prompt: &str, - ask: Ask, -) -> bool { +fn confirm_with(input: &mut impl BufRead, out: &mut impl Write, prompt: &str, ask: Ask) -> bool { if !ask.interactive { if !ask.silent { - let note = if ask.non_interactive_answer { + let note = if ask.default_yes { NON_INTERACTIVE_PROCEED } else { NON_INTERACTIVE_DECLINE }; let _ = writeln!(out, "{note}"); } - return ask.non_interactive_answer; + return ask.default_yes; } let hint = if ask.default_yes { "[Y/n]" } else { "[y/N]" }; let _ = write!(out, "{prompt} {hint} "); let _ = out.flush(); - read_answer(input, out).unwrap_or(ask.default_yes) -} - -/// The answer-reading core of [`confirm_with`]: reads one line from -/// `input` and maps it to `Some(true)` for `y`/`yes` (any case, whitespace -/// ignored), `None` for an empty line (the caller's default), and -/// `Some(false)` for any other answer, end of input (Ctrl-D) or unreadable -/// input (non-UTF-8, an I/O error). Ends the prompt line on `out` when the -/// terminal did not echo a newline. -fn read_answer(input: &mut impl BufRead, out: &mut impl Write) -> Option { // Read raw bytes: `read_line` rolls its buffer back on invalid UTF-8, // which would hide the newline the terminal already echoed. let mut buf = Vec::new(); @@ -129,24 +97,17 @@ fn read_answer(input: &mut impl BufRead, out: &mut impl Write) -> Option { if !buf.ends_with(b"\n") { let _ = writeln!(out); } - match read { - // EOF: the user hit Ctrl-D (or input is gone). Never take that as yes. - Ok(0) => Some(false), - Ok(_) => match String::from_utf8(buf) { - Ok(line) => { - let answer = line.trim().to_lowercase(); - if answer.is_empty() { - None - } else { - Some(answer == "y" || answer == "yes") - } - } - // Non-UTF-8 bytes (a Latin-1 paste): decline. - Err(_) => Some(false), - }, - // An I/O error: decline. - Err(_) => Some(false), - } + // End of input (Ctrl-D), an I/O error and non-UTF-8 bytes (a Latin-1 + // paste) all decline, even when the default is yes. + let Ok(1..) = read else { return false }; + let Ok(line) = String::from_utf8(buf) else { + return false; + }; + let answer = line.trim().to_lowercase(); + if answer.is_empty() { + return ask.default_yes; + } + answer == "y" || answer == "yes" } /// Error type for interactive selection. @@ -166,8 +127,7 @@ pub enum SelectError { /// - Empty `options`: `Err(Cancelled)` — there is nothing to select, and /// `Ok(0)` would hand callers an out-of-bounds index. /// - stdin not a terminal: the first option, with -/// [`NON_INTERACTIVE_SELECT_FIRST`] unless `--silent` or the process is -/// quiet ([`super::quiet`]). +/// [`NON_INTERACTIVE_SELECT_FIRST`] unless `--silent`. /// - Interactive: Esc/q/Ctrl-C cancel; the cursor is always restored. pub fn select_one( prompt: &str, @@ -181,7 +141,7 @@ pub fn select_one( return Err(SelectError::Cancelled); } if !io::stdin().is_terminal() { - if !common.silent && !super::quiet() { + if !common.silent { eprintln!("{NON_INTERACTIVE_SELECT_FIRST}"); } return Ok(0); @@ -314,22 +274,6 @@ impl Drop for CursorGuard { mod tests { use super::*; - #[test] - fn non_interactive_notes_share_one_wording() { - assert_eq!( - NON_INTERACTIVE_PROCEED, - "Non-interactive mode detected, proceeding automatically." - ); - assert_eq!( - NON_INTERACTIVE_DECLINE, - "Non-interactive mode detected, declining by default." - ); - assert_eq!( - NON_INTERACTIVE_SELECT_FIRST, - "Non-interactive mode detected, selecting the first option." - ); - } - #[test] fn fit_menu_keeps_every_row_on_one_terminal_line() { let prompt = "Multiple patches available for pkg:npm/ws@7.4.5. Select one:"; @@ -358,7 +302,6 @@ mod tests { fn at_tty(default_yes: bool) -> Ask { Ask { default_yes, - non_interactive_answer: default_yes, interactive: true, silent: false, } @@ -426,7 +369,7 @@ mod tests { } #[test] - fn non_interactive_returns_the_non_interactive_answer_with_note() { + fn non_interactive_answers_the_default_with_note() { let ask = Ask { interactive: false, ..at_tty(true) @@ -454,47 +397,6 @@ mod tests { assert_eq!(run(b"", ask), (true, String::new())); } - #[test] - fn setup_style_default_no_but_proceed_when_non_interactive() { - let setup = Ask { - default_yes: false, - non_interactive_answer: true, - interactive: true, - silent: false, - }; - let (answer, _) = run( - b"", - Ask { - interactive: false, - ..setup - }, - ); - assert!(answer, "nobody to ask: proceed"); - assert_eq!( - run(b"\n", setup), - (false, "Apply 1 patch? [y/N] ".into()), - "at a terminal an empty answer means no" - ); - } - - #[test] - fn read_answer_maps_empty_to_none_and_eof_to_decline() { - let read = |input: &[u8]| { - let mut out = Vec::new(); - let answer = read_answer(&mut &input[..], &mut out); - (answer, String::from_utf8(out).unwrap()) - }; - assert_eq!(read(b"\n"), (None, String::new())); - assert_eq!(read(b" \n"), (None, String::new())); - assert_eq!(read(b"Yes\n"), (Some(true), String::new())); - assert_eq!(read(b" y \n"), (Some(true), String::new())); - assert_eq!(read(b"no\n"), (Some(false), String::new())); - assert_eq!(read(b"\xE9\n"), (Some(false), String::new())); - // EOF declines (never `None`, which a default-yes caller would - // turn into yes) and ends the prompt line. - assert_eq!(read(b""), (Some(false), "\n".into())); - } - #[test] fn select_one_json_mode_requires_explicit_selection() { let json = GlobalArgs { @@ -521,22 +423,14 @@ mod tests { )); } - /// `confirm_waits` must never say "no wait" for a case `confirm` would - /// stop on. Over - /// the whole `{yes, json}` cube with this process's stdin (a pipe - /// under the test harness, so never a terminal), it says no wait — - /// and `confirm` indeed answers from its default without reading a - /// byte, whatever is on stdin. #[test] - fn confirm_waits_agrees_with_confirm_over_the_flag_cube() { - for (yes, json) in [(false, false), (true, false), (false, true), (true, true)] { + fn yes_or_json_answers_the_default() { + for (yes, json) in [(true, false), (false, true), (true, true)] { let common = GlobalArgs { yes, json, - silent: true, ..GlobalArgs::default() }; - assert!(!confirm_waits(&common), "yes={yes} json={json}"); for default_yes in [true, false] { assert_eq!( confirm("go?", default_yes, &common), @@ -546,25 +440,4 @@ mod tests { } } } - - /// The other half of the same contract, on the one input `confirm` - /// takes that a test can vary: when nobody is waiting, the answer is - /// the caller's default, never whatever happens to be on stdin. - #[test] - fn a_prompt_nobody_waits_on_never_reads_the_answer() { - let mut out = Vec::new(); - let answered = confirm_with( - &mut &b"n\n"[..], - &mut out, - "go?", - Ask { - default_yes: true, - non_interactive_answer: true, - interactive: false, - silent: true, - }, - ); - assert!(answered); - assert!(out.is_empty(), "{}", String::from_utf8_lossy(&out)); - } } diff --git a/crates/socket-patch-cli/src/ui/status.rs b/crates/socket-patch-cli/src/ui/status.rs index ca833c60b..f8caca884 100644 --- a/crates/socket-patch-cli/src/ui/status.rs +++ b/crates/socket-patch-cli/src/ui/status.rs @@ -47,17 +47,13 @@ impl StatusLine { let human = !json && !silent; let live = human && io::stderr().is_terminal() - && super::stderr_vt() - && !term_is_dumb() + && super::vt().1 + && !std::env::var("TERM").is_ok_and(|t| t == "dumb") && !socket_patch_core::utils::env_compat::is_debug_enabled(); StatusLine::new(io::stderr(), live, human, super::stderr_width()) } } -fn term_is_dumb() -> bool { - std::env::var("TERM").is_ok_and(|t| t == "dumb") -} - impl StatusLine { /// `live`: draw transient lines. `report`: write `finish_with` lines. /// `width`: terminal columns. @@ -173,7 +169,7 @@ mod tests { } #[test] - fn user_report_regression_final_line_has_no_stale_tail() { + fn finish_with_after_a_longer_status_leaves_no_tail() { // The reported garble: "Found 7 patches for 1 packagesatch 7/7)". let mut s = live(); s.set("Querying API for patches... (batch 7/7)"); @@ -186,12 +182,7 @@ mod tests { } #[test] - fn shorter_after_longer_leaves_no_residue() { - let mut s = live(); - s.set("Scanning global packages..."); - s.finish_with("Found 2 packages"); - assert_eq!(render(bytes(&s).as_bytes()), vec!["Found 2 packages"]); - + fn shorter_set_after_longer_leaves_no_tail() { let mut s = live(); s.set("Querying API for patches... (batch 10/10)"); s.set("Short"); From ee261caddf0d669ba61d6e4b41b59caeae1d261c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 23:08:48 +0000 Subject: [PATCH 3/3] Keep select_one quiet guard and env_var helper Restores select_one's process-wide quiet check (a caller passing derived flags would otherwise print the note under --silent), keeps the env_var helper, and gives each platform its own vt() so the Windows body needs no return (clippy needless_return). Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/ui/mod.rs | 52 ++++++++++++------------ crates/socket-patch-cli/src/ui/prompt.rs | 5 ++- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index e9171d986..6496be701 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -116,24 +116,26 @@ pub(crate) fn quiet() -> bool { socket_patch_core::utils::notice::is_quiet() } -/// Whether escape sequences written to a terminal on (stdout, stderr) -/// render. Unix terminals always do. On Windows: whether the console -/// accepted VT processing, probed once; console's color probe is what -/// switches VT on, so its answer is exactly "escapes will render". `false` -/// for a stream that is not a console (it is then not a terminal either, -/// and escapes in a pipe are the reader's business). +/// Whether escape sequences written to a terminal render, as +/// `(stdout, stderr)`: whether the Windows console accepted VT processing, +/// probed once. console's color probe is what switches VT on, so its +/// answer is exactly "escapes will render". `false` for a stream that is +/// not a console (it is then not a terminal either, and escapes in a pipe +/// are the reader's business). +#[cfg(windows)] +pub(crate) fn vt() -> (bool, bool) { + static VT: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new(); + *VT.get_or_init(|| { + ( + console::Term::stdout().features().colors_supported(), + console::Term::stderr().features().colors_supported(), + ) + }) +} + +/// Unix terminals always render escape sequences. +#[cfg(not(windows))] pub(crate) fn vt() -> (bool, bool) { - #[cfg(windows)] - { - static VT: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new(); - return *VT.get_or_init(|| { - ( - console::Term::stdout().features().colors_supported(), - console::Term::stderr().features().colors_supported(), - ) - }); - } - #[cfg(not(windows))] (true, true) } @@ -158,18 +160,22 @@ pub(crate) fn color_enabled(is_tty: bool, env: impl Fn(&str) -> Option) set("TERM").is_none_or(|v| v != "dumb") } +fn env_var(k: &str) -> Option { + std::env::var(k).ok() +} + /// [`color_enabled`] for stdout; a terminal must also render escapes /// ([`vt`]). pub(crate) fn stdout_color() -> bool { let tty = std::io::stdout().is_terminal(); - color_enabled(tty, |k| std::env::var(k).ok()) && (!tty || vt().0) + color_enabled(tty, env_var) && (!tty || vt().0) } /// [`color_enabled`] for stderr; a terminal must also render escapes /// ([`vt`]). pub(crate) fn stderr_color() -> bool { let tty = std::io::stderr().is_terminal(); - color_enabled(tty, |k| std::env::var(k).ok()) && (!tty || vt().1) + color_enabled(tty, env_var) && (!tty || vt().1) } /// Columns of the terminal on stderr (status lines, prompts): its size, @@ -189,13 +195,7 @@ fn width_of(term: &console::Term) -> usize { term.size_checked() .map(|(_, cols)| cols as usize) .filter(|&c| c > 0) - .or_else(|| { - std::env::var("COLUMNS") - .ok()? - .parse() - .ok() - .filter(|&c| c > 0) - }) + .or_else(|| env_var("COLUMNS")?.parse().ok().filter(|&c| c > 0)) .unwrap_or(80) } diff --git a/crates/socket-patch-cli/src/ui/prompt.rs b/crates/socket-patch-cli/src/ui/prompt.rs index 0dea7257e..8ff5c978b 100644 --- a/crates/socket-patch-cli/src/ui/prompt.rs +++ b/crates/socket-patch-cli/src/ui/prompt.rs @@ -127,7 +127,8 @@ pub enum SelectError { /// - Empty `options`: `Err(Cancelled)` — there is nothing to select, and /// `Ok(0)` would hand callers an out-of-bounds index. /// - stdin not a terminal: the first option, with -/// [`NON_INTERACTIVE_SELECT_FIRST`] unless `--silent`. +/// [`NON_INTERACTIVE_SELECT_FIRST`] unless `--silent` or the process is +/// quiet ([`super::quiet`]). /// - Interactive: Esc/q/Ctrl-C cancel; the cursor is always restored. pub fn select_one( prompt: &str, @@ -141,7 +142,7 @@ pub fn select_one( return Err(SelectError::Cancelled); } if !io::stdin().is_terminal() { - if !common.silent { + if !common.silent && !super::quiet() { eprintln!("{NON_INTERACTIVE_SELECT_FIRST}"); } return Ok(0);