use std::fmt::Display;
use serde_json::Value;
const MAX_DETAIL: usize = 100;
pub fn step(message: impl Display) {
eprintln!("thirdshift: {message}");
}
#[derive(Default)]
pub struct Progress {
started: bool,
cwd: Option<String>,
subscription: bool,
turns_and_cost: Option<(u64, f64)>,
}
impl Progress {
pub fn condense(&mut self, raw: &str) -> Vec<String> {
let Ok(event) = serde_json::from_str::<Value>(raw) else {
return Vec::new();
};
match event["type"].as_str() {
Some("system") if event["subtype"] == "init" && !self.started => {
self.started = true;
self.cwd = event["cwd"].as_str().map(String::from);
self.subscription = event["apiKeySource"] == "none";
vec!["session started".to_string()]
}
Some("assistant") => event["message"]["content"]
.as_array()
.into_iter()
.flatten()
.filter(|block| block["type"] == "tool_use")
.filter_map(|block| Some(self.tool_use(block["name"].as_str()?, &block["input"])))
.collect(),
Some("result") => {
if let (Some(turns), Some(cost)) = (
event["num_turns"].as_u64(),
event["total_cost_usd"].as_f64(),
) {
self.turns_and_cost = Some((turns, cost));
}
Vec::new()
}
_ => Vec::new(),
}
}
pub fn summary(&self) -> Option<String> {
let (turns, cost) = self.turns_and_cost?;
let basis = if self.subscription {
" at API prices"
} else {
""
};
Some(format!("{turns} turns, ${cost:.2}{basis}"))
}
fn tool_use(&self, name: &str, input: &Value) -> String {
match name {
"Skill" => format!("skill {}", input["skill"].as_str().unwrap_or("?")),
"Bash" => bash(input["command"].as_str().unwrap_or("")),
_ => {
let detail = ["file_path", "pattern", "description", "url", "query"]
.iter()
.find_map(|key| input[key].as_str());
match detail {
Some(detail) => format!("{name} {}", shorten(&self.relative(detail))),
None => name.to_string(),
}
}
}
}
fn relative(&self, path: &str) -> String {
self.cwd
.as_deref()
.and_then(|cwd| path.strip_prefix(cwd)?.strip_prefix('/'))
.unwrap_or(path)
.to_string()
}
}
fn bash(command: &str) -> String {
let actions: Vec<&str> = ["commit", "push"]
.into_iter()
.filter(|subcommand| runs_git(command, subcommand))
.collect();
if actions.is_empty() {
format!("$ {}", shorten(command.lines().next().unwrap_or("")))
} else {
actions.join(" and ")
}
}
fn runs_git(command: &str, subcommand: &str) -> bool {
command.split(['\n', ';', '&', '|']).any(|part| {
let mut words = part.split_whitespace();
words.next() == Some("git") && words.next() == Some(subcommand)
})
}
fn shorten(text: &str) -> String {
match text.char_indices().nth(MAX_DETAIL) {
Some((cut, _)) => format!("{}…", &text[..cut]),
None => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn lines(events: &[Value]) -> (Progress, Vec<Vec<String>>) {
let mut progress = Progress::default();
let lines = events
.iter()
.map(|event| progress.condense(&event.to_string()))
.collect();
(progress, lines)
}
fn tool_use(name: &str, input: Value) -> Value {
json!({
"type": "assistant",
"message": { "content": [{ "type": "tool_use", "name": name, "input": input }] }
})
}
fn init(cwd: &str) -> Value {
json!({ "type": "system", "subtype": "init", "cwd": cwd })
}
fn line_for(event: Value) -> Option<String> {
let mut lines = lines(&[init("/work/widgets-issue-7"), event])
.1
.pop()
.unwrap();
assert!(lines.len() <= 1, "several lines: {lines:?}");
lines.pop()
}
fn result(turns: u64, cost: f64) -> Value {
json!({ "type": "result", "num_turns": turns, "total_cost_usd": cost })
}
#[test]
fn only_the_first_init_starts_the_session() {
let (_, lines) = lines(&[init("/a"), init("/a")]);
assert_eq!(lines, [vec!["session started".to_string()], vec![]]);
}
#[test]
fn a_skill_is_named() {
assert_eq!(
line_for(tool_use(
"Skill",
json!({ "skill": "thirdshift:tdd", "args": "x" })
)),
Some("skill thirdshift:tdd".to_string())
);
}
#[test]
fn commits_and_pushes_are_named() {
let line = |command: &str| line_for(tool_use("Bash", json!({ "command": command })));
assert_eq!(line("git commit -m 'x'"), Some("commit".to_string()));
assert_eq!(line("git push -u origin issue-7"), Some("push".to_string()));
assert_eq!(
line("git add -A && git commit -m x && git push"),
Some("commit and push".to_string())
);
}
#[test]
fn mentions_of_commit_or_push_are_not_commits_or_pushes() {
let line = |command: &str| line_for(tool_use("Bash", json!({ "command": command })));
assert_eq!(
line("grep 'git push' README.md"),
Some("$ grep 'git push' README.md".to_string())
);
assert_eq!(
line("git commit-tree HEAD^{tree}"),
Some("$ git commit-tree HEAD^{tree}".to_string())
);
}
#[test]
fn other_commands_show_their_first_line() {
assert_eq!(
line_for(tool_use(
"Bash",
json!({ "command": "cargo test\necho done" })
)),
Some("$ cargo test".to_string())
);
}
#[test]
fn file_tools_show_the_path_relative_to_the_session() {
assert_eq!(
line_for(tool_use(
"Edit",
json!({ "file_path": "/work/widgets-issue-7/src/run.rs" })
)),
Some("Edit src/run.rs".to_string())
);
assert_eq!(
line_for(tool_use("Read", json!({ "file_path": "/etc/hosts" }))),
Some("Read /etc/hosts".to_string())
);
}
#[test]
fn other_tools_show_their_most_telling_input() {
assert_eq!(
line_for(tool_use("Grep", json!({ "pattern": "fn main" }))),
Some("Grep fn main".to_string())
);
assert_eq!(
line_for(tool_use(
"Agent",
json!({ "description": "Standards review" })
)),
Some("Agent Standards review".to_string())
);
assert_eq!(
line_for(tool_use("TodoWrite", json!({ "todos": [] }))),
Some("TodoWrite".to_string())
);
}
#[test]
fn long_details_are_cut_short() {
let line = line_for(tool_use("Bash", json!({ "command": "é".repeat(150) }))).unwrap();
assert_eq!(line, format!("$ {}…", "é".repeat(100)));
}
#[test]
fn several_tool_uses_in_one_message_get_a_line_each() {
let event = json!({
"type": "assistant",
"message": { "content": [
{ "type": "text", "text": "Reading both." },
{ "type": "tool_use", "name": "Read", "input": { "file_path": "a.rs" } },
{ "type": "tool_use", "name": "Read", "input": { "file_path": "b.rs" } }
] }
});
let (_, lines) = lines(&[event]);
assert_eq!(
lines,
[vec!["Read a.rs".to_string(), "Read b.rs".to_string()]]
);
}
#[test]
fn text_tool_results_and_results_give_no_line() {
let (_, lines) = lines(&[
json!({ "type": "assistant", "message": { "content": [{ "type": "text", "text": "hi" }] } }),
json!({ "type": "user", "message": { "content": [{ "type": "tool_result" }] } }),
json!({ "type": "result", "num_turns": 3, "total_cost_usd": 0.1 }),
]);
assert_eq!(lines, [vec![], vec![], vec![]] as [Vec<String>; 3]);
}
#[test]
fn the_summary_is_the_last_result_with_totals() {
let (progress, _) = lines(&[
result(10, 0.5),
result(34, 1.8249),
json!({ "type": "result", "subtype": "success" }),
]);
assert_eq!(progress.summary().as_deref(), Some("34 turns, $1.82"));
}
#[test]
fn on_a_subscription_the_cost_is_marked_as_at_api_prices() {
let (progress, _) = lines(&[
json!({ "type": "system", "subtype": "init", "apiKeySource": "none" }),
result(34, 1.82),
]);
assert_eq!(
progress.summary().as_deref(),
Some("34 turns, $1.82 at API prices")
);
}
#[test]
fn no_result_means_no_summary() {
assert_eq!(Progress::default().summary(), None);
}
#[test]
fn unknown_and_malformed_lines_are_skipped() {
let mut progress = Progress::default();
for raw in [
"",
"not json",
"[1, 2]",
"42",
r#"{"type": "assistant""#,
r#"{"no_type": true}"#,
r#"{"type": 7}"#,
r#"{"type": "stream_event", "event": {}}"#,
r#"{"type": "assistant", "message": {"content": "oops"}}"#,
r#"{"type": "assistant", "message": {"content": [{"type": "tool_use"}]}}"#,
r#"{"type": "result", "num_turns": "many", "total_cost_usd": null}"#,
] {
assert_eq!(progress.condense(raw), Vec::<String>::new(), "{raw}");
}
assert_eq!(progress.summary(), None);
}
}