Skip to content

Commit bbc95fa

Browse files
Vangallewebhive
authored andcommitted
fix(onboard): make tmux paste safe for text prompts (zeroclaw-labs#4106)
1 parent 9441067 commit bbc95fa

6 files changed

Lines changed: 397 additions & 29 deletions

File tree

src/cli_input.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
use anyhow::{bail, Result};
2+
use std::io::{BufRead, Write};
3+
4+
#[derive(Debug, Clone, Default)]
5+
pub struct Input {
6+
prompt: String,
7+
default: Option<String>,
8+
allow_empty: bool,
9+
}
10+
11+
impl Input {
12+
#[must_use]
13+
pub fn new() -> Self {
14+
Self {
15+
prompt: String::new(),
16+
default: None,
17+
allow_empty: false,
18+
}
19+
}
20+
21+
#[must_use]
22+
pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
23+
self.prompt = prompt.into();
24+
self
25+
}
26+
27+
#[must_use]
28+
pub fn allow_empty(mut self, val: bool) -> Self {
29+
self.allow_empty = val;
30+
self
31+
}
32+
33+
#[must_use]
34+
pub fn default<S: Into<String>>(mut self, value: S) -> Self {
35+
self.default = Some(value.into());
36+
self
37+
}
38+
39+
pub fn interact_text(self) -> Result<String> {
40+
let stdin = std::io::stdin();
41+
let stdout = std::io::stdout();
42+
self.interact_text_with_io(stdin.lock(), stdout.lock())
43+
}
44+
45+
fn interact_text_with_io<R: BufRead, W: Write>(
46+
self,
47+
mut reader: R,
48+
mut writer: W,
49+
) -> Result<String> {
50+
loop {
51+
write!(writer, "{}", self.render_prompt())?;
52+
writer.flush()?;
53+
54+
let mut line = String::new();
55+
let bytes_read = reader.read_line(&mut line)?;
56+
if bytes_read == 0 {
57+
bail!("No input received from stdin");
58+
}
59+
60+
let trimmed = trim_trailing_line_ending(&line);
61+
if trimmed.is_empty() {
62+
if let Some(default) = &self.default {
63+
return Ok(default.clone());
64+
}
65+
if self.allow_empty {
66+
return Ok(String::new());
67+
}
68+
writeln!(writer, "Input cannot be empty.")?;
69+
continue;
70+
}
71+
72+
return Ok(trimmed.to_string());
73+
}
74+
}
75+
76+
fn render_prompt(&self) -> String {
77+
match &self.default {
78+
Some(default) => format!("{} [{}]: ", self.prompt, default),
79+
None => format!("{}: ", self.prompt),
80+
}
81+
}
82+
}
83+
84+
fn trim_trailing_line_ending(input: &str) -> &str {
85+
input.trim_end_matches(['\n', '\r'])
86+
}
87+
88+
#[cfg(test)]
89+
mod tests {
90+
use super::{trim_trailing_line_ending, Input};
91+
use anyhow::Result;
92+
use std::io::Cursor;
93+
94+
#[test]
95+
fn trim_trailing_line_ending_strips_newlines() {
96+
assert_eq!(trim_trailing_line_ending("value\n"), "value");
97+
assert_eq!(trim_trailing_line_ending("value\r\n"), "value");
98+
assert_eq!(trim_trailing_line_ending("value\r"), "value");
99+
assert_eq!(trim_trailing_line_ending("value"), "value");
100+
}
101+
102+
#[test]
103+
fn interact_text_returns_typed_value_without_newline() -> Result<()> {
104+
let input = Input::new().with_prompt("Prompt");
105+
let mut output = Vec::new();
106+
107+
let value = input.interact_text_with_io(Cursor::new(b"typed-value\n"), &mut output)?;
108+
109+
assert_eq!(value, "typed-value");
110+
assert_eq!(String::from_utf8(output)?, "Prompt: ");
111+
Ok(())
112+
}
113+
114+
#[test]
115+
fn interact_text_returns_default_for_blank_input() -> Result<()> {
116+
let input = Input::new().with_prompt("Prompt").default("fallback");
117+
let mut output = Vec::new();
118+
119+
let value = input.interact_text_with_io(Cursor::new(b"\n"), &mut output)?;
120+
121+
assert_eq!(value, "fallback");
122+
assert_eq!(String::from_utf8(output)?, "Prompt [fallback]: ");
123+
Ok(())
124+
}
125+
126+
#[test]
127+
fn interact_text_allows_empty_when_requested() -> Result<()> {
128+
let input = Input::new().with_prompt("Prompt").allow_empty(true);
129+
let mut output = Vec::new();
130+
131+
let value = input.interact_text_with_io(Cursor::new(b"\n"), &mut output)?;
132+
133+
assert_eq!(value, "");
134+
assert_eq!(String::from_utf8(output)?, "Prompt: ");
135+
Ok(())
136+
}
137+
138+
#[test]
139+
fn interact_text_reprompts_when_empty_is_not_allowed() -> Result<()> {
140+
let input = Input::new().with_prompt("Prompt");
141+
let mut output = Vec::new();
142+
143+
let value = input.interact_text_with_io(Cursor::new(b"\nsecond-try\n"), &mut output)?;
144+
145+
assert_eq!(value, "second-try");
146+
assert_eq!(
147+
String::from_utf8(output)?,
148+
"Prompt: Input cannot be empty.\nPrompt: "
149+
);
150+
Ok(())
151+
}
152+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub mod agent;
4242
pub(crate) mod approval;
4343
pub(crate) mod auth;
4444
pub mod channels;
45+
pub(crate) mod cli_input;
4546
pub mod commands;
4647
pub mod config;
4748
pub(crate) mod cost;

src/main.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
use anyhow::{bail, Context, Result};
3737
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
38-
use dialoguer::{Input, Password};
38+
use dialoguer::Password;
3939
use serde::{Deserialize, Serialize};
4040
use std::io::{IsTerminal, Write};
4141
use std::path::PathBuf;
@@ -75,6 +75,7 @@ mod agent;
7575
mod approval;
7676
mod auth;
7777
mod channels;
78+
mod cli_input;
7879
mod commands;
7980
mod rag {
8081
pub use zeroclaw::rag::*;
@@ -1805,7 +1806,9 @@ fn read_auth_input(prompt: &str) -> Result<String> {
18051806
}
18061807

18071808
fn read_plain_input(prompt: &str) -> Result<String> {
1808-
let input: String = Input::new().with_prompt(prompt).interact_text()?;
1809+
let input: String = cli_input::Input::new()
1810+
.with_prompt(prompt)
1811+
.interact_text()?;
18091812
Ok(input.trim().to_string())
18101813
}
18111814

0 commit comments

Comments
 (0)