use std::io::{self, Write};
use super::style::{Glyph, Sgr, Style};
use crate::canonical::{ContentKind, Delta, Event, FinishReason, Usage};
pub struct PrettySink<O: Write, E: Write> {
out: O,
err: E,
style: Style,
thinking: bool,
pending_sep: bool,
tool: Option<(String, String)>,
usage: Usage,
}
impl<O: Write, E: Write> PrettySink<O, E> {
pub fn new(out: O, err: E, thinking: bool, style: Style) -> Self {
Self {
out,
err,
style,
thinking,
pending_sep: false,
tool: None,
usage: Usage::default(),
}
}
fn flush_tool(&mut self) -> io::Result<()> {
let Some((name, args)) = self.tool.take() else {
return Ok(());
};
writeln!(
self.err,
"{} {} {}",
self.style.paint(Sgr::Yellow, self.style.glyph(Glyph::Tool)),
self.style.paint(Sgr::Bold, &name),
self.style.paint(Sgr::Dim, &args),
)?;
self.err.flush()
}
fn footer(&mut self, reason: &FinishReason) -> io::Result<()> {
let mut line = finish_label(reason);
for (count, label) in [
(self.usage.input_tokens, "in"),
(self.usage.output_tokens, "out"),
(self.usage.cache_read_tokens, "cache_r"),
(self.usage.cache_write_tokens, "cache_w"),
] {
if let Some(n) = count.filter(|n| *n > 0) {
line.push_str(&format!(" · {n} {label}"));
}
}
writeln!(
self.err,
"{} {}",
self.style
.paint(Sgr::Green, self.style.glyph(Glyph::Footer)),
self.style.paint(Sgr::Dim, &line),
)?;
self.err.flush()
}
}
impl<O: Write, E: Write> super::sink::Sink for PrettySink<O, E> {
fn write(&mut self, ev: &Event) -> io::Result<()> {
match ev {
Event::ContentStart {
kind: ContentKind::ToolUse { name, .. },
..
} => {
self.tool = Some((name.clone(), String::new()));
Ok(())
}
Event::ContentDelta {
delta: Delta::JsonDelta(frag),
..
} => {
if let Some((_, args)) = &mut self.tool {
args.push_str(frag);
}
Ok(())
}
Event::ContentDelta {
delta: Delta::ThinkingDelta(text),
..
} if self.thinking => {
self.out
.write_all(self.style.paint(Sgr::Dim, text).as_bytes())?;
self.pending_sep = true;
self.out.flush()
}
Event::ContentDelta {
delta: Delta::TextDelta(text),
..
} => {
if self.pending_sep {
self.out.write_all(b"\n")?;
self.pending_sep = false;
}
self.out.write_all(text.as_bytes())?;
self.out.flush()
}
Event::ContentStop { .. } => self.flush_tool(),
Event::Usage(usage) => {
merge(&mut self.usage.input_tokens, usage.input_tokens);
merge(&mut self.usage.output_tokens, usage.output_tokens);
merge(&mut self.usage.cache_read_tokens, usage.cache_read_tokens);
merge(&mut self.usage.cache_write_tokens, usage.cache_write_tokens);
Ok(())
}
Event::Finish { reason } => self.footer(reason),
Event::Error(err) => {
self.flush_tool()?;
writeln!(
self.err,
"{} {}",
self.style.paint(Sgr::Red, self.style.glyph(Glyph::Error)),
err.message,
)?;
self.err.flush()
}
Event::End => self.flush_tool(),
_ => Ok(()),
}
}
}
fn merge(slot: &mut Option<u32>, next: Option<u32>) {
if next.is_some() {
*slot = next;
}
}
fn finish_label(reason: &FinishReason) -> String {
match reason {
FinishReason::Stop => "stop".to_owned(),
FinishReason::Length => "length".to_owned(),
FinishReason::ToolUse => "tool_use".to_owned(),
FinishReason::StopSequence => "stop_sequence".to_owned(),
FinishReason::Pause => "pause".to_owned(),
FinishReason::Refusal { category, .. } => format!("refusal: {category}"),
FinishReason::Other(reason) => reason.clone(),
}
}