Skip to content

Commit 0d75252

Browse files
authored
Merge pull request #89 from xinhuagu/feat/cli-tool-trace-compact
feat(cli): compact tool status with subtle trace logs
2 parents 017a348 + d1464fc commit 0d75252

2 files changed

Lines changed: 93 additions & 5 deletions

File tree

aceclaw-cli/src/main/java/dev/aceclaw/cli/ForegroundOutputSink.java

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ public void onToolUse(String toolId, String toolName, String summary) {
122122
wasThinking = false;
123123
}
124124
stopSpinner();
125+
emitToolTraceStart(toolName, summary);
125126
statusRenderer.onToolStarted(toolId, toolName, summary);
126127
}
127128
}
@@ -130,6 +131,7 @@ public void onToolUse(String toolId, String toolName, String summary) {
130131
public void onToolCompleted(String toolId, String toolName,
131132
long durationMs, boolean isError, String error) {
132133
synchronized (lock) {
134+
emitToolTraceCompleted(toolName, durationMs, isError, error);
133135
statusRenderer.onToolCompleted(toolId, toolName, durationMs, isError, error);
134136
}
135137
}
@@ -290,6 +292,40 @@ private static String truncate(String text, int maxLen) {
290292
return normalized.substring(0, maxLen - 3) + "...";
291293
}
292294

295+
private void emitToolTraceStart(String toolName, String summary) {
296+
statusRenderer.hide();
297+
String label = safeName(toolName);
298+
String detail = truncate(summary, 90);
299+
if (detail.isBlank()) {
300+
out.println(MUTED + "[tool:start] " + label + RESET);
301+
} else {
302+
out.println(MUTED + "[tool:start] " + label + " - " + detail + RESET);
303+
}
304+
out.flush();
305+
}
306+
307+
private void emitToolTraceCompleted(String toolName, long durationMs, boolean isError, String error) {
308+
statusRenderer.hide();
309+
String label = safeName(toolName);
310+
String elapsed = String.format(Locale.ROOT, "%.1fs", Math.max(0L, durationMs) / 1000.0);
311+
if (isError) {
312+
String reason = truncate(error, 90);
313+
if (reason.isBlank()) {
314+
out.println(MUTED + "[tool:error] " + label + " (" + elapsed + ")" + RESET);
315+
} else {
316+
out.println(MUTED + "[tool:error] " + label + " (" + elapsed + ") - " + reason + RESET);
317+
}
318+
} else {
319+
out.println(MUTED + "[tool:done] " + label + " (" + elapsed + ")" + RESET);
320+
}
321+
out.flush();
322+
}
323+
324+
private static String safeName(String toolName) {
325+
if (toolName == null || toolName.isBlank()) return "unknown";
326+
return toolName;
327+
}
328+
293329
/**
294330
* Lightweight status renderer for tool/sub-agent/plan progress lines.
295331
*
@@ -335,13 +371,31 @@ private static final class StatusEntry {
335371

336372
void onToolStarted(String toolId, String toolName, String summary) {
337373
pruneExpired();
338-
String key = (toolId != null && !toolId.isBlank())
339-
? toolId : "tool-" + (++nextSyntheticId);
340374
String parent = firstActiveKey(Kind.SUBAGENT);
341-
var entry = new StatusEntry(key, Kind.TOOL, safe(toolName, "unknown"),
342-
truncate(summary, 60), System.nanoTime());
375+
String normalizedToolName = safe(toolName, "unknown");
376+
377+
StatusEntry entry = null;
378+
if (toolId != null && !toolId.isBlank()) {
379+
entry = entries.get(toolId);
380+
}
381+
if (entry == null) {
382+
entry = firstReusableTool(normalizedToolName);
383+
}
384+
385+
if (entry == null) {
386+
String key = (toolId != null && !toolId.isBlank())
387+
? toolId : "tool-" + (++nextSyntheticId);
388+
entry = new StatusEntry(key, Kind.TOOL, normalizedToolName,
389+
truncate(summary, 60), System.nanoTime());
390+
entries.put(key, entry);
391+
} else {
392+
entry.name = normalizedToolName;
393+
entry.detail = truncate(summary, 60);
394+
entry.state = State.ACTIVE;
395+
entry.durationMs = 0L;
396+
entry.expiresAtMs = 0L;
397+
}
343398
entry.parentKey = parent;
344-
entries.put(key, entry);
345399
redraw();
346400
}
347401

@@ -570,6 +624,17 @@ private StatusEntry firstActiveTool(String toolName) {
570624
return null;
571625
}
572626

627+
private StatusEntry firstReusableTool(String toolName) {
628+
StatusEntry active = firstActiveTool(toolName);
629+
if (active != null) return active;
630+
for (var entry : entries.values()) {
631+
if (entry.kind == Kind.TOOL && toolName.equals(entry.name)) {
632+
return entry;
633+
}
634+
}
635+
return null;
636+
}
637+
573638
private String firstActiveKey(Kind kind) {
574639
for (var entry : entries.values()) {
575640
if (entry.kind == kind && entry.state == State.ACTIVE) {

aceclaw-cli/src/test/java/dev/aceclaw/cli/ForegroundOutputSinkTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import java.io.PrintWriter;
77
import java.io.StringWriter;
8+
import java.lang.reflect.Field;
9+
import java.util.Map;
810

911
import static org.assertj.core.api.Assertions.assertThat;
1012

@@ -46,4 +48,25 @@ void subAgentAndPlanStepStatus_areVisible() {
4648
assertThat(output).contains("step 2/4");
4749
assertThat(output).contains("Run tests");
4850
}
51+
52+
@Test
53+
void repeatedSameTool_reusesSingleStatusEntry() throws Exception {
54+
var buffer = new StringWriter();
55+
var sink = new ForegroundOutputSink(new PrintWriter(buffer), new TerminalMarkdownRenderer());
56+
57+
sink.onToolUse("toolu_1", "web_search", "q1");
58+
sink.onToolCompleted("toolu_1", "web_search", 900, false, "");
59+
sink.onToolUse("toolu_2", "web_search", "q2");
60+
sink.onToolCompleted("toolu_2", "web_search", 1000, false, "");
61+
62+
Field rendererField = ForegroundOutputSink.class.getDeclaredField("statusRenderer");
63+
rendererField.setAccessible(true);
64+
Object renderer = rendererField.get(sink);
65+
Field entriesField = renderer.getClass().getDeclaredField("entries");
66+
entriesField.setAccessible(true);
67+
68+
@SuppressWarnings("unchecked")
69+
Map<String, Object> entries = (Map<String, Object>) entriesField.get(renderer);
70+
assertThat(entries).hasSize(1);
71+
}
4972
}

0 commit comments

Comments
 (0)