use std::env;
use std::io::{self, IsTerminal, Write};
use niucode_agent::{AgentEvent, ApprovalRequest};
use niucode_tools::{ChangePreview, LineKind, PreviewBody};
use niuse_core::{parse, Parsed, StreamFilter};
fn styled(code: &str, text: &str) -> String {
if io::stdout().is_terminal() && env::var_os("NO_COLOR").is_none() {
format!("\x1b[{code}m{text}\x1b[0m")
} else {
text.to_string()
}
}
fn diff_style(line: &str) -> Option<&'static str> {
if line.starts_with("+++") || line.starts_with("---") {
Some("1")
} else if line.starts_with('+') {
Some("32")
} else if line.starts_with('-') {
Some("31")
} else if line.starts_with("@@") {
Some("36")
} else {
None
}
}
fn paint_diff(line: &str) -> String {
match diff_style(line) {
Some(code) => styled(code, line),
None => line.to_string(),
}
}
pub(crate) fn compact_tokens(tokens: u64) -> String {
if tokens < 1_000 {
return tokens.to_string();
}
let value = tokens as f64 / 1_000.0;
if value >= 10.0 {
format!("{value:.0}k")
} else {
format!("{value:.1}k").replace(".0k", "k")
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct UsageTotals {
pub input: u64,
pub output: u64,
pub cached: u64,
}
pub struct Renderer {
filter: StreamFilter,
at_line_start: bool,
steps: u64,
usage_input: u64,
usage_output: u64,
usage_cached: u64,
}
impl Renderer {
pub fn new() -> Self {
Self {
filter: StreamFilter::new(),
at_line_start: true,
steps: 0,
usage_input: 0,
usage_output: 0,
usage_cached: 0,
}
}
pub fn event(&mut self, event: AgentEvent) {
match event {
AgentEvent::TextDelta(delta) => {
let visible = self.filter.push(&delta);
if !visible.is_empty() {
print!("{visible}");
let _ = io::stdout().flush();
self.at_line_start = visible.ends_with('\n');
}
}
AgentEvent::ReasoningDelta(_) => {}
AgentEvent::ToolCall { name, input } => {
self.newline();
let line = format!("→ {} {}", name, truncate(&input.to_string(), 180));
println!("{}", styled("2", &line));
self.at_line_start = true;
}
AgentEvent::ToolResult {
name, ok, summary, ..
} => {
self.newline();
let mark = if ok { "✓" } else { "✗" };
let line = format!("{mark} {name}: {}", truncate(&summary, 240));
let line = if ok {
styled("2", &line)
} else {
styled("31", &line)
};
println!("{line}");
self.at_line_start = true;
}
AgentEvent::Warning(warning) => {
self.newline();
println!("{}", styled("33", &format!("! {warning}")));
self.at_line_start = true;
}
AgentEvent::Compaction {
before_tokens,
after_tokens,
..
} => {
self.newline();
println!("[compacted {before_tokens} → {after_tokens} tokens]");
self.at_line_start = true;
}
AgentEvent::TurnEnd { usage } => {
self.steps += 1;
self.usage_input += usage.input_tokens;
self.usage_output += usage.output_tokens;
self.usage_cached += usage.cached_tokens;
}
AgentEvent::Phase(_) => {}
}
}
pub fn usage(&self) -> UsageTotals {
UsageTotals {
input: self.usage_input,
output: self.usage_output,
cached: self.usage_cached,
}
}
pub fn finish(&mut self) -> Parsed {
let (tail, raw) = self.filter.finish();
if !tail.is_empty() {
print!("{tail}");
let _ = io::stdout().flush();
self.at_line_start = tail.ends_with('\n');
}
self.newline();
if self.steps > 0 {
let mut line = format!(
" {} step{} · {} in · {} out",
self.steps,
if self.steps == 1 { "" } else { "s" },
compact_tokens(self.usage_input),
compact_tokens(self.usage_output)
);
if self.usage_cached > 0 {
line.push_str(&format!(" · {} cached", compact_tokens(self.usage_cached)));
}
println!("{}", styled("2", &line));
}
parse(&raw)
}
fn newline(&mut self) {
if !self.at_line_start {
println!();
let _ = io::stdout().flush();
self.at_line_start = true;
}
}
}
impl Default for Renderer {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApprovalDecision {
Allow,
Deny,
ShowFull,
}
fn parse_approval(input: &str, has_full: bool) -> ApprovalDecision {
match input.trim().to_lowercase().as_str() {
"y" | "yes" => ApprovalDecision::Allow,
"d" | "diff" if has_full => ApprovalDecision::ShowFull,
_ => ApprovalDecision::Deny,
}
}
pub fn approve(request: &ApprovalRequest) -> bool {
println!();
println!(
"{}",
styled(
"1",
&format!("[approval] {}: {}", request.tool, request.summary)
)
);
let mut full = None;
let mut truncated = false;
if let Some(preview) = &request.preview {
let shown = preview_lines(preview, 80);
let all = preview_lines(preview, usize::MAX);
truncated = all.len() > shown.len();
full = Some(all);
for line in &shown {
println!("{}", paint_diff(line));
}
if let Some(warning) = &preview.warning {
println!("{}", styled("33", &format!(" warning: {warning}")));
}
}
loop {
if truncated {
print!("allow? [y/N] (d = full diff) ");
} else {
print!("allow? [y/N] ");
}
let _ = io::stdout().flush();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_err() {
return false;
}
match parse_approval(&line, truncated) {
ApprovalDecision::Allow => return true,
ApprovalDecision::Deny => return false,
ApprovalDecision::ShowFull => {
if let Some(lines) = &full {
for line in lines {
println!("{}", paint_diff(line));
}
}
}
}
}
}
pub fn read_line() -> Option<String> {
let mut line = String::new();
match io::stdin().read_line(&mut line) {
Ok(0) => None,
Ok(_) => Some(line.trim_end().to_string()),
Err(_) => None,
}
}
fn preview_lines(preview: &ChangePreview, max_lines: usize) -> Vec<String> {
let mut lines = Vec::new();
match &preview.body {
PreviewBody::Diff { files } => {
for (index, file) in files.iter().enumerate() {
if index > 0 {
lines.push(String::new());
}
let marker = if file.is_new { " (new)" } else { "" };
lines.push(format!("--- {} {}", file.path, marker));
for hunk in &file.hunks {
lines.push(format!(
"@@ -{},{} +{},{} @@",
hunk.old_start, hunk.old_len, hunk.new_start, hunk.new_len
));
for line in &hunk.lines {
let prefix = match line.kind {
LineKind::Context => ' ',
LineKind::Add => '+',
LineKind::Remove => '-',
};
lines.push(format!("{prefix}{}", line.text.trim_end_matches('\n')));
}
}
}
}
PreviewBody::Command { command } => lines.push(format!("$ {command}")),
PreviewBody::Note { message } => lines.push(message.clone()),
}
if lines.len() > max_lines {
let omitted = lines.len() - max_lines;
lines.truncate(max_lines);
lines.push(format!("… {omitted} more line(s)"));
}
lines
}
fn truncate(text: &str, max_chars: usize) -> String {
let mut out = String::new();
for (count, character) in text.chars().enumerate() {
if count >= max_chars {
out.push('…');
break;
}
if character == '\n' {
out.push(' ');
} else {
out.push(character);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use niucode_tools::{ChangePreview, FilePreview, PreviewBody};
#[test]
fn diff_preview_shows_hunks_and_marks_additions() {
let file = FilePreview::from_text("a.txt", "alpha\nbeta\n", "alpha\ngamma\n", false);
let preview = ChangePreview {
added: file.added,
removed: file.removed,
warning: None,
body: PreviewBody::Diff { files: vec![file] },
};
let lines = preview_lines(&preview, 80);
assert!(lines.iter().any(|line| line == "-beta"));
assert!(lines.iter().any(|line| line == "+gamma"));
assert!(lines.iter().any(|line| line.starts_with("@@ ")));
}
#[test]
fn long_previews_are_capped() {
let old = (0..200)
.map(|i| format!("old {i}"))
.collect::<Vec<_>>()
.join("\n");
let new = (0..200)
.map(|i| format!("new {i}"))
.collect::<Vec<_>>()
.join("\n");
let file = FilePreview::from_text("big.txt", &old, &new, false);
let preview = ChangePreview {
added: file.added,
removed: file.removed,
warning: None,
body: PreviewBody::Diff { files: vec![file] },
};
let lines = preview_lines(&preview, 10);
assert_eq!(lines.len(), 11);
assert!(lines.last().unwrap().contains("more line"));
}
}