use serde_json::Value;
use crate::adapters::OutputFormat;
const DETAIL_CHARS: usize = 120;
pub(crate) fn progress_lines(format: OutputFormat, kind: &str, event: &Value) -> Vec<String> {
match format {
OutputFormat::Text => Vec::new(),
OutputFormat::ClaudeStreamJson => claude(kind, event),
OutputFormat::CodexJsonl => codex(kind, event),
OutputFormat::PiJson => pi(kind, event),
}
}
fn claude(kind: &str, event: &Value) -> Vec<String> {
if kind != "assistant" {
return Vec::new();
}
let Some(parts) = event.pointer("/message/content").and_then(Value::as_array) else {
return Vec::new();
};
parts
.iter()
.filter_map(|part| match part.get("type").and_then(Value::as_str) {
Some("tool_use") => {
let name = part.get("name").and_then(Value::as_str)?;
Some(claude_tool(name, part.get("input").unwrap_or(&Value::Null)))
}
Some("text") => {
let text = part.get("text").and_then(Value::as_str)?;
let first = text.lines().find(|line| !line.trim().is_empty())?;
Some(detail(first))
}
_ => None,
})
.filter(|line| !line.is_empty())
.collect()
}
fn claude_tool(name: &str, input: &Value) -> String {
let field = |key: &str| input.get(key).and_then(Value::as_str);
match name {
"Bash" => field("command").map_or_else(|| name.to_owned(), shell),
"Read" | "Write" | "Edit" | "MultiEdit" => field("file_path").map_or_else(
|| name.to_owned(),
|path| format!("{name} {}", short_path(path)),
),
"NotebookEdit" => field("notebook_path").map_or_else(
|| name.to_owned(),
|path| format!("{name} {}", short_path(path)),
),
"Glob" | "Grep" => field("pattern").map_or_else(
|| name.to_owned(),
|pattern| format!("{name} {}", detail(pattern)),
),
"WebSearch" => field("query").map_or_else(
|| name.to_owned(),
|query| format!("search: {}", detail(query)),
),
"WebFetch" => field("url").map_or_else(
|| name.to_owned(),
|url| format!("fetch {}", url_without_query(url)),
),
"Task" | "Agent" => field("description").map_or_else(
|| name.to_owned(),
|text| format!("{name}: {}", detail(text)),
),
"TodoWrite" => String::new(),
_ => name.to_owned(),
}
}
fn codex(kind: &str, event: &Value) -> Vec<String> {
let Some(item) = event.get("item") else {
return Vec::new();
};
let field = |key: &str| item.get(key).and_then(Value::as_str);
let line = match (kind, field("type").unwrap_or("")) {
("item.started", "command_execution") => field("command").map(shell),
("item.completed", "command_execution") => item
.get("exit_code")
.and_then(Value::as_i64)
.filter(|code| *code != 0)
.map(|code| match field("command") {
Some(command) => format!("exit {code}: {}", strip_shell(command)),
None => format!("exit {code}"),
}),
("item.completed", "file_change") => {
let changes = item.get("changes").and_then(Value::as_array);
let lines: Vec<String> = changes
.into_iter()
.flatten()
.filter_map(|change| {
let path = change.get("path").and_then(Value::as_str)?;
let action = change.get("kind").and_then(Value::as_str).unwrap_or("edit");
Some(format!("{action} {}", short_path(path)))
})
.collect();
return lines;
}
("item.completed", "web_search") => field("query")
.filter(|query| !query.trim().is_empty())
.map(|query| format!("search: {}", detail(query))),
("item.started", "mcp_tool_call") => match (field("server"), field("tool")) {
(Some(server), Some(tool)) => Some(format!("{server}.{tool}")),
(_, Some(tool)) => Some(tool.to_owned()),
_ => None,
},
_ => None,
};
line.into_iter().collect()
}
fn pi(kind: &str, event: &Value) -> Vec<String> {
let name = event
.get("toolName")
.and_then(Value::as_str)
.unwrap_or("tool");
match kind {
"tool_execution_start" => {
let args = event.get("args").unwrap_or(&Value::Null);
let field = |key: &str| args.get(key).and_then(Value::as_str);
let line = match name {
"bash" => field("command").map(shell),
"read" | "write" | "edit" => field("path")
.or_else(|| field("file_path"))
.map(|path| format!("{name} {}", short_path(path))),
"grep" | "find" => {
field("pattern").map(|pattern| format!("{name} {}", detail(pattern)))
}
_ => None,
};
vec![line.unwrap_or_else(|| name.to_owned())]
}
"tool_execution_end" if event.get("isError").and_then(Value::as_bool) == Some(true) => {
vec![format!("{name} failed")]
}
_ => Vec::new(),
}
}
fn shell(command: &str) -> String {
format!("$ {}", strip_shell(command))
}
fn strip_shell(command: &str) -> String {
let trimmed = command.trim();
let inner = trimmed
.split_once(' ')
.filter(|(program, _)| {
let base = program.rsplit('/').next().unwrap_or(program);
matches!(base, "bash" | "zsh" | "sh" | "dash")
})
.and_then(|(_, rest)| {
let rest = rest.trim_start();
rest.strip_prefix("-lc ")
.or_else(|| rest.strip_prefix("-c "))
.map(str::trim)
})
.map(|rest| {
for quote in ['\'', '"'] {
if let Some(unquoted) = rest
.strip_prefix(quote)
.and_then(|value| value.strip_suffix(quote))
{
return unquoted;
}
}
rest
})
.unwrap_or(trimmed);
detail(inner)
}
fn short_path(path: &str) -> String {
let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
let tail = if parts.len() > 2 {
format!("…/{}", parts[parts.len() - 2..].join("/"))
} else {
path.to_owned()
};
detail(&tail)
}
fn url_without_query(url: &str) -> String {
let end = url.find(['?', '#']).unwrap_or(url.len());
detail(&url[..end])
}
fn detail(text: &str) -> String {
let line = redact(&text.split_whitespace().collect::<Vec<_>>().join(" "));
if line.chars().count() <= DETAIL_CHARS {
return line;
}
let mut cut: String = line.chars().take(DETAIL_CHARS - 1).collect();
cut.push('…');
cut
}
const SECRET_NAMES: [&str; 8] = [
"key",
"token",
"secret",
"password",
"passwd",
"authorization",
"credential",
"cookie",
];
const SECRET_PREFIXES: [&str; 9] = [
"sk-",
"sk_",
"ghp_",
"gho_",
"ghs_",
"github_pat_",
"xoxb-",
"xoxp-",
"AKIA",
];
pub(crate) fn redact(text: &str) -> String {
let words: Vec<&str> = text.split(' ').collect();
let mut output = Vec::with_capacity(words.len());
let mut hide_next = false;
for word in words {
let lower = word.to_ascii_lowercase();
let bare = lower.trim_matches(|character: char| {
matches!(character, '"' | '\'' | '-' | ':' | '(' | ')' | ',')
});
if bare == "bearer" || bare == "basic" {
output.push(word.to_owned());
hide_next = true;
continue;
}
if std::mem::take(&mut hide_next) && !word.is_empty() {
output.push("…".to_owned());
continue;
}
if let Some((name, value)) = word.split_once(['=', ':'])
&& !value.starts_with("//")
&& SECRET_NAMES
.iter()
.any(|secret| name.to_ascii_lowercase().contains(secret))
{
if value.trim_matches(['"', '\'']).is_empty() {
output.push(word.to_owned());
hide_next = true;
} else {
let separator = &word[name.len()..=name.len()];
output.push(format!("{name}{separator}…"));
}
continue;
}
if word.starts_with("--") && SECRET_NAMES.iter().any(|secret| bare.contains(secret)) {
output.push(word.to_owned());
hide_next = true;
continue;
}
let token = word.trim_matches(|character: char| matches!(character, '"' | '\''));
if SECRET_PREFIXES
.iter()
.any(|prefix| token.starts_with(prefix) && token.len() >= prefix.len() + 8)
{
output.push("…".to_owned());
continue;
}
output.push(word.to_owned());
}
output.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn codex_reports_commands_files_and_searches() {
let started = json!({"type":"item.started","item":{"type":"command_execution","command":"/usr/bin/zsh -lc 'cargo test -p scv-tools'","aggregated_output":"secret output","exit_code":null}});
assert_eq!(
progress_lines(OutputFormat::CodexJsonl, "item.started", &started),
["$ cargo test -p scv-tools"]
);
let failed = json!({"type":"item.completed","item":{"type":"command_execution","command":"bash -lc \"false\"","aggregated_output":"boom","exit_code":1}});
assert_eq!(
progress_lines(OutputFormat::CodexJsonl, "item.completed", &failed),
["exit 1: false"]
);
let succeeded = json!({"type":"item.completed","item":{"type":"command_execution","command":"ls","exit_code":0}});
assert!(progress_lines(OutputFormat::CodexJsonl, "item.completed", &succeeded).is_empty());
let files = json!({"type":"item.completed","item":{"type":"file_change","changes":[{"path":"/home/u/projects/scv/src/main.rs","kind":"update"},{"path":"note.txt","kind":"add"}]}});
assert_eq!(
progress_lines(OutputFormat::CodexJsonl, "item.completed", &files),
["update …/src/main.rs", "add note.txt"]
);
let search = json!({"type":"item.completed","item":{"type":"web_search","query":"latest serde version"}});
assert_eq!(
progress_lines(OutputFormat::CodexJsonl, "item.completed", &search),
["search: latest serde version"]
);
let message =
json!({"type":"item.completed","item":{"type":"agent_message","text":"final answer"}});
assert!(progress_lines(OutputFormat::CodexJsonl, "item.completed", &message).is_empty());
}
#[test]
fn claude_reports_tool_use_and_brief_text_but_not_results() {
let assistant = json!({"type":"assistant","message":{"content":[
{"type":"text","text":"I'll list the files.\nThen more detail."},
{"type":"tool_use","name":"Bash","input":{"command":"ls -la","description":"List"}},
{"type":"tool_use","name":"Edit","input":{"file_path":"/w/crates/core/src/lib.rs","old_string":"a","new_string":"b"}},
{"type":"tool_use","name":"WebFetch","input":{"url":"https://docs.rs/serde?token=abc","prompt":"x"}},
{"type":"tool_use","name":"TodoWrite","input":{"todos":[]}},
{"type":"tool_use","name":"mcp__github__search","input":{}}
]}});
assert_eq!(
progress_lines(OutputFormat::ClaudeStreamJson, "assistant", &assistant),
[
"I'll list the files.",
"$ ls -la",
"Edit …/src/lib.rs",
"fetch https://docs.rs/serde",
"mcp__github__search",
]
);
let result = json!({"type":"user","message":{"content":[{"type":"tool_result","content":"private output"}]}});
assert!(progress_lines(OutputFormat::ClaudeStreamJson, "user", &result).is_empty());
}
#[test]
fn pi_reports_tool_starts_and_failures() {
let bash = json!({"type":"tool_execution_start","toolName":"bash","args":{"command":"ls","timeout":10}});
assert_eq!(
progress_lines(OutputFormat::PiJson, "tool_execution_start", &bash),
["$ ls"]
);
let write = json!({"type":"tool_execution_start","toolName":"write","args":{"path":"pi.txt","content":"hi"}});
assert_eq!(
progress_lines(OutputFormat::PiJson, "tool_execution_start", &write),
["write pi.txt"]
);
let failed = json!({"type":"tool_execution_end","toolName":"bash","result":{"content":[{"type":"text","text":"out"}]},"isError":true});
assert_eq!(
progress_lines(OutputFormat::PiJson, "tool_execution_end", &failed),
["bash failed"]
);
let update = json!({"type":"tool_execution_update","toolName":"bash","partialResult":{"content":[{"type":"text","text":"out"}]}});
assert!(progress_lines(OutputFormat::PiJson, "tool_execution_update", &update).is_empty());
}
#[test]
fn credentials_are_redacted() {
assert_eq!(
redact("curl -H Authorization: Bearer abc.def https://x"),
"curl -H Authorization: Bearer … https://x"
);
assert_eq!(
redact("OPENAI_API_KEY=sk-live-123 run"),
"OPENAI_API_KEY=… run"
);
assert_eq!(
redact("tool --api-key s3cret --verbose"),
"tool --api-key … --verbose"
);
assert_eq!(
redact("echo sk-abcdefghijklmnop ghp_0123456789abcdef"),
"echo … …"
);
assert_eq!(redact("cargo test -p scv-tools"), "cargo test -p scv-tools");
assert_eq!(redact("git log --oneline -3"), "git log --oneline -3");
assert_eq!(
codex(
"item.started",
&json!({"item":{"type":"command_execution","command":"bash -lc 'curl -H \"Authorization: Bearer tok123\" https://api'"}})
),
["$ curl -H \"Authorization: Bearer … https://api"]
);
}
#[test]
fn details_are_one_bounded_line() {
let long = format!("echo {}", "x".repeat(400));
let line = shell(&long);
assert!(line.chars().count() <= DETAIL_CHARS + 2);
assert!(line.ends_with('…'));
assert_eq!(detail("a\n b\tc"), "a b c");
}
}