From 39a8bb6d3c019b00c320a1d9e4eaefe05ab6edcb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 14:53:27 +0800 Subject: [PATCH 01/11] feat(cli): Codex-style terminal UI for the agent (a3s-code) A new `crates/code/cli` crate (binary `a3s-code`) built on the a3s-tui TEA framework. It drives an AgentSession via `session.stream()` and renders the AgentEvent stream as a live chat transcript: - streaming assistant text (StreamingMarkdown) + working spinner - tool-call lifecycle lines (ToolStart/ToolEnd) - HITL: ConfirmationRequired -> approve/deny modal -> confirm_tool_use - multi-line input, scrollback, slash commands (/clear, /exit), Ctrl+C quit The async bridge is a self-re-issuing "pump" command that drains the agent's mpsc event receiver into the synchronous TEA update loop one event at a time. Own `[workspace]` root with path deps on a3s-code-core (../core) and a3s-tui (../../tui), so it does not affect a3s-code's main workspace and a standalone clone of this repo keeps building unchanged. (a3s-tui must be published to crates.io before this can ship as a release artifact.) A headless `A3S_CODE_TUI_SMOKE=1` mode exercises the same stream/AgentEvent path without a TTY. Verified end-to-end against a real model (gpt-4o via the gateway): "what is 2 + 2?" -> streamed "2 + 2 equals 4." then End. --- cli/.gitignore | 1 + cli/Cargo.toml | 22 ++ cli/src/main.rs | 532 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 555 insertions(+) create mode 100644 cli/.gitignore create mode 100644 cli/Cargo.toml create mode 100644 cli/src/main.rs diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1 @@ +/target diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..3df82b0 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,22 @@ +# Standalone crate (own `[workspace]`) so it never gets pulled into a3s-code's +# workspace — the a3s-tui path dep only resolves inside the a3s monorepo, and +# this keeps a standalone clone of AI45Lab/Code building unchanged. +[package] +name = "a3s-code" +version = "0.1.0" +edition = "2021" +description = "Codex-style terminal UI for the A3S Code agent" +license = "MIT" +default-run = "a3s-code" + +[[bin]] +name = "a3s-code" +path = "src/main.rs" + +[dependencies] +a3s-code-core = { path = "../core" } +a3s-tui = { path = "../../tui" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +anyhow = "1" + +[workspace] diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..8350311 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,532 @@ +//! Codex-style terminal UI for the A3S Code agent. +//! +//! Built on the `a3s-tui` TEA framework: it drives an [`AgentSession`] via +//! `session.stream()` and renders the resulting [`AgentEvent`] stream as a live +//! chat transcript, mapping tool-confirmation events to an approve/deny modal. +//! +//! Streaming bridge: `session.stream()` yields a `tokio::mpsc` receiver. A +//! self-re-issuing "pump" command reads one event, turns it into a `Msg`, and +//! the update handler issues the next pump — feeding the async event stream into +//! the synchronous TEA update loop one event at a time. + +use std::sync::Arc; +use std::time::Duration; + +use a3s_code_core::{Agent, AgentEvent, AgentSession}; +use a3s_tui::cmd::{self, Cmd}; +use a3s_tui::components::modal::{Modal, ModalMsg}; +use a3s_tui::components::textarea::TextareaMsg; +use a3s_tui::components::viewport::ViewportMsg; +use a3s_tui::components::{Spinner, StatusBar, Textarea, Viewport}; +use a3s_tui::event::KeyEvent; +use a3s_tui::keymap::{KeyBinding, Keymap}; +use a3s_tui::layout::{Constraint, Layout}; +use a3s_tui::streaming::StreamingMarkdown; +use a3s_tui::style::{Color, Style}; +use a3s_tui::{Event, KeyCode, KeyModifiers, Model, ProgramBuilder}; +use tokio::sync::{mpsc, Mutex}; + +/// Shared, single-consumer receiver for the active agent run. Wrapped so the +/// pump command can own a clone; pumps run sequentially, so the mutex never +/// actually contends. +type SharedRx = Arc>>; + +#[derive(PartialEq)] +enum State { + Idle, + Streaming, + Awaiting, +} + +#[derive(Clone)] +#[allow(clippy::enum_variant_names)] +enum Action { + ScrollUp, + ScrollDown, + ScrollTop, + ScrollBottom, +} + +enum Msg { + Term(Event), + // Boxed: AgentEvent is large; keeps the Msg enum small. + Agent(Box), + Submit(String), + StreamStarted(SharedRx), + StreamEnded, + StreamError(String), + SpinnerTick, + ModalConfirm(usize), + ModalDismiss, + Resume, + Quit, +} + +impl From for Msg { + fn from(event: Event) -> Self { + match &event { + Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers, + }) if modifiers.contains(KeyModifiers::CONTROL) => Msg::Quit, + _ => Msg::Term(event), + } + } +} + +/// Read one event from the active run and turn it into a `Msg`. +fn pump(rx: SharedRx) -> Cmd { + cmd::cmd(move || async move { + let mut guard = rx.lock().await; + match guard.recv().await { + Some(event) => Msg::Agent(Box::new(event)), + None => Msg::StreamEnded, + } + }) +} + +fn spinner_tick() -> Cmd { + cmd::tick(Duration::from_millis(80), Msg::SpinnerTick) +} + +struct App { + session: Arc, + viewport: Viewport, + textarea: Textarea, + spinner: Spinner, + streaming: StreamingMarkdown, + state: State, + messages: Vec, + rx: Option, + modal: Option, + pending_tool: Option<(String, String)>, + width: u16, + height: u16, + keymap: Keymap, +} + +impl Model for App { + type Msg = Msg; + + fn init(&mut self) -> Option> { + let welcome = Style::new().fg(Color::BrightBlack).italic().render( + " A3S Code — type a message and press Enter.\n \ + Ctrl+C quit | PageUp/PageDown scroll | /clear reset | /exit quit\n", + ); + self.viewport.set_content(&welcome); + None + } + + fn update(&mut self, msg: Msg) -> Option> { + match msg { + Msg::Quit => return Some(cmd::quit()), + + Msg::Term(Event::Resize { width, height }) => { + self.width = width; + self.height = height; + self.viewport.resize(width, height.saturating_sub(7)); + self.streaming = StreamingMarkdown::new((width as usize).saturating_sub(2)); + self.rebuild_viewport(); + } + + Msg::Term(Event::Key(key)) => { + if self.state == State::Awaiting { + return self.handle_modal_key(&key); + } + if let Some(action) = self.keymap.resolve(&key) { + let m = match action { + Action::ScrollUp => ViewportMsg::PageUp, + Action::ScrollDown => ViewportMsg::PageDown, + Action::ScrollTop => ViewportMsg::Top, + Action::ScrollBottom => ViewportMsg::Bottom, + }; + self.viewport.update(m); + return None; + } + if self.state == State::Streaming { + return None; + } + if let Some(TextareaMsg::Submit(text)) = self.textarea.handle_key(&key) { + return Some(cmd::msg(Msg::Submit(text))); + } + } + + Msg::Submit(text) => return self.on_submit(text), + + Msg::StreamStarted(rx) => { + self.rx = Some(rx.clone()); + return Some(pump(rx)); + } + + Msg::StreamError(e) => { + self.push_line(&Style::new().fg(Color::Red).render(&format!(" error: {e}"))); + self.finish(); + } + + Msg::Agent(event) => return self.on_agent_event(*event), + + Msg::StreamEnded => { + if self.state == State::Streaming { + self.finalize_streaming(); + } + self.finish(); + } + + Msg::SpinnerTick => { + self.spinner.tick(); + if self.state == State::Streaming { + self.update_viewport_with_stream(); + return Some(spinner_tick()); + } + } + + Msg::ModalConfirm(idx) => { + self.modal = None; + let approved = idx == 0; + self.state = State::Streaming; + if let Some((tool_id, name)) = self.pending_tool.take() { + let verdict = if approved { "allowed" } else { "denied" }; + let color = if approved { Color::Yellow } else { Color::Red }; + self.push_line( + &Style::new() + .fg(color) + .render(&format!(" [{verdict}] {name}")), + ); + let session = self.session.clone(); + return Some(cmd::batch(vec![ + cmd::cmd(move || async move { + let _ = session.confirm_tool_use(&tool_id, approved, None).await; + Msg::Resume + }), + spinner_tick(), + ])); + } + } + + Msg::ModalDismiss => return Some(cmd::msg(Msg::ModalConfirm(1))), + + Msg::Resume => { + if let Some(rx) = self.rx.clone() { + return Some(pump(rx)); + } + } + + _ => {} + } + None + } + + fn view(&self) -> String { + if self.state == State::Awaiting { + if let Some(modal) = &self.modal { + return modal.view(self.width, self.height); + } + } + + let status_text = match self.state { + State::Streaming => format!(" {} working...", self.spinner.view()), + State::Idle => " a3s-code".to_string(), + State::Awaiting => " awaiting approval...".to_string(), + }; + let status = StatusBar::new() + .left(&status_text) + .right("Ctrl+C quit | PgUp/PgDn scroll ") + .fg(Color::White) + .bg(Color::BrightBlack) + .view(self.width); + + let viewport_view = self.viewport.view(); + let separator = Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(self.width as usize)); + let prompt = Style::new().fg(Color::BrightGreen).bold().render("❯ "); + let input_view = format!("{}{}", prompt, self.textarea.view()); + + Layout::vertical() + .item(&status, Constraint::Fixed(1)) + .item(&viewport_view, Constraint::Fill) + .item(&separator, Constraint::Fixed(1)) + .item(&input_view, Constraint::Fixed(3)) + .render(self.height) + } +} + +impl App { + fn on_submit(&mut self, text: String) -> Option> { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + match trimmed { + "/exit" | "/quit" => return Some(cmd::quit()), + "/clear" => { + self.messages.clear(); + self.textarea.clear(); + self.rebuild_viewport(); + return None; + } + _ => {} + } + + self.messages.push( + Style::new() + .bold() + .fg(Color::BrightGreen) + .render(&format!("❯ {trimmed}")), + ); + self.textarea.clear(); + self.streaming.clear(); + self.state = State::Streaming; + self.spinner.start(); + self.rebuild_viewport(); + + let session = self.session.clone(); + let prompt = trimmed.to_string(); + Some(cmd::batch(vec![ + cmd::cmd(move || async move { + match session.stream(prompt.as_str(), None).await { + Ok((rx, _join)) => Msg::StreamStarted(Arc::new(Mutex::new(rx))), + Err(e) => Msg::StreamError(e.to_string()), + } + }), + spinner_tick(), + ])) + } + + fn on_agent_event(&mut self, event: AgentEvent) -> Option> { + match event { + AgentEvent::TextDelta { text } => { + self.streaming.push(&text); + self.update_viewport_with_stream(); + } + AgentEvent::ToolStart { name, .. } => { + self.finalize_streaming(); + self.push_line(&Style::new().fg(Color::Cyan).render(&format!(" ⚙ {name}"))); + } + AgentEvent::ToolEnd { + name, + output, + exit_code, + .. + } => { + let status = if exit_code == 0 { "✓" } else { "✗" }; + let head = output.lines().take(6).collect::>().join("\n"); + self.push_line( + &Style::new() + .fg(Color::BrightBlack) + .render(&format!(" {status} {name}\n{head}")), + ); + } + AgentEvent::ConfirmationRequired { + tool_id, + tool_name, + args, + .. + } => { + self.state = State::Awaiting; + self.pending_tool = Some((tool_id, tool_name.clone())); + let body = format!( + "Tool: {tool_name}\nArgs: {}", + truncate(&args.to_string(), 300) + ); + self.modal = Some( + Modal::new() + .title("Approve tool call?") + .body(&body) + .options(vec!["Allow", "Deny"]), + ); + return None; // wait for the user; do not pump + } + AgentEvent::End { text, .. } => { + if self.streaming.raw_content().trim().is_empty() && !text.is_empty() { + self.streaming.push(&text); + } + self.finalize_streaming(); + self.finish(); + return None; + } + AgentEvent::Error { message } => { + self.push_line( + &Style::new() + .fg(Color::Red) + .render(&format!(" error: {message}")), + ); + self.finish(); + return None; + } + // TurnStart/TurnEnd, ToolInputDelta, ReasoningDelta, planning, memory, + // subagent, confirmation echoes, etc. — not surfaced in this MVP. + _ => {} + } + // Keep draining the stream. + self.rx.clone().map(pump) + } + + fn finalize_streaming(&mut self) { + let rendered = self.streaming.view(); + if !rendered.trim().is_empty() { + self.messages.push(rendered); + } + self.streaming.clear(); + self.rebuild_viewport(); + } + + fn finish(&mut self) { + self.state = State::Idle; + self.spinner.stop(); + self.rx = None; + self.rebuild_viewport(); + } + + fn push_line(&mut self, line: &str) { + self.messages.push(line.to_string()); + self.rebuild_viewport(); + } + + fn update_viewport_with_stream(&mut self) { + let mut full = self.messages.join("\n\n"); + let rendered = self.streaming.view(); + if !rendered.is_empty() { + if !full.is_empty() { + full.push_str("\n\n"); + } + full.push_str(&rendered); + } + self.viewport.set_content(&full); + } + + fn rebuild_viewport(&mut self) { + let full = self.messages.join("\n\n"); + self.viewport.set_content(&format!("{full}\n")); + } + + fn handle_modal_key(&mut self, key: &KeyEvent) -> Option> { + if let Some(modal) = &mut self.modal { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + modal.update(ModalMsg::Prev); + } + KeyCode::Down | KeyCode::Char('j') => { + modal.update(ModalMsg::Next); + } + KeyCode::Enter => { + let idx = modal.confirm(); + return Some(cmd::msg(Msg::ModalConfirm(idx))); + } + KeyCode::Esc => return Some(cmd::msg(Msg::ModalDismiss)), + _ => {} + } + } + None + } +} + +/// Headless probe of the same `session.stream()` / `AgentEvent` path the TUI +/// uses, auto-approving tool calls. Drives the integration without a TTY. +async fn run_smoke(session: Arc) -> anyhow::Result<()> { + let prompt = std::env::var("A3S_CODE_TUI_PROMPT") + .unwrap_or_else(|_| "Reply with exactly one short sentence: what is 2 + 2?".to_string()); + eprintln!("[smoke] prompt: {prompt}"); + let (mut rx, _join) = session.stream(prompt.as_str(), None).await?; + while let Some(event) = rx.recv().await { + match event { + AgentEvent::TextDelta { text } => print!("{text}"), + AgentEvent::ToolStart { name, .. } => eprintln!("\n[tool start] {name}"), + AgentEvent::ToolEnd { + name, exit_code, .. + } => eprintln!("[tool end] {name} (exit {exit_code})"), + AgentEvent::ConfirmationRequired { + tool_id, tool_name, .. + } => { + eprintln!("[confirm] auto-allowing {tool_name}"); + let _ = session.confirm_tool_use(&tool_id, true, None).await; + } + AgentEvent::End { .. } => { + eprintln!("\n[end]"); + break; + } + AgentEvent::Error { message } => { + eprintln!("\n[error] {message}"); + break; + } + _ => {} + } + } + Ok(()) +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let head: String = s.chars().take(max).collect(); + format!("{head}…") + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let config_path = + std::env::var("A3S_CONFIG_FILE").unwrap_or_else(|_| ".a3s/config.acl".to_string()); + let agent = Agent::new(config_path.clone()) + .await + .map_err(|e| anyhow::anyhow!("failed to load agent from {config_path}: {e}"))?; + let workspace = std::env::current_dir()?.to_string_lossy().to_string(); + let session = Arc::new(agent.session(workspace, None)?); + + // Headless smoke mode: exercise the agent-stream integration (the hard part + // the TUI depends on) without taking over the terminal. Useful for CI/probes + // and for validating a model/config end-to-end. + if std::env::var_os("A3S_CODE_TUI_SMOKE").is_some() { + return run_smoke(session).await; + } + + let (width, height) = a3s_tui::terminal::Terminal::size().unwrap_or((80, 24)); + let keymap = Keymap::new() + .bind( + KeyBinding::new(KeyCode::PageUp), + Action::ScrollUp, + "Scroll up", + ) + .bind( + KeyBinding::new(KeyCode::PageDown), + Action::ScrollDown, + "Scroll down", + ) + .bind( + KeyBinding::ctrl(KeyCode::Home), + Action::ScrollTop, + "Scroll to top", + ) + .bind( + KeyBinding::ctrl(KeyCode::End), + Action::ScrollBottom, + "Scroll to bottom", + ); + + let app = App { + session, + viewport: Viewport::new(width, height.saturating_sub(7)), + textarea: Textarea::new() + .with_height(3) + .with_width(width) + .with_submit_on_enter(true), + spinner: Spinner::new().with_title(""), + streaming: StreamingMarkdown::new((width as usize).saturating_sub(2)), + state: State::Idle, + messages: Vec::new(), + rx: None, + modal: None, + pending_tool: None, + width, + height, + keymap, + }; + + ProgramBuilder::new(app) + .with_alt_screen() + .with_fps(30) + .run() + .await?; + Ok(()) +} From 516c9ec068021b8a23dec93709c8b8a3ea10434a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 15:46:39 +0800 Subject: [PATCH 02/11] feat(cli): interrupt, reasoning, token usage, /help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next increment on the agent TUI: - Esc interrupts an in-progress run (session.cancel()), with an "interrupting…" note; the stream then closes and the turn finalizes normally. - ReasoningDelta is rendered live as dimmed "💭 thinking" above the answer and cleared when the answer finalizes (useful for reasoning models). - On completion, show token usage from the End event (total / prompt / completion). - /help slash command; refreshed welcome + status-bar hints (Esc interrupt). Build + clippy + fmt clean; regression smoke against gpt-4o still streams a valid answer. --- cli/src/main.rs | 62 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 8350311..beb46f7 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -59,6 +59,7 @@ enum Msg { ModalConfirm(usize), ModalDismiss, Resume, + Interrupted, Quit, } @@ -95,6 +96,9 @@ struct App { textarea: Textarea, spinner: Spinner, streaming: StreamingMarkdown, + /// Live reasoning ("thinking") text for the current turn, shown dimmed above + /// the answer and cleared when the answer is finalized. + thinking: String, state: State, messages: Vec, rx: Option, @@ -111,7 +115,7 @@ impl Model for App { fn init(&mut self) -> Option> { let welcome = Style::new().fg(Color::BrightBlack).italic().render( " A3S Code — type a message and press Enter.\n \ - Ctrl+C quit | PageUp/PageDown scroll | /clear reset | /exit quit\n", + Esc interrupt | Ctrl+C quit | PgUp/PgDn scroll | /help\n", ); self.viewport.set_content(&welcome); None @@ -144,6 +148,15 @@ impl Model for App { return None; } if self.state == State::Streaming { + // Esc interrupts the in-progress run. + if key.code == KeyCode::Esc { + self.push_line(&Style::new().fg(Color::Yellow).render(" ⎋ interrupting…")); + let session = self.session.clone(); + return Some(cmd::cmd(move || async move { + session.cancel().await; + Msg::Interrupted + })); + } return None; } if let Some(TextareaMsg::Submit(text)) = self.textarea.handle_key(&key) { @@ -224,7 +237,7 @@ impl Model for App { } let status_text = match self.state { - State::Streaming => format!(" {} working...", self.spinner.view()), + State::Streaming => format!(" {} working... (Esc interrupt)", self.spinner.view()), State::Idle => " a3s-code".to_string(), State::Awaiting => " awaiting approval...".to_string(), }; @@ -265,6 +278,16 @@ impl App { self.rebuild_viewport(); return None; } + "/help" => { + self.messages + .push(Style::new().fg(Color::BrightBlack).render( + " commands: /clear reset · /exit quit\n \ + Enter send · Esc interrupt · Ctrl+C quit · PgUp/PgDn scroll", + )); + self.textarea.clear(); + self.rebuild_viewport(); + return None; + } _ => {} } @@ -299,6 +322,10 @@ impl App { self.streaming.push(&text); self.update_viewport_with_stream(); } + AgentEvent::ReasoningDelta { text } => { + self.thinking.push_str(&text); + self.update_viewport_with_stream(); + } AgentEvent::ToolStart { name, .. } => { self.finalize_streaming(); self.push_line(&Style::new().fg(Color::Cyan).render(&format!(" ⚙ {name}"))); @@ -337,11 +364,17 @@ impl App { ); return None; // wait for the user; do not pump } - AgentEvent::End { text, .. } => { + AgentEvent::End { text, usage, .. } => { if self.streaming.raw_content().trim().is_empty() && !text.is_empty() { self.streaming.push(&text); } self.finalize_streaming(); + if usage.total_tokens > 0 { + self.push_line(&Style::new().fg(Color::BrightBlack).render(&format!( + " ⏱ {} tokens (prompt {}, completion {})", + usage.total_tokens, usage.prompt_tokens, usage.completion_tokens + ))); + } self.finish(); return None; } @@ -354,8 +387,8 @@ impl App { self.finish(); return None; } - // TurnStart/TurnEnd, ToolInputDelta, ReasoningDelta, planning, memory, - // subagent, confirmation echoes, etc. — not surfaced in this MVP. + // TurnStart/TurnEnd, ToolInputDelta, planning, memory, subagent, + // confirmation echoes, etc. — not surfaced in this MVP. _ => {} } // Keep draining the stream. @@ -368,6 +401,7 @@ impl App { self.messages.push(rendered); } self.streaming.clear(); + self.thinking.clear(); self.rebuild_viewport(); } @@ -384,15 +418,20 @@ impl App { } fn update_viewport_with_stream(&mut self) { - let mut full = self.messages.join("\n\n"); + let mut blocks: Vec = self.messages.clone(); + if !self.thinking.trim().is_empty() { + blocks.push( + Style::new() + .fg(Color::BrightBlack) + .italic() + .render(&format!("💭 {}", self.thinking.trim())), + ); + } let rendered = self.streaming.view(); if !rendered.is_empty() { - if !full.is_empty() { - full.push_str("\n\n"); - } - full.push_str(&rendered); + blocks.push(rendered); } - self.viewport.set_content(&full); + self.viewport.set_content(&blocks.join("\n\n")); } fn rebuild_viewport(&mut self) { @@ -513,6 +552,7 @@ async fn main() -> anyhow::Result<()> { .with_submit_on_enter(true), spinner: Spinner::new().with_title(""), streaming: StreamingMarkdown::new((width as usize).saturating_sub(2)), + thinking: String::new(), state: State::Idle, messages: Vec::new(), rx: None, From 82ce2af0305c28e05bff2d43308bff8d4840201e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 15:56:07 +0800 Subject: [PATCH 03/11] feat(cli): render colored diffs for file edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write/edit tools already emit before/after/file_path in ToolEnd metadata (edit.rs even notes it's "so frontend can show Monaco diff"), so this is a TUI-only change — no core change needed. - render_tool_end: for tool results carrying before/after/file_path, render a colored line diff (similar::TextDiff) with +adds/-dels header and green/red changed lines (context lines omitted; capped at 80 lines). Other tools keep the status + output-head line. - Adds serde_json + similar deps; unit tests for the diff vs status-line paths. patch tool doesn't emit before/after, so it falls back to the status line. --- cli/Cargo.toml | 2 + cli/src/main.rs | 110 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 3df82b0..7090b46 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -18,5 +18,7 @@ a3s-code-core = { path = "../core" } a3s-tui = { path = "../../tui" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } anyhow = "1" +serde_json = "1" +similar = "2" [workspace] diff --git a/cli/src/main.rs b/cli/src/main.rs index beb46f7..85bddd6 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -334,15 +334,15 @@ impl App { name, output, exit_code, + metadata, .. } => { - let status = if exit_code == 0 { "✓" } else { "✗" }; - let head = output.lines().take(6).collect::>().join("\n"); - self.push_line( - &Style::new() - .fg(Color::BrightBlack) - .render(&format!(" {status} {name}\n{head}")), - ); + self.push_line(&render_tool_end( + &name, + exit_code, + &output, + metadata.as_ref(), + )); } AgentEvent::ConfirmationRequired { tool_id, @@ -494,6 +494,75 @@ async fn run_smoke(session: Arc) -> anyhow::Result<()> { Ok(()) } +/// Render a completed tool call. File-editing tools (`write`/`edit`) carry +/// `before`/`after`/`file_path` in their metadata — show those as a colored +/// diff; everything else shows a status line + a few lines of output. +fn render_tool_end( + name: &str, + exit_code: i32, + output: &str, + meta: Option<&serde_json::Value>, +) -> String { + if let Some(meta) = meta { + if let (Some(before), Some(after), Some(path)) = ( + meta.get("before").and_then(|v| v.as_str()), + meta.get("after").and_then(|v| v.as_str()), + meta.get("file_path").and_then(|v| v.as_str()), + ) { + return render_diff(path, before, after); + } + } + let status = if exit_code == 0 { "✓" } else { "✗" }; + let head = output.lines().take(6).collect::>().join("\n"); + Style::new() + .fg(Color::BrightBlack) + .render(&format!(" {status} {name}\n{head}")) +} + +/// Render a unified-ish line diff (changed lines only) with +/- coloring. +fn render_diff(path: &str, before: &str, after: &str) -> String { + use similar::{ChangeTag, TextDiff}; + const MAX_LINES: usize = 80; + + let diff = TextDiff::from_lines(before, after); + let mut lines: Vec = Vec::new(); + let (mut adds, mut dels) = (0usize, 0usize); + for change in diff.iter_all_changes() { + let raw = change.value(); + let raw = raw.strip_suffix('\n').unwrap_or(raw); + match change.tag() { + ChangeTag::Delete => { + dels += 1; + if lines.len() < MAX_LINES { + lines.push(Style::new().fg(Color::Red).render(&format!(" - {raw}"))); + } + } + ChangeTag::Insert => { + adds += 1; + if lines.len() < MAX_LINES { + lines.push(Style::new().fg(Color::Green).render(&format!(" + {raw}"))); + } + } + ChangeTag::Equal => {} + } + } + if lines.len() >= MAX_LINES { + lines.push( + Style::new() + .fg(Color::BrightBlack) + .render(" … (diff truncated)"), + ); + } + let mut out = Style::new() + .fg(Color::Cyan) + .render(&format!(" ✎ {path} (+{adds} -{dels})")); + if !lines.is_empty() { + out.push('\n'); + out.push_str(&lines.join("\n")); + } + out +} + fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { s.to_string() @@ -570,3 +639,30 @@ async fn main() -> anyhow::Result<()> { .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edit_metadata_renders_colored_diff() { + let meta = serde_json::json!({ + "file_path": "src/x.rs", + "before": "let a = 1;\nkeep;\n", + "after": "let a = 2;\nkeep;\n", + }); + let out = render_tool_end("edit", 0, "ok", Some(&meta)); + assert!(out.contains("src/x.rs"), "header has path"); + assert!(out.contains("+1") && out.contains("-1"), "add/del counts"); + assert!(out.contains("let a = 2;"), "shows inserted line"); + assert!(out.contains("let a = 1;"), "shows deleted line"); + assert!(!out.contains("keep;"), "unchanged lines are omitted"); + } + + #[test] + fn non_edit_tool_renders_status_line() { + let out = render_tool_end("bash", 0, "hello\nworld", None); + assert!(out.contains("bash") && out.contains("hello")); + assert!(!out.contains('✎'), "no diff marker for non-edit tools"); + } +} From e6d708b47c919f1373f3b9a210869f4f167cc703 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 16:02:10 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(cli):=20prompt=20history=20(?= =?UTF-8?q?=E2=86=91/=E2=86=93)=20+=20subagent=20activity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ↑/↓ recall submitted prompts into the input (single-line input only, so multi-line editing keeps normal cursor movement); going forward past the newest entry returns to a fresh input. - Render SubagentStart / SubagentEnd as dimmed "↳ subagent " lines so delegated work is visible in the transcript. - Refreshed /help. Build + tests + clippy + fmt clean; regression smoke against gpt-4o still streams correctly. --- cli/src/main.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 85bddd6..28ce0f2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -104,6 +104,10 @@ struct App { rx: Option, modal: Option, pending_tool: Option<(String, String)>, + /// Submitted prompts, oldest first, for ↑/↓ recall. + history: Vec, + /// Cursor into `history` while browsing; `None` means "fresh input". + history_pos: Option, width: u16, height: u16, keymap: Keymap, @@ -159,6 +163,15 @@ impl Model for App { } return None; } + // ↑/↓ recall prompt history (single-line input only, so multi-line + // editing keeps normal cursor movement). + if matches!(key.code, KeyCode::Up | KeyCode::Down) + && !self.textarea.value().contains('\n') + && !self.history.is_empty() + { + self.history_recall(key.code == KeyCode::Up); + return None; + } if let Some(TextareaMsg::Submit(text)) = self.textarea.handle_key(&key) { return Some(cmd::msg(Msg::Submit(text))); } @@ -282,7 +295,7 @@ impl App { self.messages .push(Style::new().fg(Color::BrightBlack).render( " commands: /clear reset · /exit quit\n \ - Enter send · Esc interrupt · Ctrl+C quit · PgUp/PgDn scroll", + Enter send · ↑/↓ history · Esc interrupt · Ctrl+C quit · PgUp/PgDn scroll", )); self.textarea.clear(); self.rebuild_viewport(); @@ -291,6 +304,8 @@ impl App { _ => {} } + self.history.push(trimmed.to_string()); + self.history_pos = None; self.messages.push( Style::new() .bold() @@ -344,6 +359,24 @@ impl App { metadata.as_ref(), )); } + AgentEvent::SubagentStart { + agent, description, .. + } => { + self.finalize_streaming(); + self.push_line( + &Style::new() + .fg(Color::Magenta) + .render(&format!(" ↳ subagent {agent}: {description}")), + ); + } + AgentEvent::SubagentEnd { agent, success, .. } => { + let mark = if success { "✓" } else { "✗" }; + self.push_line( + &Style::new() + .fg(Color::Magenta) + .render(&format!(" ↳ {mark} subagent {agent} done")), + ); + } AgentEvent::ConfirmationRequired { tool_id, tool_name, @@ -417,6 +450,24 @@ impl App { self.rebuild_viewport(); } + /// Move through prompt history and load the entry into the input. Going + /// forward past the newest entry returns to a fresh, empty input. + fn history_recall(&mut self, up: bool) { + let pos = match (self.history_pos, up) { + (None, true) => self.history.len().saturating_sub(1), + (None, false) => return, + (Some(i), true) => i.saturating_sub(1), + (Some(i), false) => i + 1, + }; + if pos >= self.history.len() { + self.history_pos = None; + self.textarea.clear(); + } else { + self.history_pos = Some(pos); + self.textarea.set_value(&self.history[pos]); + } + } + fn update_viewport_with_stream(&mut self) { let mut blocks: Vec = self.messages.clone(); if !self.thinking.trim().is_empty() { @@ -627,6 +678,8 @@ async fn main() -> anyhow::Result<()> { rx: None, modal: None, pending_tool: None, + history: Vec::new(), + history_pos: None, width, height, keymap, From e2f939e6f363c49a0a978ae03d7fd8f1c11dae37 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 16:11:20 +0800 Subject: [PATCH 05/11] feat(cli): mouse scroll + model/token status bar - Enable mouse support; scroll wheel scrolls the transcript viewport. - Status bar now shows the model (captured from the first turn's response metadata) and cumulative session token usage. Build + tests + clippy + fmt clean. --- cli/src/main.rs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 28ce0f2..99028c4 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -108,6 +108,10 @@ struct App { history: Vec, /// Cursor into `history` while browsing; `None` means "fresh input". history_pos: Option, + /// Model name reported by the provider (captured from the first turn). + model: Option, + /// Cumulative tokens used this session. + total_tokens: usize, width: u16, height: u16, keymap: Keymap, @@ -177,6 +181,15 @@ impl Model for App { } } + Msg::Term(Event::Mouse(m)) => { + use a3s_tui::event::MouseEventKind; + match m.kind { + MouseEventKind::ScrollUp => self.viewport.update(ViewportMsg::ScrollUp(3)), + MouseEventKind::ScrollDown => self.viewport.update(ViewportMsg::ScrollDown(3)), + _ => {} + } + } + Msg::Submit(text) => return self.on_submit(text), Msg::StreamStarted(rx) => { @@ -254,9 +267,18 @@ impl Model for App { State::Idle => " a3s-code".to_string(), State::Awaiting => " awaiting approval...".to_string(), }; + let mut right = String::new(); + if let Some(model) = &self.model { + right.push_str(model); + right.push_str(" · "); + } + if self.total_tokens > 0 { + right.push_str(&format!("{} tok · ", self.total_tokens)); + } + right.push_str("Ctrl+C quit "); let status = StatusBar::new() .left(&status_text) - .right("Ctrl+C quit | PgUp/PgDn scroll ") + .right(&right) .fg(Color::White) .bg(Color::BrightBlack) .view(self.width); @@ -397,11 +419,17 @@ impl App { ); return None; // wait for the user; do not pump } - AgentEvent::End { text, usage, .. } => { + AgentEvent::End { + text, usage, meta, .. + } => { if self.streaming.raw_content().trim().is_empty() && !text.is_empty() { self.streaming.push(&text); } self.finalize_streaming(); + self.total_tokens += usage.total_tokens; + if self.model.is_none() { + self.model = meta.and_then(|m| m.response_model.or(m.request_model)); + } if usage.total_tokens > 0 { self.push_line(&Style::new().fg(Color::BrightBlack).render(&format!( " ⏱ {} tokens (prompt {}, completion {})", @@ -680,6 +708,8 @@ async fn main() -> anyhow::Result<()> { pending_tool: None, history: Vec::new(), history_pos: None, + model: None, + total_tokens: 0, width, height, keymap, @@ -687,6 +717,7 @@ async fn main() -> anyhow::Result<()> { ProgramBuilder::new(app) .with_alt_screen() + .with_mouse_support() .with_fps(30) .run() .await?; From e7da94840212d98ed128c5c6e58a8d354936341c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 16:20:53 +0800 Subject: [PATCH 06/11] feat(cli): session resume across runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist the conversation to /.a3s/tui-sessions (FileSessionStore, fixed "tui-default" id, auto-save). On launch, resume that session if it exists and seed the transcript with the prior user/assistant turns; otherwise start fresh. Relaunching in the same directory continues the conversation with full agent context. Also fixes run_smoke to drain the stream fully and await the stream task, so the background auto-save completes before exit (the headless probe was exiting too early to persist). Verified end-to-end against gpt-4o: run 1 "remember 42" → run 2 (fresh process) recalls "42" from the persisted session. --- cli/src/main.rs | 87 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 99028c4..5c2e32d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use std::time::Duration; -use a3s_code_core::{Agent, AgentEvent, AgentSession}; +use a3s_code_core::{Agent, AgentEvent, AgentSession, SessionOptions}; use a3s_tui::cmd::{self, Cmd}; use a3s_tui::components::modal::{Modal, ModalMsg}; use a3s_tui::components::textarea::TextareaMsg; @@ -121,11 +121,17 @@ impl Model for App { type Msg = Msg; fn init(&mut self) -> Option> { - let welcome = Style::new().fg(Color::BrightBlack).italic().render( - " A3S Code — type a message and press Enter.\n \ - Esc interrupt | Ctrl+C quit | PgUp/PgDn scroll | /help\n", - ); - self.viewport.set_content(&welcome); + if self.messages.is_empty() { + let welcome = Style::new().fg(Color::BrightBlack).italic().render( + " A3S Code — type a message and press Enter.\n \ + Esc interrupt | Ctrl+C quit | PgUp/PgDn scroll | /help\n", + ); + self.viewport.set_content(&welcome); + } else { + // Resumed session — show the prior conversation, scrolled to the end. + self.rebuild_viewport(); + self.viewport.update(ViewportMsg::Bottom); + } None } @@ -545,7 +551,7 @@ async fn run_smoke(session: Arc) -> anyhow::Result<()> { let prompt = std::env::var("A3S_CODE_TUI_PROMPT") .unwrap_or_else(|_| "Reply with exactly one short sentence: what is 2 + 2?".to_string()); eprintln!("[smoke] prompt: {prompt}"); - let (mut rx, _join) = session.stream(prompt.as_str(), None).await?; + let (mut rx, join) = session.stream(prompt.as_str(), None).await?; while let Some(event) = rx.recv().await { match event { AgentEvent::TextDelta { text } => print!("{text}"), @@ -559,17 +565,13 @@ async fn run_smoke(session: Arc) -> anyhow::Result<()> { eprintln!("[confirm] auto-allowing {tool_name}"); let _ = session.confirm_tool_use(&tool_id, true, None).await; } - AgentEvent::End { .. } => { - eprintln!("\n[end]"); - break; - } - AgentEvent::Error { message } => { - eprintln!("\n[error] {message}"); - break; - } + AgentEvent::End { .. } => eprintln!("\n[end]"), + AgentEvent::Error { message } => eprintln!("\n[error] {message}"), _ => {} } } + // Let the stream task finish (incl. auto-save/persist) before we exit. + let _ = join.await; Ok(()) } @@ -659,7 +661,58 @@ async fn main() -> anyhow::Result<()> { .await .map_err(|e| anyhow::anyhow!("failed to load agent from {config_path}: {e}"))?; let workspace = std::env::current_dir()?.to_string_lossy().to_string(); - let session = Arc::new(agent.session(workspace, None)?); + + // Persistent, resumable session: stored under /.a3s/tui-sessions and + // keyed by a fixed id, so relaunching in the same directory continues the + // conversation. Falls back to a fresh session when none exists yet. + let store_dir = std::path::Path::new(&workspace).join(".a3s/tui-sessions"); + let store: Arc = Arc::new( + a3s_code_core::store::FileSessionStore::new(&store_dir) + .await + .map_err(|e| anyhow::anyhow!("failed to open session store {store_dir:?}: {e}"))?, + ); + const SESSION_ID: &str = "tui-default"; + let session = match agent.resume_session( + SESSION_ID, + SessionOptions::new() + .with_session_store(store.clone()) + .with_auto_save(true), + ) { + Ok(s) => s, + Err(_) => agent.session( + workspace.clone(), + Some( + SessionOptions::new() + .with_session_store(store.clone()) + .with_session_id(SESSION_ID) + .with_auto_save(true), + ), + )?, + }; + + // Seed the transcript with any resumed conversation (user + assistant text). + let initial_messages: Vec = session + .history() + .iter() + .filter_map(|m| { + let text = m.text(); + if text.trim().is_empty() { + return None; + } + match m.role.as_str() { + "user" => Some( + Style::new() + .bold() + .fg(Color::BrightGreen) + .render(&format!("❯ {}", text.trim())), + ), + "assistant" => Some(text), + _ => None, + } + }) + .collect(); + + let session = Arc::new(session); // Headless smoke mode: exercise the agent-stream integration (the hard part // the TUI depends on) without taking over the terminal. Useful for CI/probes @@ -702,7 +755,7 @@ async fn main() -> anyhow::Result<()> { streaming: StreamingMarkdown::new((width as usize).saturating_sub(2)), thinking: String::new(), state: State::Idle, - messages: Vec::new(), + messages: initial_messages, rx: None, modal: None, pending_tool: None, From fbfe7a7520b675bb20f7ae1b725c2d7cd478f747 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 16:28:45 +0800 Subject: [PATCH 07/11] feat(cli): tool action transparency + pretty approval args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex-style action log: accumulate the streamed tool-input JSON and show the tool's primary argument on completion — "✓ bash — npm test", "✓ read — src/main.rs", "✓ grep — TODO" — instead of just the tool name. The HITL approval modal now renders args as pretty-printed JSON. Unit tests for arg-summary extraction and the summary in the result line. --- cli/src/main.rs | 78 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 5c2e32d..fed1e2e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -112,6 +112,9 @@ struct App { model: Option, /// Cumulative tokens used this session. total_tokens: usize, + /// Accumulated streamed JSON args of the in-progress tool call, so the + /// result line can show what the tool actually did (command/path/pattern). + tool_args: String, width: u16, height: u16, keymap: Keymap, @@ -371,8 +374,12 @@ impl App { } AgentEvent::ToolStart { name, .. } => { self.finalize_streaming(); + self.tool_args.clear(); self.push_line(&Style::new().fg(Color::Cyan).render(&format!(" ⚙ {name}"))); } + AgentEvent::ToolInputDelta { delta } => { + self.tool_args.push_str(&delta); + } AgentEvent::ToolEnd { name, output, @@ -380,12 +387,15 @@ impl App { metadata, .. } => { + let args: Option = serde_json::from_str(&self.tool_args).ok(); self.push_line(&render_tool_end( &name, exit_code, &output, metadata.as_ref(), + args.as_ref(), )); + self.tool_args.clear(); } AgentEvent::SubagentStart { agent, description, .. @@ -413,10 +423,9 @@ impl App { } => { self.state = State::Awaiting; self.pending_tool = Some((tool_id, tool_name.clone())); - let body = format!( - "Tool: {tool_name}\nArgs: {}", - truncate(&args.to_string(), 300) - ); + let pretty = + serde_json::to_string_pretty(&args).unwrap_or_else(|_| args.to_string()); + let body = format!("Tool: {tool_name}\n{}", truncate(&pretty, 400)); self.modal = Some( Modal::new() .title("Approve tool call?") @@ -583,6 +592,7 @@ fn render_tool_end( exit_code: i32, output: &str, meta: Option<&serde_json::Value>, + args: Option<&serde_json::Value>, ) -> String { if let Some(meta) = meta { if let (Some(before), Some(after), Some(path)) = ( @@ -594,10 +604,38 @@ fn render_tool_end( } } let status = if exit_code == 0 { "✓" } else { "✗" }; + // Show the tool's primary argument (command/path/pattern) so the action log + // reads like Codex — "✓ bash — npm test" rather than just "✓ bash". + let header = match args.and_then(arg_summary) { + Some(summary) => format!(" {status} {name} — {summary}"), + None => format!(" {status} {name}"), + }; let head = output.lines().take(6).collect::>().join("\n"); - Style::new() - .fg(Color::BrightBlack) - .render(&format!(" {status} {name}\n{head}")) + let body = if head.trim().is_empty() { + header + } else { + format!("{header}\n{head}") + }; + Style::new().fg(Color::BrightBlack).render(&body) +} + +/// Extract a one-line summary of a tool's primary argument. +fn arg_summary(args: &serde_json::Value) -> Option { + for key in [ + "command", + "file_path", + "path", + "pattern", + "query", + "url", + "old_string", + ] { + if let Some(v) = args.get(key).and_then(|v| v.as_str()) { + let v = v.replace('\n', " "); + return Some(truncate(v.trim(), 120)); + } + } + None } /// Render a unified-ish line diff (changed lines only) with +/- coloring. @@ -763,6 +801,7 @@ async fn main() -> anyhow::Result<()> { history_pos: None, model: None, total_tokens: 0, + tool_args: String::new(), width, height, keymap, @@ -788,7 +827,7 @@ mod tests { "before": "let a = 1;\nkeep;\n", "after": "let a = 2;\nkeep;\n", }); - let out = render_tool_end("edit", 0, "ok", Some(&meta)); + let out = render_tool_end("edit", 0, "ok", Some(&meta), None); assert!(out.contains("src/x.rs"), "header has path"); assert!(out.contains("+1") && out.contains("-1"), "add/del counts"); assert!(out.contains("let a = 2;"), "shows inserted line"); @@ -798,8 +837,29 @@ mod tests { #[test] fn non_edit_tool_renders_status_line() { - let out = render_tool_end("bash", 0, "hello\nworld", None); + let out = render_tool_end("bash", 0, "hello\nworld", None, None); assert!(out.contains("bash") && out.contains("hello")); assert!(!out.contains('✎'), "no diff marker for non-edit tools"); } + + #[test] + fn tool_end_shows_primary_arg_summary() { + let args = serde_json::json!({ "command": "npm test", "timeout": 60 }); + let out = render_tool_end("bash", 0, "ok\n", None, Some(&args)); + assert!(out.contains("bash")); + assert!(out.contains("npm test"), "shows the command argument"); + } + + #[test] + fn arg_summary_extracts_known_keys() { + assert_eq!( + arg_summary(&serde_json::json!({ "command": "ls -la" })), + Some("ls -la".to_string()) + ); + assert_eq!( + arg_summary(&serde_json::json!({ "pattern": "TODO" })), + Some("TODO".to_string()) + ); + assert_eq!(arg_summary(&serde_json::json!({ "unknown": "x" })), None); + } } From c0916ca448618161b2a4127e987a5b498ecb0634 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 16:31:20 +0800 Subject: [PATCH 08/11] feat(cli): auto-approve mode (/auto) + cwd context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /auto toggles Codex-style approval mode: while on, tool-confirmation prompts are auto-approved (shown as "⚡ auto-approved ") instead of opening the modal. - Welcome screen shows the working directory for context; /help lists /auto. --- cli/src/main.rs | 48 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index fed1e2e..2ea7178 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -115,6 +115,11 @@ struct App { /// Accumulated streamed JSON args of the in-progress tool call, so the /// result line can show what the tool actually did (command/path/pattern). tool_args: String, + /// When true, tool-confirmation prompts are auto-approved (Codex-style + /// approval mode), toggled with `/auto`. + auto_approve: bool, + /// Working directory shown for context. + cwd: String, width: u16, height: u16, keymap: Keymap, @@ -125,10 +130,14 @@ impl Model for App { fn init(&mut self) -> Option> { if self.messages.is_empty() { - let welcome = Style::new().fg(Color::BrightBlack).italic().render( - " A3S Code — type a message and press Enter.\n \ - Esc interrupt | Ctrl+C quit | PgUp/PgDn scroll | /help\n", - ); + let welcome = Style::new() + .fg(Color::BrightBlack) + .italic() + .render(&format!( + " A3S Code — {}\n Type a message and press Enter.\n \ + ↑/↓ history · Esc interrupt · /help · Ctrl+C quit\n", + self.cwd + )); self.viewport.set_content(&welcome); } else { // Resumed session — show the prior conversation, scrolled to the end. @@ -325,13 +334,25 @@ impl App { "/help" => { self.messages .push(Style::new().fg(Color::BrightBlack).render( - " commands: /clear reset · /exit quit\n \ + " commands: /clear reset · /auto toggle auto-approve · /exit quit\n \ Enter send · ↑/↓ history · Esc interrupt · Ctrl+C quit · PgUp/PgDn scroll", )); self.textarea.clear(); self.rebuild_viewport(); return None; } + "/auto" => { + self.auto_approve = !self.auto_approve; + let state = if self.auto_approve { "on" } else { "off" }; + self.messages.push( + Style::new() + .fg(Color::Yellow) + .render(&format!(" ⚡ auto-approve: {state}")), + ); + self.textarea.clear(); + self.rebuild_viewport(); + return None; + } _ => {} } @@ -421,6 +442,21 @@ impl App { args, .. } => { + if self.auto_approve { + self.push_line( + &Style::new() + .fg(Color::BrightBlack) + .render(&format!(" ⚡ auto-approved {tool_name}")), + ); + let session = self.session.clone(); + return Some(cmd::batch(vec![ + cmd::cmd(move || async move { + let _ = session.confirm_tool_use(&tool_id, true, None).await; + Msg::Resume + }), + spinner_tick(), + ])); + } self.state = State::Awaiting; self.pending_tool = Some((tool_id, tool_name.clone())); let pretty = @@ -802,6 +838,8 @@ async fn main() -> anyhow::Result<()> { model: None, total_tokens: 0, tool_args: String::new(), + auto_approve: false, + cwd: workspace, width, height, keymap, From 6b991da32a8a9a77172937270a5acb8f64402d0d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 16:45:42 +0800 Subject: [PATCH 09/11] feat(cli): syntax-highlight file/code tool output When a tool returns file/code content (read/edit on a known extension), render the output as a syntax-highlighted fenced block via a3s-tui's Markdown (syntect), matching how Codex shows file content. Other tool output stays dimmed. Also make the headless smoke probe print tool output for debugging. --- cli/src/main.rs | 86 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 2ea7178..66e5326 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -415,6 +415,7 @@ impl App { &output, metadata.as_ref(), args.as_ref(), + self.width as usize, )); self.tool_args.clear(); } @@ -602,8 +603,14 @@ async fn run_smoke(session: Arc) -> anyhow::Result<()> { AgentEvent::TextDelta { text } => print!("{text}"), AgentEvent::ToolStart { name, .. } => eprintln!("\n[tool start] {name}"), AgentEvent::ToolEnd { - name, exit_code, .. - } => eprintln!("[tool end] {name} (exit {exit_code})"), + name, + exit_code, + output, + .. + } => eprintln!( + "[tool end] {name} (exit {exit_code}): {}", + output.lines().take(2).collect::>().join(" | ") + ), AgentEvent::ConfirmationRequired { tool_id, tool_name, .. } => { @@ -629,6 +636,7 @@ fn render_tool_end( output: &str, meta: Option<&serde_json::Value>, args: Option<&serde_json::Value>, + width: usize, ) -> String { if let Some(meta) = meta { if let (Some(before), Some(after), Some(path)) = ( @@ -642,17 +650,63 @@ fn render_tool_end( let status = if exit_code == 0 { "✓" } else { "✗" }; // Show the tool's primary argument (command/path/pattern) so the action log // reads like Codex — "✓ bash — npm test" rather than just "✓ bash". - let header = match args.and_then(arg_summary) { - Some(summary) => format!(" {status} {name} — {summary}"), - None => format!(" {status} {name}"), - }; - let head = output.lines().take(6).collect::>().join("\n"); - let body = if head.trim().is_empty() { - header - } else { - format!("{header}\n{head}") - }; - Style::new().fg(Color::BrightBlack).render(&body) + let header = Style::new() + .fg(Color::BrightBlack) + .render(&match args.and_then(arg_summary) { + Some(summary) => format!(" {status} {name} — {summary}"), + None => format!(" {status} {name}"), + }); + let head = output.lines().take(8).collect::>().join("\n"); + if head.trim().is_empty() { + return header; + } + // If the output is file/code content (read/edit on a known extension), + // syntax-highlight it; otherwise show it dimmed. + if exit_code == 0 { + if let Some(lang) = args + .and_then(|a| { + a.get("file_path") + .or_else(|| a.get("path")) + .and_then(|v| v.as_str()) + }) + .and_then(lang_from_path) + { + let fenced = format!("```{lang}\n{head}\n```"); + let rendered = a3s_tui::markdown::Markdown::new() + .with_width(width.saturating_sub(4).max(20)) + .render(&fenced); + return format!("{header}\n{rendered}"); + } + } + format!( + "{header}\n{}", + Style::new().fg(Color::BrightBlack).render(&head) + ) +} + +/// Map a file path to a syntect language token for fenced rendering. +fn lang_from_path(path: &str) -> Option<&'static str> { + let ext = path.rsplit('.').next()?; + Some(match ext { + "rs" => "rust", + "py" => "python", + "js" | "mjs" | "cjs" => "javascript", + "ts" | "tsx" => "typescript", + "go" => "go", + "json" => "json", + "toml" => "toml", + "yaml" | "yml" => "yaml", + "md" => "markdown", + "sh" | "bash" => "bash", + "c" | "h" => "c", + "cpp" | "cc" | "hpp" => "cpp", + "java" => "java", + "rb" => "ruby", + "html" => "html", + "css" => "css", + "sql" => "sql", + _ => return None, + }) } /// Extract a one-line summary of a tool's primary argument. @@ -865,7 +919,7 @@ mod tests { "before": "let a = 1;\nkeep;\n", "after": "let a = 2;\nkeep;\n", }); - let out = render_tool_end("edit", 0, "ok", Some(&meta), None); + let out = render_tool_end("edit", 0, "ok", Some(&meta), None, 80); assert!(out.contains("src/x.rs"), "header has path"); assert!(out.contains("+1") && out.contains("-1"), "add/del counts"); assert!(out.contains("let a = 2;"), "shows inserted line"); @@ -875,7 +929,7 @@ mod tests { #[test] fn non_edit_tool_renders_status_line() { - let out = render_tool_end("bash", 0, "hello\nworld", None, None); + let out = render_tool_end("bash", 0, "hello\nworld", None, None, 80); assert!(out.contains("bash") && out.contains("hello")); assert!(!out.contains('✎'), "no diff marker for non-edit tools"); } @@ -883,7 +937,7 @@ mod tests { #[test] fn tool_end_shows_primary_arg_summary() { let args = serde_json::json!({ "command": "npm test", "timeout": 60 }); - let out = render_tool_end("bash", 0, "ok\n", None, Some(&args)); + let out = render_tool_end("bash", 0, "ok\n", None, Some(&args), 80); assert!(out.contains("bash")); assert!(out.contains("npm test"), "shows the command argument"); } From 2c88188578d63f125b90017e1b08e56178f76781 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 16:54:13 +0800 Subject: [PATCH 10/11] feat(cli): live tool output streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render ToolOutputDelta live — the tail of a running tool's stdout is shown dimmed under the action (like watching a command run in Codex), then cleared when the tool completes. --- cli/src/main.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cli/src/main.rs b/cli/src/main.rs index 66e5326..267661c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -115,6 +115,9 @@ struct App { /// Accumulated streamed JSON args of the in-progress tool call, so the /// result line can show what the tool actually did (command/path/pattern). tool_args: String, + /// Live stdout of the in-progress tool (e.g. a running command), shown + /// dimmed under the action and cleared when the tool completes. + tool_output: String, /// When true, tool-confirmation prompts are auto-approved (Codex-style /// approval mode), toggled with `/auto`. auto_approve: bool, @@ -396,11 +399,16 @@ impl App { AgentEvent::ToolStart { name, .. } => { self.finalize_streaming(); self.tool_args.clear(); + self.tool_output.clear(); self.push_line(&Style::new().fg(Color::Cyan).render(&format!(" ⚙ {name}"))); } AgentEvent::ToolInputDelta { delta } => { self.tool_args.push_str(&delta); } + AgentEvent::ToolOutputDelta { delta, .. } => { + self.tool_output.push_str(&delta); + self.update_viewport_with_stream(); + } AgentEvent::ToolEnd { name, output, @@ -418,6 +426,7 @@ impl App { self.width as usize, )); self.tool_args.clear(); + self.tool_output.clear(); } AgentEvent::SubagentStart { agent, description, .. @@ -562,6 +571,12 @@ impl App { if !rendered.is_empty() { blocks.push(rendered); } + // Live stdout of the running tool — show the tail like a terminal. + if !self.tool_output.trim().is_empty() { + let tail: Vec<&str> = self.tool_output.lines().rev().take(12).collect(); + let tail = tail.into_iter().rev().collect::>().join("\n"); + blocks.push(Style::new().fg(Color::BrightBlack).render(&tail)); + } self.viewport.set_content(&blocks.join("\n\n")); } @@ -892,6 +907,7 @@ async fn main() -> anyhow::Result<()> { model: None, total_tokens: 0, tool_args: String::new(), + tool_output: String::new(), auto_approve: false, cwd: workspace, width, From 95e0f686bab289e41cfd57c05041d14e2bceb301 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 16:57:48 +0800 Subject: [PATCH 11/11] fix(cli): enable HITL confirmation so file edits actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File-modifying tools (write/edit/patch) require a confirmation manager; without one they fail with "requires confirmation but no HITL confirmation manager is configured" — so the agent could never edit files in the TUI. Enable ConfirmationPolicy on the session (long timeout so the approve/deny modal never expires). Now write/edit emit ConfirmationRequired → the TUI modal (or /auto) approves → the tool runs → the diff renders. Verified end-to-end against gpt-4o: "create note.txt with hello" → approved → file written, exit 0. --- cli/src/main.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cli/src/main.rs b/cli/src/main.rs index 267661c..7e6dbd1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use std::time::Duration; +use a3s_code_core::hitl::TimeoutAction; use a3s_code_core::{Agent, AgentEvent, AgentSession, SessionOptions}; use a3s_tui::cmd::{self, Cmd}; use a3s_tui::components::modal::{Modal, ModalMsg}; @@ -815,10 +816,18 @@ async fn main() -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("failed to open session store {store_dir:?}: {e}"))?, ); const SESSION_ID: &str = "tui-default"; + // Enable HITL confirmation so file-modifying tools (write/edit/patch) can + // run — they require a confirmation manager, otherwise they fail with + // "requires confirmation but no HITL confirmation manager is configured". + // The TUI is that manager (approve/deny modal, or /auto). Long timeout so + // the modal never expires while the user reads it. + let confirmation = a3s_code_core::hitl::ConfirmationPolicy::enabled() + .with_timeout(3_600_000, TimeoutAction::Reject); let session = match agent.resume_session( SESSION_ID, SessionOptions::new() .with_session_store(store.clone()) + .with_confirmation_policy(confirmation.clone()) .with_auto_save(true), ) { Ok(s) => s, @@ -828,6 +837,7 @@ async fn main() -> anyhow::Result<()> { SessionOptions::new() .with_session_store(store.clone()) .with_session_id(SESSION_ID) + .with_confirmation_policy(confirmation) .with_auto_save(true), ), )?,