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};
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};
type SharedRx = Arc<Mutex<mpsc::Receiver<AgentEvent>>>;
#[derive(PartialEq)]
enum State {
Idle,
Streaming,
Awaiting,
}
#[derive(Clone)]
#[allow(clippy::enum_variant_names)]
enum Action {
ScrollUp,
ScrollDown,
ScrollTop,
ScrollBottom,
}
enum Msg {
Term(Event),
Agent(Box<AgentEvent>),
Submit(String),
StreamStarted(SharedRx),
StreamEnded,
StreamError(String),
SpinnerTick,
ModalConfirm(usize),
ModalDismiss,
Resume,
Interrupted,
Quit,
}
impl From<Event> 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),
}
}
}
fn pump(rx: SharedRx) -> Cmd<Msg> {
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<Msg> {
cmd::tick(Duration::from_millis(80), Msg::SpinnerTick)
}
struct App {
session: Arc<AgentSession>,
viewport: Viewport,
textarea: Textarea,
spinner: Spinner,
streaming: StreamingMarkdown,
thinking: String,
state: State,
messages: Vec<String>,
rx: Option<SharedRx>,
modal: Option<Modal>,
pending_tool: Option<(String, String)>,
history: Vec<String>,
history_pos: Option<usize>,
model: Option<String>,
total_tokens: usize,
tool_args: String,
tool_output: String,
auto_approve: bool,
cwd: String,
width: u16,
height: u16,
keymap: Keymap<Action>,
}
impl Model for App {
type Msg = Msg;
fn init(&mut self) -> Option<Cmd<Msg>> {
if self.messages.is_empty() {
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 {
self.rebuild_viewport();
self.viewport.update(ViewportMsg::Bottom);
}
None
}
fn update(&mut self, msg: Msg) -> Option<Cmd<Msg>> {
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 {
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 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)));
}
}
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) => {
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... (Esc interrupt)", self.spinner.view()),
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(&right)
.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<Cmd<Msg>> {
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;
}
"/help" => {
self.messages
.push(Style::new().fg(Color::BrightBlack).render(
" 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;
}
_ => {}
}
self.history.push(trimmed.to_string());
self.history_pos = 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<Cmd<Msg>> {
match event {
AgentEvent::TextDelta { text } => {
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.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,
exit_code,
metadata,
..
} => {
let args: Option<serde_json::Value> = 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.width as usize,
));
self.tool_args.clear();
self.tool_output.clear();
}
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,
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 =
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?")
.body(&body)
.options(vec!["Allow", "Deny"]),
);
return None; }
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 {})",
usage.total_tokens, usage.prompt_tokens, usage.completion_tokens
)));
}
self.finish();
return None;
}
AgentEvent::Error { message } => {
self.push_line(
&Style::new()
.fg(Color::Red)
.render(&format!(" error: {message}")),
);
self.finish();
return None;
}
_ => {}
}
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.thinking.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 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<String> = 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() {
blocks.push(rendered);
}
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::<Vec<_>>().join("\n");
blocks.push(Style::new().fg(Color::BrightBlack).render(&tail));
}
self.viewport.set_content(&blocks.join("\n\n"));
}
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<Cmd<Msg>> {
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
}
}
async fn run_smoke(session: Arc<AgentSession>) -> 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,
output,
..
} => eprintln!(
"[tool end] {name} (exit {exit_code}): {}",
output.lines().take(2).collect::<Vec<_>>().join(" | ")
),
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]"),
AgentEvent::Error { message } => eprintln!("\n[error] {message}"),
_ => {}
}
}
let _ = join.await;
Ok(())
}
fn render_tool_end(
name: &str,
exit_code: i32,
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)) = (
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 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::<Vec<_>>().join("\n");
if head.trim().is_empty() {
return header;
}
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)
)
}
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,
})
}
fn arg_summary(args: &serde_json::Value) -> Option<String> {
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
}
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<String> = 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()
} else {
let head: String = s.chars().take(max).collect();
format!("{head}…")
}
}
fn find_config() -> Option<String> {
if let Ok(p) = std::env::var("A3S_CONFIG_FILE") {
if !p.is_empty() {
return Some(p);
}
}
if let Ok(cwd) = std::env::current_dir() {
let mut dir: Option<&std::path::Path> = Some(cwd.as_path());
while let Some(d) = dir {
let candidate = d.join(".a3s/config.acl");
if candidate.is_file() {
return Some(candidate.to_string_lossy().into_owned());
}
dir = d.parent();
}
}
if let Some(home) = std::env::var_os("HOME") {
let candidate = std::path::Path::new(&home).join(".a3s/config.acl");
if candidate.is_file() {
return Some(candidate.to_string_lossy().into_owned());
}
}
None
}
pub async fn run() -> anyhow::Result<()> {
let config_path = find_config().ok_or_else(|| {
anyhow::anyhow!(
"no A3S config found.\n\nLooked for: $A3S_CONFIG_FILE, .a3s/config.acl in this \
directory or a parent, and ~/.a3s/config.acl.\n\nCreate one, e.g.:\n \
mkdir -p ~/.a3s\n $EDITOR ~/.a3s/config.acl\n\nMinimal example:\n \
default_model = \"openai/gpt-4o\"\n providers \"openai\" {{\n apiKey = \
\"sk-...\"\n }}\n\nOr point at an existing file: A3S_CONFIG_FILE=/path/config.acl a3s code"
)
})?;
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 store_dir = std::path::Path::new(&workspace).join(".a3s/tui-sessions");
let store: Arc<dyn a3s_code_core::store::SessionStore> = 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 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,
Err(_) => agent.session(
workspace.clone(),
Some(
SessionOptions::new()
.with_session_store(store.clone())
.with_session_id(SESSION_ID)
.with_confirmation_policy(confirmation)
.with_auto_save(true),
),
)?,
};
let initial_messages: Vec<String> = 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);
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)),
thinking: String::new(),
state: State::Idle,
messages: initial_messages,
rx: None,
modal: None,
pending_tool: None,
history: Vec::new(),
history_pos: None,
model: None,
total_tokens: 0,
tool_args: String::new(),
tool_output: String::new(),
auto_approve: false,
cwd: workspace,
width,
height,
keymap,
};
ProgramBuilder::new(app)
.with_alt_screen()
.with_mouse_support()
.with_fps(30)
.run()
.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), 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");
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, None, 80);
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), 80);
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);
}
}