use std::io::Write;
use crate::tool_display::format_tool_block;
use super::{
ASSISTANT_FLUSH_BYTE_THRESHOLD, THINKING_FLUSH_BYTE_THRESHOLD, format_pending_tool_block,
pending_tool_display_summary,
};
use crate::output::{OutputEvent, StreamingRedactor, redact_sensitive_text};
pub(crate) trait OutputRenderer {
fn render(&mut self, event: &OutputEvent) -> anyhow::Result<()>;
fn finish(&mut self) -> anyhow::Result<()>;
}
pub(crate) struct PlainOutputRenderer<'a, W: Write> {
writer: &'a mut W,
wrote_stream: bool,
at_line_start: bool,
active_thinking_text: String,
active_thinking_redactor: StreamingRedactor,
active_thinking_open: bool,
active_thinking_unflushed_bytes: usize,
assistant_redactor: StreamingRedactor,
assistant_unflushed_bytes: usize,
}
impl<'a, W: Write> PlainOutputRenderer<'a, W> {
pub(crate) fn new(writer: &'a mut W) -> Self {
Self {
writer,
wrote_stream: false,
at_line_start: true,
active_thinking_text: String::new(),
active_thinking_redactor: StreamingRedactor::default(),
active_thinking_open: false,
active_thinking_unflushed_bytes: 0,
assistant_redactor: StreamingRedactor::default(),
assistant_unflushed_bytes: 0,
}
}
pub(crate) fn render_assistant_delta(&mut self, text: &str) -> anyhow::Result<()> {
self.flush_thinking()?;
let emitted = self.assistant_redactor.push(text);
self.write_assistant_visible(&emitted)?;
self.flush_assistant_if_needed(text)
}
fn write_assistant_visible(&mut self, text: &str) -> anyhow::Result<()> {
if text.is_empty() {
return Ok(());
}
self.writer.write_all(text.as_bytes())?;
self.wrote_stream = true;
self.at_line_start = text.ends_with('\n');
Ok(())
}
fn drain_assistant(&mut self) -> anyhow::Result<()> {
let text = self.assistant_redactor.drain();
self.write_assistant_visible(&text)?;
if self.assistant_unflushed_bytes > 0 || !text.is_empty() {
self.writer.flush()?;
self.assistant_unflushed_bytes = 0;
}
Ok(())
}
fn flush_assistant(&mut self) -> anyhow::Result<()> {
let text = self.assistant_redactor.flush();
self.write_assistant_visible(&text)?;
if self.assistant_unflushed_bytes > 0 || !text.is_empty() {
self.writer.flush()?;
self.assistant_unflushed_bytes = 0;
}
Ok(())
}
fn flush_assistant_if_needed(&mut self, text: &str) -> anyhow::Result<()> {
self.assistant_unflushed_bytes = self.assistant_unflushed_bytes.saturating_add(text.len());
if text.contains('\n') || self.assistant_unflushed_bytes >= ASSISTANT_FLUSH_BYTE_THRESHOLD {
self.drain_assistant()?;
}
Ok(())
}
fn write_thinking_visible(&mut self, text: &str) -> anyhow::Result<()> {
if text.is_empty() {
return Ok(());
}
self.writer.write_all(text.as_bytes())?;
self.active_thinking_text.push_str(text);
self.wrote_stream = true;
self.at_line_start = text.ends_with('\n');
Ok(())
}
fn flush_active_thinking_redactor(&mut self) -> anyhow::Result<()> {
let text = self.active_thinking_redactor.flush();
self.write_thinking_visible(&text)
}
fn render_thinking_delta(&mut self, text: &str) -> anyhow::Result<()> {
self.flush_assistant()?;
if !self.active_thinking_open {
if !self.at_line_start {
writeln!(self.writer)?;
}
writeln!(self.writer, "--- THINKING SUMMARY ---")?;
self.active_thinking_open = true;
self.at_line_start = true;
}
let emitted = self.active_thinking_redactor.push(text);
self.write_thinking_visible(&emitted)?;
self.active_thinking_unflushed_bytes = self
.active_thinking_unflushed_bytes
.saturating_add(text.len());
if text.contains('\n')
|| self.active_thinking_unflushed_bytes >= THINKING_FLUSH_BYTE_THRESHOLD
{
let text = self.active_thinking_redactor.drain();
self.write_thinking_visible(&text)?;
self.writer.flush()?;
self.active_thinking_unflushed_bytes = 0;
}
Ok(())
}
fn render_thinking_complete(&mut self, text: &str) -> anyhow::Result<()> {
self.flush_active_thinking_redactor()?;
let text = redact_sensitive_text(text);
if !self.active_thinking_open {
return self.render_tool_block(&format!(
"--- THINKING SUMMARY ---\n{text}\n--- END THINKING SUMMARY ---\n"
));
}
if let Some(suffix) = text.strip_prefix(&self.active_thinking_text) {
if !suffix.is_empty() {
self.writer.write_all(suffix.as_bytes())?;
}
if !self.at_line_start || !suffix.ends_with('\n') {
writeln!(self.writer)?;
}
} else {
if !self.at_line_start {
writeln!(self.writer)?;
}
writeln!(self.writer, "--- AUTHORITATIVE THINKING SUMMARY ---")?;
self.writer.write_all(text.as_bytes())?;
if !text.ends_with('\n') {
writeln!(self.writer)?;
}
writeln!(self.writer, "--- END AUTHORITATIVE THINKING SUMMARY ---")?;
self.writer.flush()?;
self.active_thinking_text.clear();
self.active_thinking_open = false;
self.wrote_stream = true;
self.at_line_start = true;
return Ok(());
}
writeln!(self.writer, "--- END THINKING SUMMARY ---")?;
self.writer.flush()?;
self.active_thinking_unflushed_bytes = 0;
self.active_thinking_text.clear();
self.active_thinking_open = false;
self.wrote_stream = true;
self.at_line_start = true;
Ok(())
}
fn flush_thinking(&mut self) -> anyhow::Result<()> {
if self.active_thinking_open {
self.flush_active_thinking_redactor()?;
if !self.at_line_start {
writeln!(self.writer)?;
}
writeln!(self.writer, "--- END THINKING SUMMARY ---")?;
self.writer.flush()?;
self.active_thinking_unflushed_bytes = 0;
self.active_thinking_text.clear();
self.active_thinking_open = false;
self.at_line_start = true;
}
Ok(())
}
pub(crate) fn render_tool_block(&mut self, block: &str) -> anyhow::Result<()> {
self.flush_thinking()?;
self.flush_assistant()?;
if !self.at_line_start {
writeln!(self.writer)?;
}
self.writer.write_all(block.as_bytes())?;
if !block.ends_with('\n') {
writeln!(self.writer)?;
}
self.writer.flush()?;
self.wrote_stream = true;
self.at_line_start = true;
Ok(())
}
}
impl<W: Write> OutputRenderer for PlainOutputRenderer<'_, W> {
fn render(&mut self, event: &OutputEvent) -> anyhow::Result<()> {
match event {
OutputEvent::SessionHeader { .. }
| OutputEvent::UserPrompt { .. }
| OutputEvent::BashCommand { .. }
| OutputEvent::ContextUsage { .. }
| OutputEvent::AssistantComplete { .. }
| OutputEvent::ProviderContextInjection { .. } => Ok(()),
OutputEvent::SubdirInstructionInjection { path, .. } => {
self.render_tool_block(&format!(
"Loaded subdirectory instructions: {}\n",
redact_sensitive_text(&path.display().to_string())
))
}
OutputEvent::ThinkingSummaryDelta { text } => self.render_thinking_delta(text),
OutputEvent::ThinkingSummaryComplete { text } => self.render_thinking_complete(text),
OutputEvent::ThinkingSummaryCompleteIdentified { text, .. } => {
self.render_thinking_complete(text)
}
OutputEvent::ToolStarted { call, .. } => {
if let Some(summary) = pending_tool_display_summary(call) {
let block = format_pending_tool_block(&summary);
self.render_tool_block(&block)?;
}
Ok(())
}
OutputEvent::AssistantDelta { text } => self.render_assistant_delta(text),
OutputEvent::Diagnostic { level, message } => self.render_tool_block(&format!(
"--- {} ---\n{}\n--- END ---\n",
level.to_ascii_uppercase(),
redact_sensitive_text(message)
)),
OutputEvent::HookDiagnostic { diagnostic } => self.render_tool_block(&format!(
"--- HOOK WARNING: {} ---\n{}\n--- HOOK END ---\n",
diagnostic.tool_name,
diagnostic.sanitized_message()
)),
OutputEvent::ToolResult { call, result, .. } => {
let block = format_tool_block(call, result);
self.render_tool_block(&block)
}
}
}
fn finish(&mut self) -> anyhow::Result<()> {
self.flush_thinking()?;
self.flush_assistant()?;
if self.wrote_stream && !self.at_line_start {
writeln!(self.writer)?;
self.at_line_start = true;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{output::tool_display_summary, providers::ToolCall, tools::ToolResult};
use serde_json::{Value, json};
fn call(name: &str, arguments: Value) -> ToolCall {
ToolCall {
id: "call_1".to_string(),
name: name.to_string(),
arguments,
}
}
fn result(name: &str, success: bool, content: &str, metadata: Value) -> ToolResult {
ToolResult {
tool_name: name.to_string(),
success,
content: content.to_string(),
metadata,
display: crate::tools::ToolResultDisplay::default(),
}
}
struct CountingWriter {
bytes: Vec<u8>,
writes: usize,
flushes: usize,
}
impl CountingWriter {
fn new() -> Self {
Self {
bytes: Vec::new(),
writes: 0,
flushes: 0,
}
}
fn text(&self) -> String {
String::from_utf8(self.bytes.clone()).unwrap()
}
}
impl Write for CountingWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.writes += 1;
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.flushes += 1;
Ok(())
}
}
#[test]
fn plain_fixed_stream_has_equivalent_bytes_and_bounded_flushes() {
fn run(chunks: &[&str]) -> (String, usize, usize) {
let mut output = CountingWriter::new();
{
let mut renderer = PlainOutputRenderer::new(&mut output);
for chunk in chunks {
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: (*chunk).to_string(),
})
.unwrap();
}
renderer
.render(&OutputEvent::ThinkingSummaryComplete {
text: "α line\nauthoritative completion".to_string(),
})
.unwrap();
renderer.finish().unwrap();
}
(output.text(), output.writes, output.flushes)
}
let text = "α line\nthinking Bearer sentinelPlainToken123";
let coarse = run(&[text]);
let tiny_chunks = text
.char_indices()
.map(|(start, ch)| {
let end = start + ch.len_utf8();
&text[start..end]
})
.collect::<Vec<_>>();
let tiny = run(&tiny_chunks);
assert_eq!(coarse.0, tiny.0);
assert!(!tiny.0.contains("sentinelPlainToken123"));
assert!(tiny.1 < tiny_chunks.len(), "tiny writes: {}", tiny.1);
assert!(tiny.2 < tiny_chunks.len(), "tiny flushes: {}", tiny.2);
eprintln!(
"plain fixed stream: coarse writes/flushes={}/{} tiny={}/{}",
coarse.1, coarse.2, tiny.1, tiny.2
);
}
#[test]
fn plain_renderer_coalesces_assistant_delta_flushes_and_preserves_order() {
let call = call("read", json!({"path":"file.txt"}));
let result = result("read", true, "ok", json!({}));
let summary = tool_display_summary(&call, &result);
let mut output = CountingWriter::new();
{
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::AssistantDelta {
text: "a".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "b".to_string(),
})
.unwrap();
assert_eq!(renderer.writer.flushes, 0);
renderer
.render(&OutputEvent::ToolResult {
call: Box::new(call),
result: Box::new(result),
summary: Box::new(summary),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "c".to_string(),
})
.unwrap();
renderer.finish().unwrap();
}
let text = output.text();
assert!(text.contains("ab\nSTATUS: success"));
assert!(text.contains("OUTPUT:\nok\nc\n"));
assert!(output.flushes < 4);
}
#[test]
fn plain_renderer_redacts_split_assistant_secret_across_deltas() {
let assistant_secret = "sentinelAssistantToken123";
let thinking_secret = "sentinelThinkingToken123";
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::AssistantDelta {
text: "assistant api_".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "key=".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: format!("{assistant_secret}\n"),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: "thinking Bear".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: "er ".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: format!("{thinking_secret}\n"),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryComplete {
text: format!("thinking Bearer {thinking_secret}\n"),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("api_key=<redacted>"), "{text}");
assert!(text.contains("Bearer <redacted>"), "{text}");
assert!(!text.contains(assistant_secret), "{text}");
assert!(!text.contains(thinking_secret), "{text}");
}
#[test]
fn plain_renderer_reconciles_thinking_prefix_and_divergence() {
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: "streamed".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryComplete {
text: "different authoritative".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "answer".to_string(),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("different authoritative"), "{text}");
assert!(
text.contains("--- AUTHORITATIVE THINKING SUMMARY ---"),
"{text}"
);
assert!(
text.contains("--- END AUTHORITATIVE THINKING SUMMARY ---"),
"{text}"
);
assert!(text.ends_with("answer\n"), "{text}");
}
#[test]
fn plain_renderer_drains_split_quoted_secrets_on_newline_and_threshold() {
for (first, second) in [
("api_key=\"first\n", "second\" suffix"),
("api_key='first\n", "second' suffix"),
(&format!("api_key=\"{}", "x".repeat(1024)), "tail\" suffix"),
(&format!("api_key='{}", "x".repeat(1024)), "tail' suffix"),
] {
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::AssistantDelta {
text: first.to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: second.to_string(),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert_eq!(text.matches("<redacted>").count(), 1, "{text}");
assert!(!text.contains("first"), "{text}");
assert!(!text.contains("second"), "{text}");
assert!(!text.contains(&"x".repeat(1024)), "{text}");
assert!(text.contains("suffix\n"), "{text}");
}
}
#[test]
fn plain_renderer_redacts_divergent_authoritative_thinking() {
let secret = "sentinelDivergentToken123";
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: "streamed\n".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryComplete {
text: format!("different Bearer {secret}\n"),
})
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Bearer <redacted>"), "{text}");
assert!(!text.contains(secret), "{text}");
}
}