223 lines
6.9 KiB
Rust
223 lines
6.9 KiB
Rust
|
|
use std::collections::HashMap;
|
||
|
|
use std::io::{self, BufRead, BufReader, Write};
|
||
|
|
use std::path::Path;
|
||
|
|
use std::process::ChildStdout;
|
||
|
|
|
||
|
|
use crate::json::{extract_num, extract_str, unescape_json};
|
||
|
|
|
||
|
|
const RESET: &str = "\x1b[0m";
|
||
|
|
const BOLD: &str = "\x1b[1m";
|
||
|
|
const DIM: &str = "\x1b[2m";
|
||
|
|
const GREEN: &str = "\x1b[38;5;46m";
|
||
|
|
const ORANGE: &str = "\x1b[38;5;208m";
|
||
|
|
const BLUE: &str = "\x1b[38;5;75m";
|
||
|
|
const CYAN: &str = "\x1b[38;5;80m";
|
||
|
|
const YELLOW: &str = "\x1b[38;5;222m";
|
||
|
|
const MAGENTA: &str = "\x1b[38;5;183m";
|
||
|
|
const RED: &str = "\x1b[38;5;196m";
|
||
|
|
const GRAY: &str = "\x1b[38;5;245m";
|
||
|
|
|
||
|
|
struct StreamState {
|
||
|
|
turn_num: u32,
|
||
|
|
iteration_cost: f64,
|
||
|
|
iteration_duration_ms: f64,
|
||
|
|
tool_counts: HashMap<String, u32>,
|
||
|
|
total_tokens: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl StreamState {
|
||
|
|
fn new() -> Self {
|
||
|
|
Self {
|
||
|
|
turn_num: 0,
|
||
|
|
iteration_cost: 0.0,
|
||
|
|
iteration_duration_ms: 0.0,
|
||
|
|
tool_counts: HashMap::new(),
|
||
|
|
total_tokens: 0,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn format_tool_call(tool_name: &str, input: &str) -> String {
|
||
|
|
match tool_name {
|
||
|
|
"read" => {
|
||
|
|
let path = extract_str(input, "filePath").unwrap_or("?");
|
||
|
|
format!("{}{}Read:{} {}{}{}", BOLD, CYAN, RESET, DIM, path, RESET)
|
||
|
|
}
|
||
|
|
"write" => {
|
||
|
|
let path = extract_str(input, "filePath").unwrap_or("?");
|
||
|
|
format!("{}{}Write:{} {}{}{}", BOLD, YELLOW, RESET, DIM, path, RESET)
|
||
|
|
}
|
||
|
|
"apply_patch" => {
|
||
|
|
format!("{}{}ApplyPatch{}{}", BOLD, YELLOW, RESET, RESET)
|
||
|
|
}
|
||
|
|
"bash" => {
|
||
|
|
let cmd = extract_str(input, "command").unwrap_or("?");
|
||
|
|
if cmd.len() > 80 {
|
||
|
|
format!(
|
||
|
|
"{}{}Bash:{} {}{}...{}",
|
||
|
|
BOLD,
|
||
|
|
MAGENTA,
|
||
|
|
RESET,
|
||
|
|
DIM,
|
||
|
|
&cmd.chars().take(77).collect::<String>(),
|
||
|
|
RESET
|
||
|
|
)
|
||
|
|
} else {
|
||
|
|
format!("{}{}Bash:{} {}{}{}", BOLD, MAGENTA, RESET, DIM, cmd, RESET)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"glob" => {
|
||
|
|
let pat = extract_str(input, "pattern").unwrap_or("?");
|
||
|
|
format!("{}{}Glob:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
|
||
|
|
}
|
||
|
|
"grep" => {
|
||
|
|
let pat = extract_str(input, "pattern").unwrap_or("?");
|
||
|
|
format!("{}{}Grep:{} {}{}{}", BOLD, CYAN, RESET, DIM, pat, RESET)
|
||
|
|
}
|
||
|
|
other => format!("{}{}{}{}", BOLD, BLUE, other, RESET),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn process_line(out: &mut impl Write, line: &str, state: &mut StreamState) -> io::Result<()> {
|
||
|
|
let ev_type = extract_str(line, "type");
|
||
|
|
|
||
|
|
match ev_type {
|
||
|
|
Some("step_start") => {
|
||
|
|
state.turn_num += 1;
|
||
|
|
writeln!(
|
||
|
|
out,
|
||
|
|
"{}{}━━━ Turn {} ━━━{}",
|
||
|
|
BOLD, ORANGE, state.turn_num, RESET
|
||
|
|
)?;
|
||
|
|
}
|
||
|
|
Some("text") => {
|
||
|
|
if let Some(text) = extract_str(line, "text") {
|
||
|
|
let text = unescape_json(text);
|
||
|
|
write!(out, "{}{}{}", DIM, text, RESET)?;
|
||
|
|
out.flush()?;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Some("tool_use") => {
|
||
|
|
let tool_name = extract_str(line, "tool").unwrap_or("?");
|
||
|
|
|
||
|
|
let status = if line.contains("\"status\":\"error\"")
|
||
|
|
|| line.contains("\"status\": \"error\"")
|
||
|
|
{
|
||
|
|
"error"
|
||
|
|
} else if line.contains("\"status\":\"completed\"")
|
||
|
|
|| line.contains("\"status\": \"completed\"")
|
||
|
|
{
|
||
|
|
"completed"
|
||
|
|
} else {
|
||
|
|
"pending"
|
||
|
|
};
|
||
|
|
|
||
|
|
*state.tool_counts.entry(tool_name.to_string()).or_insert(0) += 1;
|
||
|
|
|
||
|
|
if status == "error" {
|
||
|
|
if let Some(error) = extract_str(line, "error") {
|
||
|
|
let error = unescape_json(error);
|
||
|
|
writeln!(
|
||
|
|
out,
|
||
|
|
" {}>> {}{}{} {}{}✗{}",
|
||
|
|
GRAY, RESET, BOLD, tool_name, RESET, RED, RESET
|
||
|
|
)?;
|
||
|
|
writeln!(out, " {}{}{}", RED, error, RESET)?;
|
||
|
|
} else {
|
||
|
|
writeln!(
|
||
|
|
out,
|
||
|
|
" {}>> {}{}{} {}{}✗{}",
|
||
|
|
GRAY, RESET, BOLD, tool_name, RESET, RED, RESET
|
||
|
|
)?;
|
||
|
|
}
|
||
|
|
} else if status == "completed" {
|
||
|
|
let input = extract_str(line, "input").unwrap_or("");
|
||
|
|
let desc = format_tool_call(tool_name, input);
|
||
|
|
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?;
|
||
|
|
writeln!(out, " {}← {}✓{}", GRAY, GREEN, RESET)?;
|
||
|
|
} else {
|
||
|
|
let input = extract_str(line, "input").unwrap_or("");
|
||
|
|
let desc = format_tool_call(tool_name, input);
|
||
|
|
writeln!(out, " {}>>{} {}", GRAY, RESET, desc)?;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Some("step_finish") => {
|
||
|
|
let cost = extract_num(line, "cost").unwrap_or(0.0);
|
||
|
|
state.iteration_cost += cost;
|
||
|
|
|
||
|
|
let tokens = extract_num(line, "total").unwrap_or(0.0) as u64;
|
||
|
|
state.total_tokens = tokens;
|
||
|
|
}
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn format_summary_strip(state: &StreamState) -> String {
|
||
|
|
let mut parts: Vec<String> = Vec::new();
|
||
|
|
|
||
|
|
parts.push(format!(
|
||
|
|
"{} turn{}",
|
||
|
|
state.turn_num,
|
||
|
|
if state.turn_num == 1 { "" } else { "s" }
|
||
|
|
));
|
||
|
|
|
||
|
|
let tool_order = ["bash", "read", "write", "apply_patch", "glob", "grep"];
|
||
|
|
for tool in &tool_order {
|
||
|
|
if let Some(&count) = state.tool_counts.get(*tool) {
|
||
|
|
parts.push(format!("{} {}", count, tool));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (name, &count) in &state.tool_counts {
|
||
|
|
if !tool_order.contains(&name.as_str()) {
|
||
|
|
parts.push(format!("{} {}", count, name));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
parts.push(format!("${:.2}", state.iteration_cost));
|
||
|
|
|
||
|
|
format!("{} ⟪ {} ⟫{}", DIM, parts.join(" │ "), RESET)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn filter_stream(stdout: ChildStdout, log_path: Option<&Path>, _prior_total: f64) -> f64 {
|
||
|
|
let reader = BufReader::new(stdout);
|
||
|
|
let mut state = StreamState::new();
|
||
|
|
let mut log_file = log_path.and_then(|p| {
|
||
|
|
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok();
|
||
|
|
std::fs::File::create(p).ok()
|
||
|
|
});
|
||
|
|
|
||
|
|
let mut out = io::stdout().lock();
|
||
|
|
|
||
|
|
for line_result in reader.lines() {
|
||
|
|
if crate::signal::interrupted() {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
let line = match line_result {
|
||
|
|
Ok(l) => l,
|
||
|
|
Err(_) => break,
|
||
|
|
};
|
||
|
|
|
||
|
|
if line.trim().is_empty() {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if let Some(ref mut f) = log_file {
|
||
|
|
let _ = writeln!(f, "{}", line);
|
||
|
|
}
|
||
|
|
|
||
|
|
if process_line(&mut out, &line, &mut state).is_err() {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if state.turn_num > 0 {
|
||
|
|
let strip = format_summary_strip(&state);
|
||
|
|
let _ = writeln!(out, "{}", strip);
|
||
|
|
}
|
||
|
|
|
||
|
|
let _ = out.flush();
|
||
|
|
state.iteration_cost
|
||
|
|
}
|