This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 964b6b7738c8 CAMEL-23860: Track LLM token usage in CLI ask and TUI
(#24361)
964b6b7738c8 is described below
commit 964b6b7738c8216ef0188715061643fcf6212818
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Jul 1 13:58:00 2026 +0200
CAMEL-23860: Track LLM token usage in CLI ask and TUI (#24361)
* CAMEL-23860: Track LLM token usage in CLI ask command and TUI AI panel
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23860: Add --show-stats option to camel ask for toggling token/time
display
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23860: Default --show-stats to false for silent output by default
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23860: Regenerate docs for camel ask --show-stats option
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23860: Format token counts as compact numbers (5.4k instead of 5400)
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23860: Track accumulated session token total in TUI AI panel
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23860: Address review feedback
- Use AtomicInteger for toolCallCount in TuiMcpServer (thread-safe
increment)
- Mark sessionTotalTokens volatile in AiPanel (cross-thread visibility)
- Move formatTokens() to LlmClient to eliminate duplication between Ask and
AiPanel
- Regenerate commands metadata
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---------
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude <[email protected]>
---
.../ROOT/pages/jbang-commands/camel-jbang-ask.adoc | 1 +
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../apache/camel/dsl/jbang/core/commands/Ask.java | 26 ++++++
.../camel/dsl/jbang/core/commands/LlmClient.java | 103 +++++++++++++++++----
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 4 +-
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 40 ++++++--
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 3 +-
.../dsl/jbang/core/commands/tui/McpLogPopup.java | 17 +++-
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 7 ++
9 files changed, 174 insertions(+), 29 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-ask.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-ask.adoc
index 0710cd347ae8..3cbefd8280fb 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-ask.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-ask.adoc
@@ -24,6 +24,7 @@ camel ask [options]
| `--max-iterations` | Maximum number of tool-calling rounds | 10 | int
| `--model` | Model to use | DEFAULT_MODEL | String
| `--name` | Name or PID of the Camel process. Auto-detected when exactly one
process is running | | String
+| `--show-stats` | Show token usage and elapsed time after response | |
boolean
| `--show-tools` | Show tool calls and results as they happen | | boolean
| `--timeout` | Timeout in seconds for LLM response | 120 | int
| `--url` | LLM API endpoint URL. Auto-detected if not specified. | | String
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 93ef87cda9f2..9a8c2a2680a7 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -1,6 +1,6 @@
{
"commands": [
- { "name": "ask", "fullName": "ask", "description": "Ask a question about a
running Camel application using AI", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names":
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY,
OPENAI_API_KEY, or LLM_API_KEY env vars", "javaType": "java.lang.String",
"type": "string" }, { "names": "--api-type", "description": "API type:
'ollama', 'openai', or 'anthropic'", "javaType": "LlmClient.ApiType", "type"
[...]
+ { "name": "ask", "fullName": "ask", "description": "Ask a question about a
running Camel application using AI", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names":
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY,
OPENAI_API_KEY, or LLM_API_KEY env vars", "javaType": "java.lang.String",
"type": "string" }, { "names": "--api-type", "description": "API type:
'ollama', 'openai', or 'anthropic'", "javaType": "LlmClient.ApiType", "type"
[...]
{ "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind
source and sink Kamelets as a new Camel integration", "deprecated": true,
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options":
[ { "names": "--error-handler", "description": "Add error handler
(none|log|sink:<endpoint>). Sink endpoints are expected in the format
[[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet
name.", "javaType": "java.lang.String", "type": "stri [...]
{ "name": "catalog", "fullName": "catalog", "description": "List artifacts
from Camel Catalog", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [
{ "names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"component", "fullName": "catalog component", "description": "List components
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
{ "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
index f6a3ad5d70ba..342c6eaca339 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
@@ -91,6 +91,10 @@ public class Ask extends CamelCommand {
description = "Show tool calls and results as they happen")
boolean showTools;
+ @Option(names = { "--show-stats" },
+ description = "Show token usage and elapsed time after response")
+ boolean showStats;
+
@Option(names = { "--verbose" },
description = "Print debug information: HTTP requests, responses,
and parsed results")
boolean verbose;
@@ -195,12 +199,15 @@ public class Ask extends CamelCommand {
String userQuestion) {
messages.add(LlmClient.Message.user(userQuestion));
+ long startTime = System.currentTimeMillis();
+ LlmClient.TokenUsage totalUsage = LlmClient.TokenUsage.EMPTY;
for (int i = 0; i < maxIterations; i++) {
LlmClient.ChatResponse response =
client.chatWithTools(systemPrompt, messages, tools);
if (response == null) {
printer().printErr("Failed to get response from LLM");
return 1;
}
+ totalUsage = totalUsage.add(response.usage());
if (response.toolCalls() != null &&
!response.toolCalls().isEmpty()) {
messages.add(LlmClient.Message.assistantWithToolCalls(response.text(),
response.toolCalls()));
@@ -222,14 +229,33 @@ public class Ask extends CamelCommand {
printer().println(response.text());
}
messages.add(LlmClient.Message.assistantWithToolCalls(response.text(),
List.of()));
+ printStats(totalUsage, startTime);
return 0;
}
}
+ printStats(totalUsage, startTime);
printer().printErr("Reached maximum iterations (" + maxIterations + ")
without a final answer.");
return 1;
}
+ private void printStats(LlmClient.TokenUsage usage, long startTime) {
+ if (!showStats) {
+ return;
+ }
+ long elapsed = (System.currentTimeMillis() - startTime) / 1000;
+ StringBuilder sb = new StringBuilder();
+ sb.append("(").append(elapsed).append("s");
+ if (usage.totalTokens() > 0) {
+ sb.append(",
").append(LlmClient.formatTokens(usage.inputTokens())).append(" input / ")
+
.append(LlmClient.formatTokens(usage.outputTokens())).append(" output / ")
+
.append(LlmClient.formatTokens(usage.totalTokens())).append(" total tokens");
+ }
+ sb.append(")");
+ printer().println();
+ printer().println(sb.toString());
+ }
+
// ---- Process discovery (delegates to RuntimeHelper) ----
private RuntimeHelper.ProcessInfo findProcess(String nameOrPid) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
index d2abee4f8f44..63488519de91 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
@@ -90,7 +90,30 @@ public class LlmClient {
}
}
- public record ChatResponse(String text, List<ToolCall> toolCalls, String
stopReason, boolean streamed) {
+ public record TokenUsage(int inputTokens, int outputTokens, int
totalTokens) {
+ public static final TokenUsage EMPTY = new TokenUsage(0, 0, 0);
+
+ public TokenUsage add(TokenUsage other) {
+ return new TokenUsage(
+ inputTokens + other.inputTokens,
+ outputTokens + other.outputTokens,
+ totalTokens + other.totalTokens);
+ }
+ }
+
+ public record ChatResponse(String text, List<ToolCall> toolCalls, String
stopReason, boolean streamed,
+ TokenUsage usage) {
+ }
+
+ public static String formatTokens(int tokens) {
+ if (tokens >= 1000) {
+ double k = tokens / 1000.0;
+ if (k == (int) k) {
+ return (int) k + "k";
+ }
+ return String.format("%.1fk", k);
+ }
+ return String.valueOf(tokens);
}
// -- Configuration --
@@ -388,12 +411,13 @@ public class LlmClient {
if (response.statusCode() != 200) {
String errorBody =
response.body().collect(Collectors.joining("\n"));
handleErrorStatus(response.statusCode(), errorBody);
- return new ChatResponse(null, List.of(), "error", false);
+ return new ChatResponse(null, List.of(), "error", false,
TokenUsage.EMPTY);
}
StringBuilder fullText = new StringBuilder();
List<ToolCall> toolCalls = new ArrayList<>();
String[] doneReasonHolder = { null };
+ int[] tokenHolder = { 0, 0 };
response.body().forEach(line -> {
if (line.isBlank()) {
@@ -443,6 +467,8 @@ public class LlmClient {
if (Boolean.TRUE.equals(chunk.get("done"))) {
doneReasonHolder[0] = chunk.getString("done_reason");
+ tokenHolder[0] = getIntValue(chunk,
"prompt_eval_count");
+ tokenHolder[1] = getIntValue(chunk, "eval_count");
}
} catch (Exception e) {
// skip malformed chunks
@@ -457,17 +483,19 @@ public class LlmClient {
String stopReason
= !toolCalls.isEmpty() ? "tool_calls" :
(doneReasonHolder[0] != null ? doneReasonHolder[0] : "stop");
+ TokenUsage usage = new TokenUsage(tokenHolder[0], tokenHolder[1],
tokenHolder[0] + tokenHolder[1]);
if (verbose) {
printer.println("[verbose] Streamed Ollama: text=" + (text !=
null ? truncateVerbose(text) : "null")
- + ", toolCalls=" + toolCalls.size() + ",
doneReason=" + doneReasonHolder[0]);
+ + ", toolCalls=" + toolCalls.size() + ",
doneReason=" + doneReasonHolder[0]
+ + ", tokens=" + usage.totalTokens());
}
- return new ChatResponse(text, toolCalls, stopReason, true);
+ return new ChatResponse(text, toolCalls, stopReason, true, usage);
} catch (HttpTimeoutException e) {
printer.println("\nRequest timed out after " + timeout + "
seconds.");
- return new ChatResponse(null, List.of(), "error", false);
+ return new ChatResponse(null, List.of(), "error", false,
TokenUsage.EMPTY);
} catch (Exception e) {
printer.println("\nError during streaming: " + e.getMessage());
- return new ChatResponse(null, List.of(), "error", false);
+ return new ChatResponse(null, List.of(), "error", false,
TokenUsage.EMPTY);
}
}
@@ -699,20 +727,21 @@ public class LlmClient {
if (verbose) {
printer.println("[verbose] parseOpenAiChatResponse: response
is null");
}
- return new ChatResponse(null, List.of(), "error", false);
+ return new ChatResponse(null, List.of(), "error", false,
TokenUsage.EMPTY);
}
+ TokenUsage usage = extractOpenAiUsage(response);
JsonArray choices = (JsonArray) response.get("choices");
if (choices == null || choices.isEmpty()) {
if (verbose) {
printer.println("[verbose] parseOpenAiChatResponse: no choices
in response. Keys: " + response.keySet());
}
- return new ChatResponse(null, List.of(), "error", false);
+ return new ChatResponse(null, List.of(), "error", false, usage);
}
JsonObject firstChoice = (JsonObject) choices.get(0);
String finishReason = firstChoice.getString("finish_reason");
JsonObject message = (JsonObject) firstChoice.get("message");
if (message == null) {
- return new ChatResponse(null, List.of(), finishReason, false);
+ return new ChatResponse(null, List.of(), finishReason, false,
usage);
}
String content = message.getString("content");
@@ -746,7 +775,7 @@ public class LlmClient {
printer.println("[verbose] Parsed: text=" + (content != null ?
truncateVerbose(content) : "null")
+ ", toolCalls=" + toolCalls.size() + ",
finishReason=" + finishReason);
}
- return new ChatResponse(content, toolCalls, finishReason, false);
+ return new ChatResponse(content, toolCalls, finishReason, false,
usage);
}
private ChatResponse parseOllamaChatResponse(JsonObject response) {
@@ -754,14 +783,14 @@ public class LlmClient {
if (verbose) {
printer.println("[verbose] parseOllamaChatResponse: response
is null");
}
- return new ChatResponse(null, List.of(), "error", false);
+ return new ChatResponse(null, List.of(), "error", false,
TokenUsage.EMPTY);
}
JsonObject message = (JsonObject) response.get("message");
if (message == null) {
if (verbose) {
printer.println("[verbose] parseOllamaChatResponse: no message
in response. Keys: " + response.keySet());
}
- return new ChatResponse(null, List.of(), "error", false);
+ return new ChatResponse(null, List.of(), "error", false,
TokenUsage.EMPTY);
}
String content = message.getString("content");
@@ -796,21 +825,27 @@ public class LlmClient {
String stopReason = !toolCalls.isEmpty() ? "tool_calls" : (doneReason
!= null ? doneReason : "stop");
+ int inputTokens = getIntValue(response, "prompt_eval_count");
+ int outputTokens = getIntValue(response, "eval_count");
+ TokenUsage usage = new TokenUsage(inputTokens, outputTokens,
inputTokens + outputTokens);
+
if (verbose) {
printer.println("[verbose] Parsed Ollama: text=" + (content !=
null ? truncateVerbose(content) : "null")
- + ", toolCalls=" + toolCalls.size() + ",
doneReason=" + doneReason);
+ + ", toolCalls=" + toolCalls.size() + ",
doneReason=" + doneReason
+ + ", tokens=" + usage.totalTokens());
}
- return new ChatResponse(content, toolCalls, stopReason, false);
+ return new ChatResponse(content, toolCalls, stopReason, false, usage);
}
private ChatResponse parseAnthropicChatResponse(JsonObject response) {
if (response == null) {
- return new ChatResponse(null, List.of(), "error", false);
+ return new ChatResponse(null, List.of(), "error", false,
TokenUsage.EMPTY);
}
String stopReason = response.getString("stop_reason");
+ TokenUsage usage = extractAnthropicUsage(response);
JsonArray contentBlocks = (JsonArray) response.get("content");
if (contentBlocks == null) {
- return new ChatResponse(null, List.of(), stopReason, false);
+ return new ChatResponse(null, List.of(), stopReason, false, usage);
}
StringBuilder text = new StringBuilder();
@@ -828,7 +863,41 @@ public class LlmClient {
}
}
String textContent = text.length() > 0 ? text.toString() : null;
- return new ChatResponse(textContent, toolCalls, stopReason, false);
+ return new ChatResponse(textContent, toolCalls, stopReason, false,
usage);
+ }
+
+ // ---- Token usage extraction ----
+
+ private TokenUsage extractOpenAiUsage(JsonObject response) {
+ JsonObject usage = (JsonObject) response.get("usage");
+ if (usage == null) {
+ return TokenUsage.EMPTY;
+ }
+ int prompt = getIntValue(usage, "prompt_tokens");
+ int completion = getIntValue(usage, "completion_tokens");
+ int total = getIntValue(usage, "total_tokens");
+ if (total == 0) {
+ total = prompt + completion;
+ }
+ return new TokenUsage(prompt, completion, total);
+ }
+
+ private TokenUsage extractAnthropicUsage(JsonObject response) {
+ JsonObject usage = (JsonObject) response.get("usage");
+ if (usage == null) {
+ return TokenUsage.EMPTY;
+ }
+ int input = getIntValue(usage, "input_tokens");
+ int output = getIntValue(usage, "output_tokens");
+ return new TokenUsage(input, output, input + output);
+ }
+
+ private static int getIntValue(JsonObject obj, String key) {
+ Object val = obj.get(key);
+ if (val instanceof Number n) {
+ return n.intValue();
+ }
+ return 0;
}
private String extractOpenAiContent(JsonObject response) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index d90a0efa92eb..cce76250427d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
@@ -212,12 +212,14 @@ class ActionsPopup {
}
void setMcpEnabled(
- boolean enabled, int port, Supplier<String> connectedClient,
Supplier<List<TuiMcpServer.LogEntry>> activityLog) {
+ boolean enabled, int port, Supplier<String> connectedClient,
+ Supplier<List<TuiMcpServer.LogEntry>> activityLog,
Supplier<Integer> toolCallCount) {
this.mcpEnabled = enabled;
this.mcpPort = port;
this.mcpConnectedClient = connectedClient;
this.mcpActivityLog = activityLog;
mcpLogPopup.setActivityLog(activityLog);
+ mcpLogPopup.setToolCallCount(toolCallCount);
}
void setAiActivityLog(Supplier<List<AiPanel.LogEntry>> activityLog) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index f700cddaee5b..ee4bb5c59e59 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -86,13 +86,14 @@ class AiPanel {
private volatile Thread agentThread;
private String initError;
private long thinkingStartTime;
+ private volatile int sessionTotalTokens;
// Activity log for AI Log popup
private final List<LogEntry> activityLog = new ArrayList<>();
- record ConversationEntry(String role, String text, long elapsedSeconds) {
+ record ConversationEntry(String role, String text, long elapsedSeconds,
int totalTokens) {
ConversationEntry(String role, String text) {
- this(role, text, -1);
+ this(role, text, -1, 0);
}
}
@@ -127,6 +128,14 @@ class AiPanel {
return "assistant".equals(last.role()) ? last.elapsedSeconds() : -1;
}
+ private int lastResponseTokens() {
+ if (thinking.get() || conversation.isEmpty()) {
+ return 0;
+ }
+ ConversationEntry last = conversation.get(conversation.size() - 1);
+ return "assistant".equals(last.role()) ? last.totalTokens() : 0;
+ }
+
void cycleHeight() {
splitIndex = (splitIndex + 1) % SPLIT_PERCENTS.length;
}
@@ -285,6 +294,7 @@ class AiPanel {
}
messages.add(LlmClient.Message.user(question));
+ LlmClient.TokenUsage totalUsage = LlmClient.TokenUsage.EMPTY;
for (int i = 0; i < MAX_ITERATIONS; i++) {
if (Thread.interrupted()) {
throw new InterruptedException();
@@ -297,6 +307,7 @@ class AiPanel {
log(LogLevel.ERROR, "Error", err);
return;
}
+ totalUsage = totalUsage.add(response.usage());
// check for error response (null text, no tool calls, error stop
reason)
if ("error".equals(response.stopReason())
@@ -324,10 +335,14 @@ class AiPanel {
messages.add(LlmClient.Message.toolResults(results));
} else {
String text = response.text();
+ sessionTotalTokens += totalUsage.totalTokens();
if (text != null && !text.isBlank()) {
long elapsed = (System.currentTimeMillis() -
thinkingStartTime) / 1000;
- conversation.add(new ConversationEntry("assistant", text,
elapsed));
- log(LogLevel.RESPONSE, "Response (" + elapsed + "s)",
text);
+ conversation.add(new ConversationEntry("assistant", text,
elapsed, totalUsage.totalTokens()));
+ String tokenInfo = totalUsage.totalTokens() > 0
+ ? ", " +
LlmClient.formatTokens(totalUsage.totalTokens()) + " tokens"
+ : "";
+ log(LogLevel.RESPONSE, "Response (" + elapsed + "s" +
tokenInfo + ")", text);
} else {
String err = "Empty response from LLM.";
conversation.add(new ConversationEntry("error", err));
@@ -344,13 +359,19 @@ class AiPanel {
}
void render(Frame frame, Rect area) {
- // At 25% show elapsed in the title bar to save space
+ // At 25% show elapsed and tokens in the title bar to save space
long titleElapsed = lastResponseElapsed();
+ int titleTokens = lastResponseTokens();
Line titleLine;
if (splitIndex == 0 && titleElapsed >= 0) {
+ String tokenSuffix = titleTokens > 0 ? ", " +
LlmClient.formatTokens(titleTokens) + " tokens" : "";
+ titleLine = Line.from(
+ Span.styled(" AI ", Style.EMPTY.bold()),
+ Span.styled("(" + titleElapsed + "s" + tokenSuffix + ") ",
Style.EMPTY.dim()));
+ } else if (sessionTotalTokens > 0) {
titleLine = Line.from(
Span.styled(" AI ", Style.EMPTY.bold()),
- Span.styled("(" + titleElapsed + "s) ",
Style.EMPTY.dim()));
+ Span.styled("(total: " +
LlmClient.formatTokens(sessionTotalTokens) + " tokens) ", Style.EMPTY.dim()));
} else {
titleLine = Line.from(Span.styled(" AI ", Style.EMPTY.bold()));
}
@@ -419,12 +440,14 @@ class AiPanel {
md.append(".".repeat((int) dots + 1)).append("*\n");
}
- // Show elapsed time as a dimmed line below the markdown when at the
bottom
+ // Show elapsed time and token count as a dimmed line below the
markdown when at the bottom
long lastElapsed = -1;
+ int lastTokens = 0;
if (!thinking.get() && !conversation.isEmpty()) {
ConversationEntry last = conversation.get(conversation.size() - 1);
if ("assistant".equals(last.role()) && last.elapsedSeconds() >= 0)
{
lastElapsed = last.elapsedSeconds();
+ lastTokens = last.totalTokens();
}
}
@@ -478,8 +501,9 @@ class AiPanel {
}
if (elapsedArea != null && lastElapsed >= 0) {
+ String tokenSuffix = lastTokens > 0 ? ", " +
LlmClient.formatTokens(lastTokens) + " tokens" : "";
frame.renderWidget(
- Paragraph.from(Line.from(Span.styled("(" + lastElapsed +
"s)", Style.EMPTY.dim()))),
+ Paragraph.from(Line.from(Span.styled("(" + lastElapsed +
"s" + tokenSuffix + ")", Style.EMPTY.dim()))),
elapsedArea);
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 4a49103dde62..37d15040f9c9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -395,7 +395,8 @@ public class CamelMonitor extends CamelCommand {
mcpServer = new TuiMcpServer(mcpPort, mcpFacade);
try {
mcpServer.start();
- actionsPopup.setMcpEnabled(true, mcpPort,
mcpServer::getConnectedClient, mcpServer::getActivityLog);
+ actionsPopup.setMcpEnabled(true, mcpPort,
mcpServer::getConnectedClient,
+ mcpServer::getActivityLog,
mcpServer::getToolCallCount);
mcpJsonFile = writeMcpJson(mcpPort);
} catch (java.net.BindException e) {
System.err.println("MCP server failed to start: port " +
mcpPort + " is already in use.");
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java
index 94b69f55cd37..dea6a75d2be9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java
@@ -48,6 +48,7 @@ class McpLogPopup {
private boolean visible;
private Supplier<List<TuiMcpServer.LogEntry>> activityLog;
+ private Supplier<Integer> toolCallCount;
private List<TuiMcpServer.LogEntry> entries;
private int selected;
private int detailScroll;
@@ -56,6 +57,10 @@ class McpLogPopup {
this.activityLog = activityLog;
}
+ void setToolCallCount(Supplier<Integer> toolCallCount) {
+ this.toolCallCount = toolCallCount;
+ }
+
boolean isVisible() {
return visible;
}
@@ -148,6 +153,16 @@ class McpLogPopup {
Span.raw(entry.message()))));
}
+ int count = toolCallCount != null ? toolCallCount.get() : 0;
+ Line titleLine;
+ if (count > 0) {
+ titleLine = Line.from(
+ Span.styled(" MCP Log ", Style.EMPTY.bold()),
+ Span.styled("(" + count + " calls) ", Style.EMPTY.dim()));
+ } else {
+ titleLine = Line.from(Span.styled(" MCP Log ",
Style.EMPTY.bold()));
+ }
+
ListState masterState = new ListState();
masterState.select(selected);
ListWidget list = ListWidget.builder()
@@ -157,7 +172,7 @@ class McpLogPopup {
.scrollMode(ScrollMode.AUTO_SCROLL)
.block(Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
- .title(" MCP Log ")
+ .title(Title.from(titleLine))
.build())
.build();
frame.renderStatefulWidget(list, area, masterState);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 86e560387641..e265561c0dbb 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -27,6 +27,7 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
@@ -72,6 +73,7 @@ class TuiMcpServer {
private volatile String clientName;
private volatile long lastActivity;
private volatile long lastToolCallTime;
+ private final AtomicInteger toolCallCount = new AtomicInteger();
private final List<LogEntry> activityLog = new ArrayList<>();
TuiMcpServer(int port, McpFacade facade) {
@@ -119,6 +121,10 @@ class TuiMcpServer {
return System.currentTimeMillis() - lastToolCallTime < 2000;
}
+ int getToolCallCount() {
+ return toolCallCount.get();
+ }
+
String getConnectedClient() {
if (System.currentTimeMillis() - lastActivity < CLIENT_TIMEOUT_MS) {
return clientName != null ? clientName : "unknown";
@@ -580,6 +586,7 @@ class TuiMcpServer {
}
lastToolCallTime = System.currentTimeMillis();
+ toolCallCount.incrementAndGet();
String text;
boolean isError = false;