Show and answer approvals in bxctl chat

Implemented-By: Grok 4.6
This commit is contained in:
2026-09-22 20:25:40 -07:00
parent 0a9df81fe4
commit f1f17a171f
5 changed files with 906 additions and 66 deletions
+48 -56
View File
@@ -5,9 +5,10 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use bxctl::admin::{self, AdminError};
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn};
use bxctl::chat::{Approvals, ChatError, OnPending, Printer, TurnIo, new_session_id, stream_turn};
use bxctl::cli::{self, ChatOptions, Command, USAGE};
use proto::{ErrorCode, SessionId, Timestamp, TurnDone, TurnEvent};
use bxctl::escape::escape_model_text;
use proto::{ErrorCode, SessionId, Timestamp, TurnDone};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -85,58 +86,39 @@ fn cmd_grants_check(admin: &Path) -> Result<bool, AdminError> {
admin::cmd_grants_check(admin, &mut out)
}
// A writer that records the first io error it hits, so the on_event closure (which cannot return a
// Result) does not lose a write failure. The error is checked after run_turn returns.
struct Sink<'a> {
out: &'a mut (dyn Write + 'static),
err: Option<std::io::Error>,
}
impl Sink<'_> {
fn event(&mut self, printer: &mut Printer, event: &TurnEvent) {
if self.err.is_none() {
self.err = printer.event(self.out, event).err();
}
}
}
// Runs one turn, streaming events through `printer` to `out`. Returns the turn outcome, or the
// first io error that occurred while writing.
fn stream_turn(
socket: &Path,
session: &SessionId,
text: &str,
resume: bool,
sink: &mut Sink,
printer: &mut Printer,
) -> Result<Result<TurnDone, ChatError>, std::io::Error> {
let outcome = run_turn(socket, session, text, resume, &mut |event| {
sink.event(printer, event)
});
// Flush any dimmed block left open before reporting the turn's result.
if sink.err.is_none() {
printer.end_reasoning(sink.out)?;
}
match sink.err.take() {
Some(e) => Err(e),
None => Ok(outcome),
fn on_pending(opts: &ChatOptions) -> OnPending {
if opts.json {
OnPending::EventOnly
} else if opts.say.is_some() {
OnPending::Show
} else {
OnPending::Ask
}
}
fn run(opts: &ChatOptions) -> ExitCode {
let stdin = std::io::stdin();
let mut input = BufReader::new(stdin.lock());
match &opts.say {
Some(text) => run_say(opts, text),
None => run_interactive(opts),
Some(text) => run_say(opts, text, &mut input),
None => run_interactive(opts, &mut input),
}
}
fn run_say(opts: &ChatOptions, text: &str) -> ExitCode {
fn run_say(opts: &ChatOptions, text: &str, input: &mut dyn BufRead) -> ExitCode {
let session = match &opts.session {
Some(s) => s.clone(),
None => new_session_id(),
};
let mut printer = Printer::new(opts.show_thinking, opts.json);
let code = run_turn_twice(opts, &session, text, opts.session.is_some(), &mut printer);
let code = run_turn_twice(
opts,
&session,
text,
opts.session.is_some(),
&mut printer,
input,
);
match code {
Ok(()) => ExitCode::SUCCESS,
Err(RunError::Io(e)) => {
@@ -157,14 +139,20 @@ fn run_turn_twice(
text: &str,
resume: bool,
printer: &mut Printer,
input: &mut dyn BufRead,
) -> Result<(), RunError> {
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let mut sink = Sink {
out: &mut handle,
err: None,
let approvals = Approvals {
admin_socket: &opts.admin_socket,
on_pending: on_pending(opts),
};
match stream_turn(&opts.socket, session, text, resume, &mut sink, printer) {
let mut io = TurnIo {
printer,
input,
out: &mut handle,
};
match stream_turn(&opts.socket, session, text, resume, &approvals, &mut io) {
Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?;
@@ -172,11 +160,12 @@ fn run_turn_twice(
}
Ok(Err(ChatError::Refused(w))) if w.code == ErrorCode::NoSuchSession => {
// The named session does not exist: retry once, creating it (resume=false).
let mut sink = Sink {
let mut io = TurnIo {
printer,
input,
out: &mut handle,
err: None,
};
match stream_turn(&opts.socket, session, text, false, &mut sink, printer) {
match stream_turn(&opts.socket, session, text, false, &approvals, &mut io) {
Err(e) => Err(RunError::Io(e)),
Ok(Ok(done)) => {
write_answer(opts, &done).map_err(RunError::Io)?;
@@ -189,11 +178,9 @@ fn run_turn_twice(
}
}
fn run_interactive(opts: &ChatOptions) -> ExitCode {
fn run_interactive(opts: &ChatOptions, reader: &mut dyn BufRead) -> ExitCode {
let mut printer = Printer::new(opts.show_thinking, opts.json);
let mut session = None;
let stdin = std::io::stdin();
let mut reader = BufReader::new(stdin.lock());
loop {
eprint!("> ");
let _ = std::io::stderr().flush();
@@ -222,17 +209,22 @@ fn run_interactive(opts: &ChatOptions) -> ExitCode {
}
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let mut sink = Sink {
let approvals = Approvals {
admin_socket: &opts.admin_socket,
on_pending: on_pending(opts),
};
let mut io = TurnIo {
printer: &mut printer,
input: reader,
out: &mut handle,
err: None,
};
match stream_turn(
&opts.socket,
this_session,
text,
resume,
&mut sink,
&mut printer,
&approvals,
&mut io,
) {
Err(e) => {
eprintln!("bxctl: {e}");
@@ -264,7 +256,7 @@ fn write_answer(opts: &ChatOptions, done: &TurnDone) -> std::io::Result<()> {
}
let stdout = std::io::stdout();
let mut out = stdout.lock();
out.write_all(done.content.as_bytes())?;
out.write_all(escape_model_text(&done.content).as_bytes())?;
out.write_all(b"\n")?;
out.flush()
}