Skip to content

Commit 6b991da

Browse files
committed
feat(cli): syntax-highlight file/code tool output
When a tool returns file/code content (read/edit on a known extension), render the output as a syntax-highlighted fenced block via a3s-tui's Markdown (syntect), matching how Codex shows file content. Other tool output stays dimmed. Also make the headless smoke probe print tool output for debugging.
1 parent c0916ca commit 6b991da

1 file changed

Lines changed: 70 additions & 16 deletions

File tree

cli/src/main.rs

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ impl App {
415415
&output,
416416
metadata.as_ref(),
417417
args.as_ref(),
418+
self.width as usize,
418419
));
419420
self.tool_args.clear();
420421
}
@@ -602,8 +603,14 @@ async fn run_smoke(session: Arc<AgentSession>) -> anyhow::Result<()> {
602603
AgentEvent::TextDelta { text } => print!("{text}"),
603604
AgentEvent::ToolStart { name, .. } => eprintln!("\n[tool start] {name}"),
604605
AgentEvent::ToolEnd {
605-
name, exit_code, ..
606-
} => eprintln!("[tool end] {name} (exit {exit_code})"),
606+
name,
607+
exit_code,
608+
output,
609+
..
610+
} => eprintln!(
611+
"[tool end] {name} (exit {exit_code}): {}",
612+
output.lines().take(2).collect::<Vec<_>>().join(" | ")
613+
),
607614
AgentEvent::ConfirmationRequired {
608615
tool_id, tool_name, ..
609616
} => {
@@ -629,6 +636,7 @@ fn render_tool_end(
629636
output: &str,
630637
meta: Option<&serde_json::Value>,
631638
args: Option<&serde_json::Value>,
639+
width: usize,
632640
) -> String {
633641
if let Some(meta) = meta {
634642
if let (Some(before), Some(after), Some(path)) = (
@@ -642,17 +650,63 @@ fn render_tool_end(
642650
let status = if exit_code == 0 { "✓" } else { "✗" };
643651
// Show the tool's primary argument (command/path/pattern) so the action log
644652
// reads like Codex — "✓ bash — npm test" rather than just "✓ bash".
645-
let header = match args.and_then(arg_summary) {
646-
Some(summary) => format!(" {status} {name} — {summary}"),
647-
None => format!(" {status} {name}"),
648-
};
649-
let head = output.lines().take(6).collect::<Vec<_>>().join("\n");
650-
let body = if head.trim().is_empty() {
651-
header
652-
} else {
653-
format!("{header}\n{head}")
654-
};
655-
Style::new().fg(Color::BrightBlack).render(&body)
653+
let header = Style::new()
654+
.fg(Color::BrightBlack)
655+
.render(&match args.and_then(arg_summary) {
656+
Some(summary) => format!(" {status} {name} — {summary}"),
657+
None => format!(" {status} {name}"),
658+
});
659+
let head = output.lines().take(8).collect::<Vec<_>>().join("\n");
660+
if head.trim().is_empty() {
661+
return header;
662+
}
663+
// If the output is file/code content (read/edit on a known extension),
664+
// syntax-highlight it; otherwise show it dimmed.
665+
if exit_code == 0 {
666+
if let Some(lang) = args
667+
.and_then(|a| {
668+
a.get("file_path")
669+
.or_else(|| a.get("path"))
670+
.and_then(|v| v.as_str())
671+
})
672+
.and_then(lang_from_path)
673+
{
674+
let fenced = format!("```{lang}\n{head}\n```");
675+
let rendered = a3s_tui::markdown::Markdown::new()
676+
.with_width(width.saturating_sub(4).max(20))
677+
.render(&fenced);
678+
return format!("{header}\n{rendered}");
679+
}
680+
}
681+
format!(
682+
"{header}\n{}",
683+
Style::new().fg(Color::BrightBlack).render(&head)
684+
)
685+
}
686+
687+
/// Map a file path to a syntect language token for fenced rendering.
688+
fn lang_from_path(path: &str) -> Option<&'static str> {
689+
let ext = path.rsplit('.').next()?;
690+
Some(match ext {
691+
"rs" => "rust",
692+
"py" => "python",
693+
"js" | "mjs" | "cjs" => "javascript",
694+
"ts" | "tsx" => "typescript",
695+
"go" => "go",
696+
"json" => "json",
697+
"toml" => "toml",
698+
"yaml" | "yml" => "yaml",
699+
"md" => "markdown",
700+
"sh" | "bash" => "bash",
701+
"c" | "h" => "c",
702+
"cpp" | "cc" | "hpp" => "cpp",
703+
"java" => "java",
704+
"rb" => "ruby",
705+
"html" => "html",
706+
"css" => "css",
707+
"sql" => "sql",
708+
_ => return None,
709+
})
656710
}
657711

658712
/// Extract a one-line summary of a tool's primary argument.
@@ -865,7 +919,7 @@ mod tests {
865919
"before": "let a = 1;\nkeep;\n",
866920
"after": "let a = 2;\nkeep;\n",
867921
});
868-
let out = render_tool_end("edit", 0, "ok", Some(&meta), None);
922+
let out = render_tool_end("edit", 0, "ok", Some(&meta), None, 80);
869923
assert!(out.contains("src/x.rs"), "header has path");
870924
assert!(out.contains("+1") && out.contains("-1"), "add/del counts");
871925
assert!(out.contains("let a = 2;"), "shows inserted line");
@@ -875,15 +929,15 @@ mod tests {
875929

876930
#[test]
877931
fn non_edit_tool_renders_status_line() {
878-
let out = render_tool_end("bash", 0, "hello\nworld", None, None);
932+
let out = render_tool_end("bash", 0, "hello\nworld", None, None, 80);
879933
assert!(out.contains("bash") && out.contains("hello"));
880934
assert!(!out.contains('✎'), "no diff marker for non-edit tools");
881935
}
882936

883937
#[test]
884938
fn tool_end_shows_primary_arg_summary() {
885939
let args = serde_json::json!({ "command": "npm test", "timeout": 60 });
886-
let out = render_tool_end("bash", 0, "ok\n", None, Some(&args));
940+
let out = render_tool_end("bash", 0, "ok\n", None, Some(&args), 80);
887941
assert!(out.contains("bash"));
888942
assert!(out.contains("npm test"), "shows the command argument");
889943
}

0 commit comments

Comments
 (0)