Skip to content

Commit 242c09c

Browse files
authored
feat(chat): no-interactive mode (#878)
* feat(chat): no-interactive mode Related: #808 - Prints to STDOUT instead. - Does not print non-response text, e.g. - Runs tools as long as --accept-all is also provided. Otherwise it will error. You can bypass this by asking to not run tools in the prompt. - Continues to work with pipes, e.g. - echo "give a cool tip" | q chat --no-interactive -> outputs a cool tip - echo "give a cool tip" | q chat --no-interactive "write hello world." -> outputs hello world code and a cool tip - q chat --no-interactive -> does nothing and immediately returns Does NOT print without any formatting/styling codes or labels. That will require a larger refactor in a separate PR. * fix arg name in test * format
1 parent 36f1ae1 commit 242c09c

File tree

2 files changed

+61
-21
lines changed

2 files changed

+61
-21
lines changed

crates/q_cli/src/cli/chat/mod.rs

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,12 @@ const HELP_TEXT: &str = color_print::cstr! {"
129129
130130
"};
131131

132-
pub async fn chat(input: Option<String>, accept_all: bool, profile: Option<String>) -> Result<ExitCode> {
132+
pub async fn chat(
133+
input: Option<String>,
134+
no_interactive: bool,
135+
accept_all: bool,
136+
profile: Option<String>,
137+
) -> Result<ExitCode> {
133138
if !fig_util::system_info::in_cloudshell() && !fig_auth::is_logged_in().await {
134139
bail!(
135140
"You are not logged in, please log in with {}",
@@ -140,19 +145,24 @@ pub async fn chat(input: Option<String>, accept_all: bool, profile: Option<Strin
140145
region_check("chat")?;
141146

142147
let ctx = Context::new();
143-
let output = std::io::stderr();
144148

145149
let stdin = std::io::stdin();
146-
let interactive = stdin.is_terminal();
147-
let input = if !interactive {
148-
// append to input string any extra info that was provided.
150+
// no_interactive flag or part of a pipe
151+
let interactive = !no_interactive && stdin.is_terminal();
152+
let input = if !interactive && !stdin.is_terminal() {
153+
// append to input string any extra info that was provided, e.g. via pipe
149154
let mut input = input.unwrap_or_default();
150155
stdin.lock().read_to_string(&mut input)?;
151156
Some(input)
152157
} else {
153158
input
154159
};
155160

161+
let output: Box<dyn Write> = match interactive {
162+
true => Box::new(std::io::stderr()),
163+
false => Box::new(std::io::stdout()),
164+
};
165+
156166
let client = match ctx.env().get("Q_MOCK_CHAT_RESPONSE") {
157167
Ok(json) => create_stream(serde_json::from_str(std::fs::read_to_string(json)?.as_str())?),
158168
_ => StreamingClient::new().await?,
@@ -224,6 +234,10 @@ pub enum ChatError {
224234
Custom(Cow<'static, str>),
225235
#[error("interrupted")]
226236
Interrupted { tool_uses: Option<Vec<QueuedTool>> },
237+
#[error(
238+
"Tool approval required but --no-interactive was specified. Use --accept-all to automatically approve tools."
239+
)]
240+
NonInteractiveToolApproval,
227241
}
228242

229243
pub struct ChatContext<W: Write> {
@@ -358,14 +372,16 @@ where
358372
});
359373

360374
if let Some(user_input) = self.initial_input.take() {
361-
execute!(
362-
self.output,
363-
style::SetForegroundColor(Color::Magenta),
364-
style::Print("> "),
365-
style::SetAttribute(Attribute::Reset),
366-
style::Print(&user_input),
367-
style::Print("\n")
368-
)?;
375+
if self.interactive {
376+
execute!(
377+
self.output,
378+
style::SetForegroundColor(Color::Magenta),
379+
style::Print("> "),
380+
style::SetAttribute(Attribute::Reset),
381+
style::Print(&user_input),
382+
style::Print("\n")
383+
)?;
384+
}
369385
next_state = Some(ChatState::HandleInput {
370386
input: user_input,
371387
tool_uses: None,
@@ -381,7 +397,13 @@ where
381397
ChatState::PromptUser {
382398
tool_uses,
383399
skip_printing_tools,
384-
} => self.prompt_user(tool_uses, skip_printing_tools).await,
400+
} => {
401+
// Cannot prompt in non-interactive mode no matter what.
402+
if !self.interactive {
403+
return Ok(());
404+
}
405+
self.prompt_user(tool_uses, skip_printing_tools).await
406+
},
385407
ChatState::HandleInput { input, tool_uses } => self.handle_input(input, tool_uses).await,
386408
ChatState::ExecuteTools(tool_uses) => {
387409
let tool_uses_clone = tool_uses.clone();
@@ -493,9 +515,7 @@ where
493515
mut tool_uses: Option<Vec<QueuedTool>>,
494516
skip_printing_tools: bool,
495517
) -> Result<ChatState, ChatError> {
496-
if self.interactive {
497-
execute!(self.output, cursor::Show)?;
498-
}
518+
execute!(self.output, cursor::Show)?;
499519
let tool_uses = tool_uses.take().unwrap_or_default();
500520
if !tool_uses.is_empty() && !skip_printing_tools {
501521
self.print_tool_descriptions(&tool_uses).await?;
@@ -1332,15 +1352,16 @@ where
13321352

13331353
let skip_acceptance = self.accept_all || queued_tools.iter().all(|tool| !tool.1.requires_acceptance(&self.ctx));
13341354

1335-
match skip_acceptance {
1336-
true => {
1355+
match (skip_acceptance, self.interactive) {
1356+
(true, _) => {
13371357
self.print_tool_descriptions(&queued_tools).await?;
13381358
Ok(ChatState::ExecuteTools(queued_tools))
13391359
},
1340-
false => Ok(ChatState::PromptUser {
1360+
(false, true) => Ok(ChatState::PromptUser {
13411361
tool_uses: Some(queued_tools),
13421362
skip_printing_tools: false,
13431363
}),
1364+
(false, false) => Err(ChatError::NonInteractiveToolApproval),
13441365
}
13451366
}
13461367

crates/q_cli/src/cli/mod.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ pub enum CliRootCommands {
186186
/// them.
187187
#[arg(short, long)]
188188
accept_all: bool,
189+
/// Print the first response to STDOUT without interactive mode. This will fail if the
190+
/// prompt requests permissions to use a tool, unless --accept-all is also used.
191+
#[arg(long)]
192+
no_interactive: bool,
189193
/// The first question to ask
190194
input: Option<String>,
191195
/// Context profile to use
@@ -337,9 +341,10 @@ impl Cli {
337341
CliRootCommands::Dashboard => launch_dashboard(false).await,
338342
CliRootCommands::Chat {
339343
accept_all,
344+
no_interactive,
340345
input,
341346
profile,
342-
} => chat::chat(input, accept_all, profile).await,
347+
} => chat::chat(input, no_interactive, accept_all, profile).await,
343348
CliRootCommands::Inline(subcommand) => subcommand.execute(&cli_context).await,
344349
},
345350
// Root command
@@ -460,6 +465,7 @@ mod test {
460465
assert_eq!(Cli::parse_from([CLI_BINARY_NAME, "chat", "-vv"]), Cli {
461466
subcommand: Some(CliRootCommands::Chat {
462467
accept_all: false,
468+
no_interactive: false,
463469
input: None,
464470
profile: None,
465471
},),
@@ -589,6 +595,7 @@ mod test {
589595
fn test_chat_with_context_profile() {
590596
assert_parse!(["chat", "--profile", "my-profile"], CliRootCommands::Chat {
591597
accept_all: false,
598+
no_interactive: false,
592599
input: None,
593600
profile: Some("my-profile".to_string()),
594601
});
@@ -598,6 +605,7 @@ mod test {
598605
fn test_chat_with_context_profile_and_input() {
599606
assert_parse!(["chat", "--profile", "my-profile", "Hello"], CliRootCommands::Chat {
600607
accept_all: false,
608+
no_interactive: false,
601609
input: Some("Hello".to_string()),
602610
profile: Some("my-profile".to_string()),
603611
});
@@ -609,9 +617,20 @@ mod test {
609617
["chat", "--profile", "my-profile", "--accept-all"],
610618
CliRootCommands::Chat {
611619
accept_all: true,
620+
no_interactive: false,
612621
input: None,
613622
profile: Some("my-profile".to_string()),
614623
}
615624
);
616625
}
626+
627+
#[test]
628+
fn test_chat_with_no_interactive() {
629+
assert_parse!(["chat", "--no-interactive"], CliRootCommands::Chat {
630+
accept_all: false,
631+
no_interactive: true,
632+
input: None,
633+
profile: None,
634+
});
635+
}
617636
}

0 commit comments

Comments
 (0)