Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 133 additions & 4 deletions aceclaw-cli/src/main/java/dev/aceclaw/cli/TerminalRepl.java
Original file line number Diff line number Diff line change
Expand Up @@ -1066,18 +1066,22 @@ private void renderTaskCompletion(PrintWriter out, TaskHandle handle) {
int turnOut = usage.path("outputTokens").asInt(0);
totalInputTokens += turnIn;
totalOutputTokens += turnOut;
latestInputTokens = turnIn;
// Prefer the live input tokens from streaming (= actual context window usage)
// over turnIn which is the cumulative total across all API calls in the turn.
long liveTokens = handle.liveInputTokens();
latestInputTokens = liveTokens > 0 ? liveTokens : turnIn;

long elapsedMs = (System.nanoTime() - promptStartNanos) / 1_000_000;
String elapsed = elapsedMs >= 1000
? String.format("%.1fs", elapsedMs / 1000.0)
: elapsedMs + "ms";

out.println();
long contextTokens = liveTokens > 0 ? liveTokens : turnIn;
out.printf("%s%s %d in / %d out %s%s%n",
MUTED, elapsed, turnIn, turnOut,
sessionInfo.contextWindowTokens() > 0
? "context " + formatTokenCount(turnIn) + "/"
? "context " + formatTokenCount(contextTokens) + "/"
+ formatTokenCount(sessionInfo.contextWindowTokens())
: "",
RESET);
Expand Down Expand Up @@ -2396,6 +2400,9 @@ boolean handleSlashCommand(PrintWriter out, String input, LineReader reader) {
out.println(INFO + " /tools" + RESET + " List available tools");
out.println(INFO + " /status" + RESET + " Show session status");
out.println(INFO + " /learning" + RESET + " Show learning summary");
out.println(INFO + " /learning signals" + RESET + " Show recent reviewable learned signals");
out.println(INFO + " /learning reviews" + RESET + " Show recent human reviews");
out.println(INFO + " /learning review <action> <type> <id> [note]" + RESET + " Apply human review");
out.println(INFO + " /project" + RESET + " Show current session project");
out.println(INFO + " /skills" + RESET + " List generated skill drafts (/skills inspect <name>)");
out.println(INFO + " /tasks" + RESET + " List all tasks with status");
Expand Down Expand Up @@ -2447,7 +2454,7 @@ boolean handleSlashCommand(PrintWriter out, String input, LineReader reader) {
out.flush();
}

case "/learning" -> handleLearningCommand(out);
case "/learning" -> handleLearningCommand(out, arg);

case "/project" -> {
out.println();
Expand Down Expand Up @@ -2660,12 +2667,36 @@ private void handleSkillsCommand(PrintWriter out, String arg) {
out.flush();
}

private void handleLearningCommand(PrintWriter out) {
private void handleLearningCommand(PrintWriter out, String arg) {
if (client == null || !client.isConnected()) {
out.println(WARNING + "Not connected to daemon" + RESET);
out.flush();
return;
}
String trimmedArg = arg == null ? "" : arg.trim();
if (trimmedArg.equalsIgnoreCase("signals")) {
handleLearningSignalsCommand(out);
return;
}
if (trimmedArg.equalsIgnoreCase("reviews")) {
handleLearningReviewsCommand(out);
return;
}
if (trimmedArg.equalsIgnoreCase("review")) {
out.println(WARNING + "Usage: /learning review <action> <targetType> <targetId> [note]" + RESET);
out.flush();
return;
}
if (!trimmedArg.isBlank() && trimmedArg.regionMatches(true, 0, "review ", 0, "review ".length())) {
handleLearningReviewApplyCommand(out, trimmedArg.substring("review ".length()).trim());
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!trimmedArg.isBlank()) {
out.println(WARNING + "Usage: /learning | /learning signals | /learning reviews | "
+ "/learning review <action> <targetType> <targetId> [note]" + RESET);
out.flush();
return;
}
try {
var params = client.objectMapper().createObjectNode();
params.put("project", sessionInfo.project());
Expand All @@ -2684,6 +2715,8 @@ private void handleLearningCommand(PrintWriter out) {
MUTED, RESET, compactCountMap(root.path("explanationCounts")));
out.printf(" %sValidations:%s %s%n",
MUTED, RESET, compactCountMap(root.path("validationCounts")));
out.printf(" %sReviews:%s %s%n",
MUTED, RESET, compactCountMap(root.path("reviewCounts")));

out.println();
out.println(BOLD + "Recent Maintenance" + RESET);
Expand All @@ -2696,6 +2729,10 @@ private void handleLearningCommand(PrintWriter out) {
out.println();
out.println(BOLD + "Recent Validations" + RESET);
renderLearningValidationRows(out, root.path("recentValidations"));

out.println();
out.println(BOLD + "Recent Reviews" + RESET);
renderLearningReviewRows(out, root.path("recentReviews"), "No human reviews yet.");
out.println();
out.flush();
} catch (Exception e) {
Expand All @@ -2704,6 +2741,65 @@ private void handleLearningCommand(PrintWriter out) {
}
}

private void handleLearningSignalsCommand(PrintWriter out) {
try {
var params = client.objectMapper().createObjectNode();
params.put("project", sessionInfo.project());
params.put("limit", 12);
JsonNode root = client.sendRequest("learning.reviewable.list", params);
out.println();
out.println(BOLD + "Reviewable Learned Signals" + RESET);
renderLearningSignals(out, root.path("signals"));
out.println();
out.flush();
} catch (Exception e) {
out.println(WARNING + "Failed to load reviewable signals: " + sanitizeTerminalText(e.getMessage()) + RESET);
out.flush();
}
}

private void handleLearningReviewsCommand(PrintWriter out) {
try {
var params = client.objectMapper().createObjectNode();
params.put("project", sessionInfo.project());
params.put("limit", 12);
JsonNode root = client.sendRequest("learning.review.list", params);
out.println();
out.println(BOLD + "Human Reviews" + RESET);
renderLearningReviewRows(out, root.path("reviews"), "No human reviews yet.");
out.println();
out.flush();
} catch (Exception e) {
out.println(WARNING + "Failed to load learning reviews: " + sanitizeTerminalText(e.getMessage()) + RESET);
out.flush();
}
}

private void handleLearningReviewApplyCommand(PrintWriter out, String arg) {
String[] parts = arg.split("\\s+", 4);
if (parts.length < 3) {
out.println(WARNING + "Usage: /learning review <action> <targetType> <targetId> [note]" + RESET);
out.flush();
return;
}
try {
var params = client.objectMapper().createObjectNode();
params.put("project", sessionInfo.project());
params.put("action", parts[0]);
params.put("targetType", parts[1]);
params.put("targetId", parts[2]);
params.put("note", parts.length >= 4 ? parts[3] : "");
params.put("reviewer", "cli");
params.put("sessionId", sessionId);
JsonNode result = client.sendRequest("learning.review.apply", params);
out.println(INFO + sanitizeTerminalText(result.path("summary").asText("Review applied.")) + RESET);
out.flush();
} catch (Exception e) {
out.println(WARNING + "Failed to apply learning review: " + sanitizeTerminalText(e.getMessage()) + RESET);
out.flush();
}
}

private void renderLearningMaintenanceRuns(PrintWriter out, JsonNode maintenanceRuns) {
if (!maintenanceRuns.isArray() || maintenanceRuns.isEmpty()) {
out.println(" " + MUTED + "No maintenance runs recorded yet." + RESET);
Expand Down Expand Up @@ -2746,6 +2842,39 @@ private void renderLearningValidationRows(PrintWriter out, JsonNode validations)
}
}

private void renderLearningReviewRows(PrintWriter out, JsonNode reviews, String emptyMessage) {
if (!reviews.isArray() || reviews.isEmpty()) {
out.println(" " + MUTED + emptyMessage + RESET);
return;
}
for (JsonNode review : reviews) {
out.printf(" %s%-12s%s %s%n",
INFO,
fitWidth(sanitizeTerminalText(review.path("action").asText("")), 12),
RESET,
fitWidth(sanitizeTerminalText(review.path("summary").asText("")), 96));
}
}

private void renderLearningSignals(PrintWriter out, JsonNode signals) {
if (!signals.isArray() || signals.isEmpty()) {
out.println(" " + MUTED + "No recent reviewable learned signals." + RESET);
return;
}
for (JsonNode signal : signals) {
String left = sanitizeTerminalText(
signal.path("targetType").asText("") + ":" + signal.path("targetId").asText(""));
String review = signal.path("reviewAction").asText("");
String suffix = review.isBlank() ? "" : " [" + review + "]";
out.printf(" %s%s%s %s%s%n",
INFO,
left,
RESET,
fitWidth(sanitizeTerminalText(signal.path("summary").asText("")), 72),
sanitizeTerminalText(suffix));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private String compactCountMap(JsonNode node) {
if (node == null || !node.isObject() || node.isEmpty()) {
return "none";
Expand Down
44 changes: 44 additions & 0 deletions aceclaw-cli/src/test/java/dev/aceclaw/cli/TerminalReplTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ void help_producesHelpOutput() {
assertThat(output).contains("/model");
assertThat(output).contains("/tools");
assertThat(output).contains("/learning");
assertThat(output).contains("/learning signals");
assertThat(output).contains("/learning reviews");
assertThat(output).contains("/learning review <action> <type> <id> [note]");
assertThat(output).contains("/project");
assertThat(output).contains("/skills");
assertThat(output).contains("/tasks");
Expand Down Expand Up @@ -134,6 +137,47 @@ void learningWithNoClient_showsNotConnectedWhenNullClient() {
assertThat(outputBuffer.toString()).contains("Not connected to daemon");
}

@Test
void learningSignalsWithNoClient_showsNotConnectedWhenNullClient() {
boolean shouldExit = repl.handleSlashCommand(out, "/learning signals", null);
assertThat(shouldExit).isFalse();
assertThat(outputBuffer.toString()).contains("Not connected to daemon");
}

@Test
void learningReviewsWithNoClient_showsNotConnectedWhenNullClient() {
boolean shouldExit = repl.handleSlashCommand(out, "/learning reviews", null);
assertThat(shouldExit).isFalse();
assertThat(outputBuffer.toString()).contains("Not connected to daemon");
}

@Test
void learningReviewApplyWithNoClient_showsNotConnectedWhenNullClient() {
boolean shouldExit = repl.handleSlashCommand(out, "/learning review suppress trend foo too-noisy", null);
assertThat(shouldExit).isFalse();
assertThat(outputBuffer.toString()).contains("Not connected to daemon");
}

@Test
void renderLearningSignals_keepsFullTargetIdentifierCopyable() throws Exception {
var mapper = new ObjectMapper();
var signals = mapper.createArrayNode();
var signal = mapper.createObjectNode();
signal.put("targetType", "runtime_skill");
signal.put("targetId", "retry-flow-with-very-long-identifier");
signal.put("summary", "Retry flow learned from repeated successful repair sequence.");
signal.put("reviewAction", "pin");
signals.add(signal);

invokePrivate(repl, "renderLearningSignals",
new Class<?>[]{PrintWriter.class, com.fasterxml.jackson.databind.JsonNode.class},
out, signals);

String output = outputBuffer.toString();
assertThat(output).contains("runtime_skill:retry-flow-with-very-long-identifier");
assertThat(output).contains("[pin]");
}

@Test
void tasks_showsNoTasks() {
boolean shouldExit = repl.handleSlashCommand(out, "/tasks", null);
Expand Down
Loading
Loading