You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by "luigidemasi (via GitHub)" <gi...@apache.org> on 2026/09/09 17:25:23 UTC
[PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
luigidemasi opened a new pull request, #26244:
URL: https://github.com/apache/camel/pull/26244
# Description
`camel tui` gets a new kind of AI provider for its F8 panel: an external coding agent spoken to over the [Agent Client Protocol](https://agentclientprotocol.com) (ACP). The agent runs as a subprocess of the TUI, receives the TUI's own MCP server in `session/new`, and drives the TUI through the same `tui_*` tools that external MCP clients already use, while its answer streams into the panel.
Why: users who already run Claude Code, Codex, Qwen Code, OpenCode, IBM Bob or DeepSeek Harness keep their login, model, memory, skills and slash commands, and the TUI needs no API key of its own. The LLM providers already in the panel are untouched.
## What is in the change
**Presets and settings**
- `acp:claude` (`npx -y @agentclientprotocol/claude-agent-acp`), `acp:codex` (`npx -y @agentclientprotocol/codex-acp`), `acp:bob` (`bob acp`), `acp:qwen` (`qwen --acp`), `acp:opencode` (`opencode acp`), `acp:dsh` (`npx -y @deepseek-ai/dsh --profile acp`), and `acp:custom` with `camel.tui.ai.acp.command`. A preset whose executable is not on PATH shows an install hint; the `npx` ones need Node.js 22, which the Claude adapter declares.
- Gemini CLI and Google Antigravity are deliberately not offered: Gemini CLI stopped serving personal accounts and Antigravity has no ACP support yet.
- F2 Settings rows "ACP Command" and "ACP Logos" (`camel.tui.ai.acp.logos=auto|on|off`). The Settings popup now scrolls the selected row into view on short terminals, which also fixes the "AI History" row being hidden at 25 lines.
**Protocol client** (`AcpAgentClient`, no new dependency)
ACP v1 as newline-delimited JSON-RPC over stdio: `initialize`, `authenticate` (one retry when the agent asks for it, otherwise the preset's login hint is shown), `session/new` carrying the MCP server, `session/prompt` with streamed `session/update` (text chunks, tool calls and their status, usage, available commands), `session/cancel`, and `session/request_permission`. Errors, timeouts and an agent exit are reported in the conversation together with the agent's stderr tail. A JSON-RPC error from a healthy agent keeps the session alive.
**MCP on demand**
When `--mcp` was not given, the embedded MCP server starts at the first prompt on 127.0.0.1 with a free port and no `.mcp.json`, and its URL is handed to the agent.
**Permissions**
Calls to the TUI's own tools are approved automatically: a tool the TUI registry knows, identified by the adapter's `mcp__camel-tui__` name or the Gemini-style `tui_get_state (camel-tui MCP Server)` title, and never a call of kind edit, delete, move, execute or fetch. Everything else opens a popup with the options the agent offers: Enter selects, Esc rejects, Ctrl+C cancels the turn.
**Panel integration**
- Streamed answer with elapsed time and the tokens the agent reports; one status line per tool call, updated in place.
- Esc cancels the turn. The next prompt waits for the cancelled turn's response, as ACP allows the agent to keep streaming until then, so late chunks cannot land in the next answer.
- `/clear` opens a new session, a provider switch closes the agent.
- The agent's slash commands and skills appear as `/agent:<name>` in the hints and TAB completion; `/agent:` alone lists them; `/agent:<name>` always reaches the agent, even when a panel command has the same name; any other unknown `/x` input is forwarded as is.
- The panel's own `/retry`, `/context`, `/compact`, `/tools` and `/prompt` answer for the agent instead of the LLM path.
**Header**
Two rows under the panel title: the preset's logo where the terminal supports kitty graphics (uploaded once, placed by id, quiet mode, so it neither flashes nor answers on stdin) or a coloured glyph elsewhere, plus the agent's name and version, session id, working directory and command count.
**Docs**
New section "Using a coding agent (ACP)" in the TUI user manual.
**Tests**
83 new tests (`AiPanelAcpTest` 45, `AcpAgentClientTest` 20, `AcpHeaderStripTest` 9, `AcpPermissionPopupTest` 8, `TuiMcpServerPortTest` 1) around an in-process fake ACP agent over pipes; existing tests updated for the new settings rows and provider list.
## Notes for reviewers
- ACP v1 is targeted; v2 is still a draft.
- The six PNGs under `src/main/resources/tui/logos/` are the vendors' marks (Anthropic, OpenAI, IBM, Alibaba, OpenCode, DeepSeek). They are kept in the PR for the committers to decide; if ASF policy prefers not to ship them, the header falls back to the coloured glyph and the files can be dropped, or loaded from a user directory instead.
- Windows: the PATH check resolves `.cmd` shims and the resolved path is what gets launched; unit-tested on Linux only, not exercised on Windows.
- `AiPanelPromptBudgetTest` fails on current `main` (6c5903d0f8bd grew the tool schemas past its budgets); the three failures are identical without this branch.
- Exercised locally with Claude Code, Codex, OpenCode and IBM Bob; Qwen Code verified up to the handshake and session; DeepSeek Harness defined from its documentation and not exercised.
# Target
- [x] I checked that the commit is targeting the correct branch (Camel 4 uses the `main` branch)
# Tracking
- [x] If this is a large change, bug fix, or code improvement, I checked there is a [JIRA issue](https://issues.apache.org/jira/browse/CAMEL) filed for the change (usually before you start working on it): [CAMEL-24663](https://issues.apache.org/jira/browse/CAMEL-24663)
# Apache Camel coding standards and style
- [x] I checked that each commit in the pull request has a meaningful subject line and body.
- [x] I have run `mvn clean install -DskipTests` locally from root folder and I have committed all auto-generated changes (run as `mvnd clean install -DskipTests -Dlicense.skip=true` from the root, 692 modules green, no generated changes; the license formatter was skipped only because it trips on untracked local files outside this change, and the module itself passed `-Psourcecheck` including the license check).
# AI-assisted contributions
- [x] Every commit carries a `Co-Authored-By: Claude Fable 5.1` trailer. The code was written with Claude Code (model Claude Fable 5.1), reviewed and tested by the author.
_Claude Code on behalf of luigidemasi_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "davsclaus (via GitHub)" <gi...@apache.org>.
davsclaus commented on PR #26244:
URL: https://github.com/apache/camel/pull/26244#issuecomment-5617268124
LGTM
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "davsclaus (via GitHub)" <gi...@apache.org>.
davsclaus commented on code in PR #26244:
URL: https://github.com/apache/camel/pull/26244#discussion_r3972044567
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java:
##########
@@ -2849,6 +2849,31 @@ private String selectedName() {
// ---- MCP .mcp.json lifecycle ----
+ /**
+ * Returns the Streamable HTTP URL of the embedded MCP server, starting it on an ephemeral localhost port if neither
+ * --mcp nor an earlier call did. Used by the AI panel to hand the TUI tools to an ACP agent. No .mcp.json is
+ * written for an on-demand server. Called from the AI panel's agent thread.
+ */
+ synchronized String ensureMcpServer() throws IOException {
Review Comment:
**The on-demand MCP server is unauthenticated, and this flips a previously opt-in surface on by default.**
Selecting an ACP provider now opens an HTTP endpoint that can drive the TUI without the user ever passing `--mcp`. The mitigations are real and I verified them in `TuiMcpServer`: loopback-only bind, `Origin` rejection, `application/json` required, ephemeral port, no `.mcp.json` written. The docs state all of this honestly, which I appreciate.
What is missing is any token, so any local process can port-scan `127.0.0.1` and reach the same tools discussed in the `AiPanel` comment. Since the URL is handed to the agent in `session/new` anyway, a random secret path segment or a bearer token would cost very little and close local-process access entirely.
Camel's security model treats insecure defaults as in scope, so this seems worth a deliberate PMC answer rather than being settled implicitly by the merge.
Minor, in the same method: if `--mcp` was given but the initial `start()` failed with `BindException`, `mcpServer` stays null and this method retries the same busy port and throws. Falling back to port `0` in that case would be more forgiving.
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java:
##########
@@ -32,6 +36,145 @@
*/
final class AiProviderSelector {
+ static final String ACP_PREFIX = "acp:";
+ static final String ACP_CUSTOM = "acp:custom";
+ private static final String NPX_HINT = "npx not found: install Node.js 22 or newer (https://nodejs.org) and try again.";
+
+ /**
+ * One external ACP agent the panel knows how to launch. {@code executable} is the first token of the command,
+ * checked on the PATH before spawning so a missing tool yields {@code installHint} instead of an obscure error.
+ * {@code glyph}, {@code color} and {@code logo} identify the agent in the panel's header strip: the logo names a
+ * {@code /tui/logos/<logo>.png} resource drawn in terminals with native graphics, the glyph is the fallback
+ * everywhere else.
+ */
+ record AcpPreset(String id, String label, List<String> command, String executable, String loginHint,
+ String installHint, String glyph, Color color, String logo) {
+ }
+
+ private static final List<AcpPreset> ACP_PRESETS = List.of(
+ new AcpPreset(
+ "acp:claude", "Claude Code (ACP)",
+ List.of("npx", "-y", "@agentclientprotocol/claude-agent-acp"), "npx",
+ "Log in with the claude CLI or set ANTHROPIC_API_KEY, then ask again.", NPX_HINT,
+ "✱", Color.rgb(0xD9, 0x77, 0x57), "claude"),
+ new AcpPreset(
+ "acp:codex", "Codex (ACP)",
+ List.of("npx", "-y", "@agentclientprotocol/codex-acp"), "npx",
+ "Run `codex login` or set OPENAI_API_KEY, then ask again.", NPX_HINT,
+ "⬢", Color.rgb(0x10, 0xA3, 0x7F), "codex"),
+ new AcpPreset(
+ "acp:bob", "IBM Bob (ACP)",
+ List.of("bob", "acp"), "bob",
+ "Set BOBSHELL_API_KEY or run `bob` once to sign in, then ask again.",
+ "bob not found: install Bob Shell (https://bob.ibm.com/docs/shell) and try again.",
+ "◆", Color.rgb(0x0F, 0x62, 0xFE), "bob"),
+ new AcpPreset(
+ "acp:qwen", "Qwen Code (ACP)",
+ List.of("qwen", "--acp"), "qwen",
+ "Set OPENAI_API_KEY and OPENAI_BASE_URL for Qwen Code, then ask again.",
+ "qwen not found: npm install -g @qwen-code/qwen-code and try again.",
+ "✦", Color.rgb(0x61, 0x5C, 0xED), "qwen"),
+ new AcpPreset(
+ "acp:opencode", "OpenCode (ACP)",
+ List.of("opencode", "acp"), "opencode",
+ "Run `opencode auth login`, then ask again.",
+ "opencode not found: install it from https://opencode.ai and try again.",
+ "▣", Color.rgb(0x9F, 0xD3, 0x5B), "opencode"),
+ new AcpPreset(
+ "acp:dsh", "DeepSeek Harness (ACP, preview)",
+ List.of("npx", "-y", "@deepseek-ai/dsh", "--profile", "acp"), "npx",
+ "Configure the model key in DeepSeek Harness, then ask again.", NPX_HINT,
+ "◉", Color.rgb(0x4D, 0x6B, 0xFE), "dsh"));
+
+ static List<AcpPreset> acpPresets() {
+ return ACP_PRESETS;
+ }
+
+ static boolean isAcp(String provider) {
+ return provider != null && provider.startsWith(ACP_PREFIX);
+ }
+
+ static String acpLabel(String provider) {
+ for (AcpPreset preset : ACP_PRESETS) {
+ if (preset.id().equals(provider)) {
+ return preset.label();
+ }
+ }
+ return ACP_CUSTOM.equals(provider) ? "Custom (ACP)" : provider;
+ }
+
+ /**
+ * Resolves the preset for an {@code acp:*} provider id. The custom provider builds its command from
+ * {@code camel.tui.ai.acp.command}, split on whitespace (no quoting support).
+ *
+ * @throws IllegalArgumentException for an unknown id, or the custom id without a configured command
+ */
+ AcpPreset acpPreset(String provider, TuiSettings settings) {
+ for (AcpPreset preset : ACP_PRESETS) {
+ if (preset.id().equals(provider)) {
+ return preset;
+ }
+ }
+ if (ACP_CUSTOM.equals(provider)) {
+ String raw = settings.getAiAcpCommand();
+ if (raw == null || raw.isBlank()) {
+ throw new IllegalArgumentException(
+ "No custom ACP command configured. Set camel.tui.ai.acp.command in F2 -> Settings.");
+ }
+ List<String> command = List.of(raw.trim().split("\\s+"));
+ return new AcpPreset(
+ ACP_CUSTOM, "Custom (ACP)", command, command.get(0),
+ "Check the agent's own login instructions, then ask again.",
+ command.get(0) + " not found: check camel.tui.ai.acp.command.",
+ "●", Theme.ACCENT, null);
+ }
+ throw new IllegalArgumentException("Unknown ACP provider '" + provider + "'.");
+ }
+
+ /**
+ * Where {@code executable} is found on {@code path}, with the {@code .cmd}/{@code .exe} suffix Windows needs; null
+ * when absent. An absolute {@code executable} resolves to itself when it is executable.
+ */
+ static String resolveExecutable(String executable, String path) {
+ Path direct = Path.of(executable);
+ if (direct.isAbsolute()) {
+ return firstExecutable(direct);
+ }
+ if (path == null) {
+ return null;
+ }
+ for (String dir : path.split(File.pathSeparator)) {
+ if (dir.isBlank()) {
+ continue;
+ }
+ String found = firstExecutable(Path.of(dir).resolve(executable));
+ if (found != null) {
+ return found;
+ }
+ }
+ return null;
+ }
+
+ /** The candidate itself or its {@code .cmd}/{@code .exe} sibling, whichever is executable; null when none is. */
+ private static String firstExecutable(Path candidate) {
+ for (Path variant : List.of(candidate, Path.of(candidate + ".cmd"), Path.of(candidate + ".exe"))) {
+ if (Files.isExecutable(variant)) {
+ return variant.toString();
+ }
+ }
+ return null;
+ }
+
+ /** Same, on the process PATH: what {@code ProcessBuilder} must be given so a Windows shim is actually launched. */
+ static String resolveExecutable(String executable) {
+ return resolveExecutable(executable, System.getenv("PATH"));
+ }
+
+ /** True when {@code executable} is an absolute path to an executable file or is found on the PATH. */
+ static boolean isOnPath(String executable) {
Review Comment:
`isOnPath` is only referenced from `AiProviderSelectorTest` — nothing in production calls it. The install-hint behaviour the PR description mentions is actually delivered by `resolveExecutable()` in `AiPanel.spawnAcpAgent`.
Either wire it up (showing the hint in the Ctrl+P provider list before the user picks an unavailable preset would be a nice touch) or drop it, so it does not read as an unused public helper.
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStrip.java:
##########
@@ -0,0 +1,357 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.image.Image;
+import dev.tamboui.image.ImageData;
+import dev.tamboui.image.ImageScaling;
+import dev.tamboui.image.capability.TerminalImageCapabilities;
+import dev.tamboui.image.capability.TerminalImageProtocol;
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.terminal.FrameInternal;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.widget.RawOutputCapable;
+import dev.tamboui.widget.Widget;
+import dev.tamboui.widgets.paragraph.Paragraph;
+
+/**
+ * Two-row header shown under the AI panel title while an ACP session is open: the agent's logo (native terminal
+ * graphics only) or a coloured glyph, the preset and agent labels, and a dimmed line with session, working directory
+ * and command count. ACP agents run headless and never draw a start screen of their own.
+ */
+final class AcpHeaderStrip {
+
+ static final int ROWS = 2;
+ private static final int LOGO_COLUMNS = 5;
+ private static final int LOGO_PIXELS = 64;
+ private static final long RESEND_INTERVAL_MS = 2_000;
+ private static final String APC = "\033_G";
+ private static final String ST = "\033\\";
+ private static final int KITTY_CHUNK = 4096;
+ private static final int KITTY_ID_BASE = 0x43_4D_00;
+
+ enum LogoMode {
+ AUTO,
+ ON,
+ OFF;
+
+ static LogoMode parse(String value) {
+ if (value == null) {
+ return AUTO;
+ }
+ try {
+ return valueOf(value.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ return AUTO;
+ }
+ }
+ }
+
+ record Model(String presetLabel, String glyph, Color color, String logo, String agentLabel, String sessionId,
+ Path cwd, int commandCount) {
+ }
+
+ private final TerminalImageCapabilities capabilities;
+ private final Map<String, ImageData> logos = new HashMap<>();
+ private final Map<String, byte[]> logoBytes = new HashMap<>();
+ private final Set<Integer> uploaded = new HashSet<>();
+ private Rect lastLogoRect;
+ private long lastLogoSentAt;
+ private int placedImageId;
+
+ AcpHeaderStrip() {
+ this(TerminalImageCapabilities.detect());
+ }
+
+ AcpHeaderStrip(TerminalImageCapabilities capabilities) {
+ this.capabilities = capabilities;
+ }
+
+ boolean logosEnabled(LogoMode mode) {
+ return switch (mode) {
+ case ON -> true;
+ case OFF -> false;
+ default -> capabilities.supportsNativeImages();
+ };
+ }
+
+ /** The preset's logo scaled to a small square, cached; null when there is no such resource or it cannot be read. */
+ ImageData logoFor(String logo) {
+ if (logo == null) {
+ return null;
+ }
+ return logos.computeIfAbsent(logo, name -> {
Review Comment:
`HashMap.computeIfAbsent` does not store a `null` result, so when a logo resource is missing or unreadable the mapping function runs again on **every render** rather than being cached — despite the javadoc saying "cached". Same in `logoBytes()` just below.
Cheap in practice (a missing `getResourceAsStream` returns fast), but a sentinel value or a `containsKey` check would make the cache actually hold and match the doc.
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java:
##########
@@ -1336,6 +1488,369 @@ private static String summarize(String text, int max) {
return flat.length() <= max ? flat : flat.substring(0, max) + "...";
}
+ private void submitAcpQuestion(String question) {
+ conversation.add(new ConversationEntry(AiRole.USER, question));
+ log(LogLevel.QUESTION, "Question", question);
+ thinkingVerb = THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size()));
+ thinkingStartTime = System.currentTimeMillis();
+ thinking.set(true);
+ // The panel keeps showing exactly what was typed (including an explicit /agent: escape), but the agent
+ // itself only ever sees its own command name: /agent:clear reaches it as /clear.
+ String wireQuestion = question.startsWith(AGENT_COMMAND_PREFIX)
+ ? "/" + question.substring(AGENT_COMMAND_PREFIX.length())
+ : question;
+ agentThread = new Thread(() -> {
+ try {
+ runAcpTurn(wireQuestion);
+ } catch (IOException | RuntimeException e) {
+ reportAcpTurnFailure(e);
+ } finally {
+ if (agentThread == Thread.currentThread()) {
+ thinking.set(false);
+ agentThread = null;
+ }
+ }
+ }, "tui-ai-agent");
+ agentThread.setDaemon(true);
+ agentThread.start();
+ }
+
+ /**
+ * Reports a failed ACP turn. The agent process and its session are only thrown away when the failure means they are
+ * gone anyway, so a JSON-RPC error from a healthy agent does not cost the user the conversation context. An
+ * interrupted turn is already reported as "(cancelled)" by {@link #interruptBusyOperation()}.
+ */
+ private void reportAcpTurnFailure(Exception e) {
+ AcpAgentClient.AcpException acp = e instanceof AcpAgentClient.AcpException a ? a : null;
+ if (acp != null && acp.code() == AcpAgentClient.INTERRUPTED) {
+ return;
+ }
+ String message = e.getMessage() != null ? e.getMessage() : e.toString();
+ conversation.add(new ConversationEntry(AiRole.ERROR, message));
+ log(LogLevel.ERROR, "ACP error", message);
+ AcpAgentClient agent = acpClient;
+ boolean dead = agent == null || !agent.isAlive()
+ || (acp != null && (acp.code() == AcpAgentClient.CONNECTION || acp.code() == AcpAgentClient.TIMEOUT));
+ if (dead) {
+ closeAcpClient();
+ }
+ }
+
+ private void runAcpTurn(String question) throws IOException {
+ AcpAgentClient agent = ensureAcpSession();
+ boolean agentCommand = question.startsWith("/");
+ String text = acpPreambleSent || agentCommand ? question : buildSystemPrompt() + "\n\n" + question;
+ AcpTurnListener listener = new AcpTurnListener();
+ String stopReason = agent.prompt(acpSessionId, text, listener);
+ if (!agentCommand) {
+ acpPreambleSent = true;
+ }
+ listener.finish(stopReason);
+ scrollOffset = 0;
+ }
+
+ /**
+ * The ACP agent's advertised commands as display-only descriptors (null executor: they are forwarded, never run
+ * locally). Displayed and completed under the {@code agent:} name so they never collide with a panel command of the
+ * same name; the bare name is registered as an alias so typing it still suggests the prefixed form.
+ */
+ private List<AiSlashCommandRegistry.Descriptor> agentCommandDescriptors() {
+ AcpAgentClient agent = acpClient;
+ if (acpPreset == null || agent == null) {
+ return List.of();
+ }
+ List<AiSlashCommandRegistry.Descriptor> out = new ArrayList<>();
+ for (AcpAgentClient.AgentCommand command : agent.availableCommands()) {
+ out.add(new AiSlashCommandRegistry.Descriptor(
+ "agent:" + command.name(), List.of(command.name()), command.description(), command.hint(), null));
+ }
+ return out;
+ }
+
+ /**
+ * Formats the {@code /agent:} listing shown when the prefix is typed alone, e.g. {@code /agent:review focus area}.
+ */
+ private String agentCommandListing() {
+ List<AiSlashCommandRegistry.Descriptor> commands = agentCommandDescriptors();
+ if (commands.isEmpty()) {
+ return "The agent has not advertised any commands yet.";
+ }
+ List<AiSlashCommandRegistry.Descriptor> sorted = commands.stream()
+ .sorted(Comparator.comparing(AiSlashCommandRegistry.Descriptor::name))
+ .toList();
+ int width = AiSlashCommandRegistry.commandColumnWidth(sorted);
+ StringBuilder sb = new StringBuilder("Agent commands (").append(sorted.size()).append(")\n\n```\n");
+ for (AiSlashCommandRegistry.Descriptor descriptor : sorted) {
+ sb.append(AiSlashCommandRegistry.formatAlignedLine(descriptor, width)).append('\n');
+ }
+ return sb.append("```").toString();
+ }
+
+ /**
+ * Spawns the agent and negotiates the protocol on first use, then opens a session when none is active (first
+ * prompt, or after /clear). Runs on the agent thread; every failure is reported by the caller.
+ */
+ private AcpAgentClient ensureAcpSession() throws IOException {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ AcpAgentClient agent = acpClient;
+ if (agent == null || !agent.isAlive()) {
+ closeAcpClient();
+ String mcpUrl;
+ try {
+ mcpUrl = mcpUrlSupplier != null ? mcpUrlSupplier.call() : null;
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not start the TUI MCP server: " + e.getMessage(), e);
+ }
+ if (mcpUrl == null) {
+ throw new IllegalStateException("The TUI MCP server is not available in this session.");
+ }
+ Path cwd = acpWorkingDir();
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, "Starting " + preset.label() + "…"));
+ agent = acpClientFactory.create(preset, cwd);
+ agent.setPermissionHandler(new AcpPanelPermissionHandler());
+ AcpAgentClient.AgentInfo info;
+ try {
+ info = agent.initialize(ACP_INIT_TIMEOUT);
+ if (info.protocolVersion() != AcpAgentClient.PROTOCOL_VERSION) {
+ String mismatch = preset.label() + " negotiated ACP protocol version " + info.protocolVersion()
+ + "; the TUI supports version " + AcpAgentClient.PROTOCOL_VERSION + ".";
+ throw new IllegalStateException(mismatch);
+ }
+ if (!info.httpMcp()) {
+ throw new IllegalStateException(
+ preset.label() + " does not support HTTP MCP servers, so it cannot reach the TUI tools.");
+ }
+ } catch (RuntimeException e) {
+ agent.close();
+ throw e;
+ }
+ acpAgentInfo = info;
+ // written before the volatile acpClient write below, which publishes them to the other threads
+ acpMcpUrl = mcpUrl;
+ acpCwd = cwd;
+ acpClient = agent;
+ acpSessionId = null;
+ log(LogLevel.RESULT, "ACP agent started", info.label() + " cwd=" + cwd);
+ }
+ if (acpSessionId == null) {
+ acpSessionId = openAcpSession(agent, acpAgentInfo, acpMcpUrl, acpCwd);
+ acpPreambleSent = false;
+ log(LogLevel.RESULT, "ACP session", acpSessionId);
+ }
+ return agent;
+ }
+
+ private String openAcpSession(AcpAgentClient agent, AcpAgentClient.AgentInfo info, String mcpUrl, Path cwd) {
+ try {
+ return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT);
+ } catch (AcpAgentClient.AcpException e) {
+ if (e.code() != AcpAgentClient.AUTH_REQUIRED) {
+ throw e;
+ }
+ AcpAgentClient.AuthMethod method = info.authMethods().stream()
+ .filter(m -> m.id() != null && (m.type() == null || "agent".equals(m.type())))
+ .findFirst()
+ .orElse(null);
+ if (method == null) {
+ throw new IllegalStateException("Authentication required. " + acpPreset.loginHint());
+ }
+ conversation.add(new ConversationEntry(
+ AiRole.SYSTEM, "Authenticating with " + info.label() + " (" + method.name() + ")…"));
+ try {
+ agent.authenticate(method.id(), ACP_AUTH_TIMEOUT);
+ return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT);
+ } catch (AcpAgentClient.AcpException retry) {
+ throw new IllegalStateException(
+ "Authentication failed: " + retry.getMessage() + ". " + acpPreset.loginHint());
+ }
+ }
+ }
+
+ private AcpHeaderStrip.Model acpHeaderModel() {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ AcpAgentClient agent = acpClient;
+ int commands = agent != null ? agent.availableCommands().size() : 0;
+ return new AcpHeaderStrip.Model(
+ preset.label(), preset.glyph(), preset.color(), preset.logo(), acpLabel(),
+ acpSessionId, acpCwd, commands);
+ }
+
+ private Path acpWorkingDir() {
+ IntegrationInfo info = ctx != null ? ctx.findSelectedIntegration() : null;
+ if (info != null && info.configProperties != null) {
+ Path dir = FilesBrowser.resolveSourceDirectory(info);
+ if (dir != null && Files.isDirectory(dir)) {
+ return dir.toAbsolutePath();
+ }
+ }
+ return Path.of("").toAbsolutePath();
+ }
+
+ private int replaceOrAppend(int index, ConversationEntry entry) {
+ if (index >= 0 && index < conversation.size()) {
+ conversation.set(index, entry);
+ return index;
+ }
+ conversation.add(entry);
+ return conversation.size() - 1;
+ }
+
+ /**
+ * Turns agent updates into conversation entries. The callbacks run on the ACP reader thread while
+ * {@link #finish(String)} runs on the agent thread, and cancelling a turn returns from
+ * {@link AcpAgentClient#prompt} without waiting for the reader, so every method is synchronized on the listener.
+ */
+ private final class AcpTurnListener implements AcpAgentClient.Listener {
+ private final StringBuilder text = new StringBuilder();
+ private int liveIndex = -1;
+ private final Map<String, Integer> toolLines = new HashMap<>();
+ private final Map<String, String> toolTitles = new HashMap<>();
+ private volatile long usedTokens;
+
+ @Override
+ public synchronized void onTextChunk(String chunk) {
+ text.append(chunk);
+ liveIndex = replaceOrAppend(liveIndex, new ConversationEntry(AiRole.ASSISTANT, text.toString()));
+ scrollOffset = 0;
+ }
+
+ @Override
+ public synchronized void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) {
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, TuiIcons.GEAR + " " + title));
+ if (toolCallId != null) {
+ toolLines.put(toolCallId, conversation.size() - 1);
+ toolTitles.put(toolCallId, title);
+ }
+ // text after a tool call starts a new assistant entry below the tool line
+ liveIndex = -1;
+ text.setLength(0);
+ log(LogLevel.TOOL, title, rawInput != null ? rawInput.toJson() : "");
+ }
+
+ @Override
+ public synchronized void onToolCallUpdate(String toolCallId, String status, String contentText) {
+ Integer index = toolCallId != null ? toolLines.get(toolCallId) : null;
+ if (index == null) {
+ return;
+ }
+ String title = toolTitles.getOrDefault(toolCallId, "tool");
+ if ("completed".equals(status)) {
+ replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CHECK + " " + title));
+ log(LogLevel.RESULT, title, contentText != null ? contentText : "completed");
+ } else if ("failed".equals(status)) {
+ replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CROSS + " " + title));
+ log(LogLevel.ERROR, title, contentText != null ? contentText : "failed");
+ }
+ }
+
+ @Override
+ public synchronized void onUsage(long used, long size) {
+ usedTokens = used;
+ }
+
+ synchronized void finish(String stopReason) {
+ long elapsed = System.currentTimeMillis() - thinkingStartTime;
+ int tokens = (int) Math.min(Integer.MAX_VALUE, usedTokens);
+ if (liveIndex >= 0) {
+ // the agent runs its own tools, so ACP reports no ai/tool split to fill in
+ replaceOrAppend(liveIndex,
+ new ConversationEntry(AiRole.ASSISTANT, text.toString(), elapsed, 0, 0, 0, tokens));
+ }
+ if (tokens > 0) {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ sessionTotalTokens = tokens;
+ usageHistory.add(new AiUsageEntry(
+ acpLabel(), preset != null ? preset.id() : "acp", 0, 0, tokens, elapsed,
+ stopReason, Instant.now()));
+ }
+ switch (stopReason) {
+ case "end_turn", "cancelled" -> {
+ // cancelled is reported by interruptBusyOperation()
+ }
+ case "refusal" -> conversation.add(new ConversationEntry(AiRole.ERROR, "The agent refused to continue."));
+ default -> conversation.add(new ConversationEntry(AiRole.SYSTEM, "(stopped: " + stopReason + ")"));
+ }
+ log(LogLevel.RESPONSE, "Response (" + formatSeconds(elapsed) + ", " + stopReason + ")", text.toString());
+ }
+ }
+
+ /**
+ * Policy A from the design: calls to the camel-tui MCP server are approved silently (allow-always preferred),
+ * everything else is put in front of the user. Runs on the ACP request thread and blocks until the user answers or
+ * the turn is cancelled.
+ */
+ private final class AcpPanelPermissionHandler implements AcpAgentClient.PermissionHandler {
+ @Override
+ public String decide(JsonObject toolCall, List<JsonObject> options) {
+ String name = String.valueOf(toolCall.getStringOrDefault("name", ""));
+ String title = String.valueOf(toolCall.getStringOrDefault("title", ""));
+ String kind = String.valueOf(toolCall.getStringOrDefault("kind", ""));
+ if (isTuiTool(name, title, kind)) {
+ String optionId = firstOptionOfKind(options, "allow_always");
+ if (optionId == null) {
+ optionId = firstOptionOfKind(options, "allow_once");
+ }
+ if (optionId == null && !options.isEmpty()) {
+ optionId = options.get(0).getString("optionId");
+ }
+ log(LogLevel.TOOL, "Auto-approved TUI tool", title);
+ return optionId;
+ }
+ CompletableFuture<String> decision = new CompletableFuture<>();
+ pendingPermission = decision;
+ permissionPopup.open(toolCall, options);
+ log(LogLevel.TOOL, "Permission requested", title);
+ try {
+ return decision.get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return null;
+ } catch (ExecutionException e) {
+ return null;
+ } finally {
+ pendingPermission = null;
+ permissionPopup.close();
+ }
+ }
+ }
+
+ /**
+ * Only a call to a tool the TUI itself registers counts as a camel-tui tool: the name the Claude adapter sends
+ * ({@code mcp__camel-tui__tui_get_state}) or a title of the form {@code tui_get_state (camel-tui MCP Server)}.
+ * Kinds that touch files or run commands never qualify, whatever the title says: a path containing "camel-tui" is
+ * not a tool identity.
+ */
+ private boolean isTuiTool(String name, String title, String kind) {
+ if (FILE_OR_SHELL_KINDS.contains(kind)) {
+ return false;
+ }
+ String tool = null;
+ if (name.startsWith(TUI_TOOL_PREFIX)) {
+ tool = name.substring(TUI_TOOL_PREFIX.length());
+ } else {
+ int paren = title.indexOf("(camel-tui");
+ if (paren > 0) {
+ tool = title.substring(0, paren).strip();
+ }
+ }
+ return tool != null && isRegisteredTuiTool(tool);
+ }
+
+ private boolean isRegisteredTuiTool(String tool) {
+ TuiToolRegistry registry = toolRegistry;
+ // no registry wired yet: nothing is a known TUI tool, so the user is asked rather than the call approved
+ return registry != null && registry.getToolDefinitions().stream().anyMatch(td -> td.name().equals(tool));
Review Comment:
**The auto-approve covers state-changing tools, not just read-only ones.**
`isTuiTool()` above rejects the kinds `edit/delete/move/execute/fetch`, but an MCP tool call from an ACP adapter is normally reported as kind `other`, so that guard does not fire for `mcp__camel-tui__*` calls at all. This line then matches against `getToolDefinitions()` — **all** tools, not `CORE_TOOLS` — which includes `tui_control` (start/stop/restart an integration), `tui_execute_sql`, `tui_update_row`, `tui_send_keys`, `tui_action`, `tui_run_example` and `tui_open_project`.
`tui_write_file` is fine: I checked `McpFacade.writeFile` and it still forces the TUI write dialog, explicitly ignoring `confirm=false` outside `WriteMode.AUTO`. The others have no confirmation of their own.
So in practice an agent can restart the running integration, or execute SQL against the user's data source, and the user sees only a status line — never a prompt. That is a bigger grant than the design note ("calls to the camel-tui MCP server are approved silently") reads as.
Would an explicit read-only allowlist be better here than "any registered tool"? Something like `CORE_TOOLS` minus the mutating ones, or a dedicated set, so adding a new mutating tool later cannot silently widen the grant. The docs line "calls to the TUI's own tools are approved without asking" would want the same tightening — as written it reads as if only inspection tools are meant.
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java:
##########
@@ -1336,6 +1488,369 @@ private static String summarize(String text, int max) {
return flat.length() <= max ? flat : flat.substring(0, max) + "...";
}
+ private void submitAcpQuestion(String question) {
+ conversation.add(new ConversationEntry(AiRole.USER, question));
+ log(LogLevel.QUESTION, "Question", question);
+ thinkingVerb = THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size()));
+ thinkingStartTime = System.currentTimeMillis();
+ thinking.set(true);
+ // The panel keeps showing exactly what was typed (including an explicit /agent: escape), but the agent
+ // itself only ever sees its own command name: /agent:clear reaches it as /clear.
+ String wireQuestion = question.startsWith(AGENT_COMMAND_PREFIX)
+ ? "/" + question.substring(AGENT_COMMAND_PREFIX.length())
+ : question;
+ agentThread = new Thread(() -> {
+ try {
+ runAcpTurn(wireQuestion);
+ } catch (IOException | RuntimeException e) {
+ reportAcpTurnFailure(e);
+ } finally {
+ if (agentThread == Thread.currentThread()) {
+ thinking.set(false);
+ agentThread = null;
+ }
+ }
+ }, "tui-ai-agent");
+ agentThread.setDaemon(true);
+ agentThread.start();
+ }
+
+ /**
+ * Reports a failed ACP turn. The agent process and its session are only thrown away when the failure means they are
+ * gone anyway, so a JSON-RPC error from a healthy agent does not cost the user the conversation context. An
+ * interrupted turn is already reported as "(cancelled)" by {@link #interruptBusyOperation()}.
+ */
+ private void reportAcpTurnFailure(Exception e) {
+ AcpAgentClient.AcpException acp = e instanceof AcpAgentClient.AcpException a ? a : null;
+ if (acp != null && acp.code() == AcpAgentClient.INTERRUPTED) {
+ return;
+ }
+ String message = e.getMessage() != null ? e.getMessage() : e.toString();
+ conversation.add(new ConversationEntry(AiRole.ERROR, message));
+ log(LogLevel.ERROR, "ACP error", message);
+ AcpAgentClient agent = acpClient;
+ boolean dead = agent == null || !agent.isAlive()
+ || (acp != null && (acp.code() == AcpAgentClient.CONNECTION || acp.code() == AcpAgentClient.TIMEOUT));
+ if (dead) {
+ closeAcpClient();
+ }
+ }
+
+ private void runAcpTurn(String question) throws IOException {
+ AcpAgentClient agent = ensureAcpSession();
+ boolean agentCommand = question.startsWith("/");
+ String text = acpPreambleSent || agentCommand ? question : buildSystemPrompt() + "\n\n" + question;
+ AcpTurnListener listener = new AcpTurnListener();
+ String stopReason = agent.prompt(acpSessionId, text, listener);
+ if (!agentCommand) {
+ acpPreambleSent = true;
+ }
+ listener.finish(stopReason);
+ scrollOffset = 0;
+ }
+
+ /**
+ * The ACP agent's advertised commands as display-only descriptors (null executor: they are forwarded, never run
+ * locally). Displayed and completed under the {@code agent:} name so they never collide with a panel command of the
+ * same name; the bare name is registered as an alias so typing it still suggests the prefixed form.
+ */
+ private List<AiSlashCommandRegistry.Descriptor> agentCommandDescriptors() {
+ AcpAgentClient agent = acpClient;
+ if (acpPreset == null || agent == null) {
+ return List.of();
+ }
+ List<AiSlashCommandRegistry.Descriptor> out = new ArrayList<>();
+ for (AcpAgentClient.AgentCommand command : agent.availableCommands()) {
+ out.add(new AiSlashCommandRegistry.Descriptor(
+ "agent:" + command.name(), List.of(command.name()), command.description(), command.hint(), null));
+ }
+ return out;
+ }
+
+ /**
+ * Formats the {@code /agent:} listing shown when the prefix is typed alone, e.g. {@code /agent:review focus area}.
+ */
+ private String agentCommandListing() {
+ List<AiSlashCommandRegistry.Descriptor> commands = agentCommandDescriptors();
+ if (commands.isEmpty()) {
+ return "The agent has not advertised any commands yet.";
+ }
+ List<AiSlashCommandRegistry.Descriptor> sorted = commands.stream()
+ .sorted(Comparator.comparing(AiSlashCommandRegistry.Descriptor::name))
+ .toList();
+ int width = AiSlashCommandRegistry.commandColumnWidth(sorted);
+ StringBuilder sb = new StringBuilder("Agent commands (").append(sorted.size()).append(")\n\n```\n");
+ for (AiSlashCommandRegistry.Descriptor descriptor : sorted) {
+ sb.append(AiSlashCommandRegistry.formatAlignedLine(descriptor, width)).append('\n');
+ }
+ return sb.append("```").toString();
+ }
+
+ /**
+ * Spawns the agent and negotiates the protocol on first use, then opens a session when none is active (first
+ * prompt, or after /clear). Runs on the agent thread; every failure is reported by the caller.
+ */
+ private AcpAgentClient ensureAcpSession() throws IOException {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ AcpAgentClient agent = acpClient;
+ if (agent == null || !agent.isAlive()) {
+ closeAcpClient();
+ String mcpUrl;
+ try {
+ mcpUrl = mcpUrlSupplier != null ? mcpUrlSupplier.call() : null;
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not start the TUI MCP server: " + e.getMessage(), e);
+ }
+ if (mcpUrl == null) {
+ throw new IllegalStateException("The TUI MCP server is not available in this session.");
+ }
+ Path cwd = acpWorkingDir();
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, "Starting " + preset.label() + "…"));
+ agent = acpClientFactory.create(preset, cwd);
+ agent.setPermissionHandler(new AcpPanelPermissionHandler());
+ AcpAgentClient.AgentInfo info;
+ try {
+ info = agent.initialize(ACP_INIT_TIMEOUT);
+ if (info.protocolVersion() != AcpAgentClient.PROTOCOL_VERSION) {
+ String mismatch = preset.label() + " negotiated ACP protocol version " + info.protocolVersion()
+ + "; the TUI supports version " + AcpAgentClient.PROTOCOL_VERSION + ".";
+ throw new IllegalStateException(mismatch);
+ }
+ if (!info.httpMcp()) {
+ throw new IllegalStateException(
+ preset.label() + " does not support HTTP MCP servers, so it cannot reach the TUI tools.");
+ }
+ } catch (RuntimeException e) {
+ agent.close();
+ throw e;
+ }
+ acpAgentInfo = info;
+ // written before the volatile acpClient write below, which publishes them to the other threads
+ acpMcpUrl = mcpUrl;
+ acpCwd = cwd;
+ acpClient = agent;
+ acpSessionId = null;
+ log(LogLevel.RESULT, "ACP agent started", info.label() + " cwd=" + cwd);
+ }
+ if (acpSessionId == null) {
+ acpSessionId = openAcpSession(agent, acpAgentInfo, acpMcpUrl, acpCwd);
+ acpPreambleSent = false;
+ log(LogLevel.RESULT, "ACP session", acpSessionId);
+ }
+ return agent;
+ }
+
+ private String openAcpSession(AcpAgentClient agent, AcpAgentClient.AgentInfo info, String mcpUrl, Path cwd) {
+ try {
+ return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT);
+ } catch (AcpAgentClient.AcpException e) {
+ if (e.code() != AcpAgentClient.AUTH_REQUIRED) {
+ throw e;
+ }
+ AcpAgentClient.AuthMethod method = info.authMethods().stream()
+ .filter(m -> m.id() != null && (m.type() == null || "agent".equals(m.type())))
+ .findFirst()
+ .orElse(null);
+ if (method == null) {
+ throw new IllegalStateException("Authentication required. " + acpPreset.loginHint());
+ }
+ conversation.add(new ConversationEntry(
+ AiRole.SYSTEM, "Authenticating with " + info.label() + " (" + method.name() + ")…"));
+ try {
+ agent.authenticate(method.id(), ACP_AUTH_TIMEOUT);
+ return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT);
+ } catch (AcpAgentClient.AcpException retry) {
+ throw new IllegalStateException(
+ "Authentication failed: " + retry.getMessage() + ". " + acpPreset.loginHint());
+ }
+ }
+ }
+
+ private AcpHeaderStrip.Model acpHeaderModel() {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ AcpAgentClient agent = acpClient;
+ int commands = agent != null ? agent.availableCommands().size() : 0;
+ return new AcpHeaderStrip.Model(
+ preset.label(), preset.glyph(), preset.color(), preset.logo(), acpLabel(),
+ acpSessionId, acpCwd, commands);
+ }
+
+ private Path acpWorkingDir() {
+ IntegrationInfo info = ctx != null ? ctx.findSelectedIntegration() : null;
+ if (info != null && info.configProperties != null) {
+ Path dir = FilesBrowser.resolveSourceDirectory(info);
+ if (dir != null && Files.isDirectory(dir)) {
+ return dir.toAbsolutePath();
+ }
+ }
+ return Path.of("").toAbsolutePath();
+ }
+
+ private int replaceOrAppend(int index, ConversationEntry entry) {
+ if (index >= 0 && index < conversation.size()) {
+ conversation.set(index, entry);
+ return index;
+ }
+ conversation.add(entry);
+ return conversation.size() - 1;
+ }
+
+ /**
+ * Turns agent updates into conversation entries. The callbacks run on the ACP reader thread while
+ * {@link #finish(String)} runs on the agent thread, and cancelling a turn returns from
+ * {@link AcpAgentClient#prompt} without waiting for the reader, so every method is synchronized on the listener.
+ */
+ private final class AcpTurnListener implements AcpAgentClient.Listener {
+ private final StringBuilder text = new StringBuilder();
+ private int liveIndex = -1;
+ private final Map<String, Integer> toolLines = new HashMap<>();
+ private final Map<String, String> toolTitles = new HashMap<>();
+ private volatile long usedTokens;
+
+ @Override
+ public synchronized void onTextChunk(String chunk) {
+ text.append(chunk);
+ liveIndex = replaceOrAppend(liveIndex, new ConversationEntry(AiRole.ASSISTANT, text.toString()));
+ scrollOffset = 0;
+ }
+
+ @Override
+ public synchronized void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) {
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, TuiIcons.GEAR + " " + title));
+ if (toolCallId != null) {
+ toolLines.put(toolCallId, conversation.size() - 1);
+ toolTitles.put(toolCallId, title);
+ }
+ // text after a tool call starts a new assistant entry below the tool line
+ liveIndex = -1;
+ text.setLength(0);
+ log(LogLevel.TOOL, title, rawInput != null ? rawInput.toJson() : "");
+ }
+
+ @Override
+ public synchronized void onToolCallUpdate(String toolCallId, String status, String contentText) {
+ Integer index = toolCallId != null ? toolLines.get(toolCallId) : null;
+ if (index == null) {
+ return;
+ }
+ String title = toolTitles.getOrDefault(toolCallId, "tool");
+ if ("completed".equals(status)) {
+ replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CHECK + " " + title));
+ log(LogLevel.RESULT, title, contentText != null ? contentText : "completed");
+ } else if ("failed".equals(status)) {
+ replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CROSS + " " + title));
+ log(LogLevel.ERROR, title, contentText != null ? contentText : "failed");
+ }
+ }
+
+ @Override
+ public synchronized void onUsage(long used, long size) {
+ usedTokens = used;
+ }
+
+ synchronized void finish(String stopReason) {
+ long elapsed = System.currentTimeMillis() - thinkingStartTime;
+ int tokens = (int) Math.min(Integer.MAX_VALUE, usedTokens);
+ if (liveIndex >= 0) {
+ // the agent runs its own tools, so ACP reports no ai/tool split to fill in
+ replaceOrAppend(liveIndex,
+ new ConversationEntry(AiRole.ASSISTANT, text.toString(), elapsed, 0, 0, 0, tokens));
+ }
+ if (tokens > 0) {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ sessionTotalTokens = tokens;
Review Comment:
**`sessionTotalTokens` means something different on the two paths.**
The LLM path accumulates (`sessionTotalTokens += ...` at lines 1396 and 1412); this assigns. ACP's `usage_update.used` is the *current context size*, not a session total, so the title bar's `total: N tokens` and `/context`'s "Tokens reported by the agent so far" both mislabel the number — it will go down when the agent compacts.
Display-only, so not blocking. Either accumulate per-turn deltas, or relabel it as context size on the ACP path.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "gnodet (via GitHub)" <gi...@apache.org>.
gnodet commented on code in PR #26244:
URL: https://github.com/apache/camel/pull/26244#discussion_r3971255231
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopup.java:
##########
@@ -0,0 +1,207 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.MouseEvent;
+import dev.tamboui.tui.event.MouseEventKind;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.list.ListItem;
+import dev.tamboui.widgets.list.ListState;
+import dev.tamboui.widgets.list.ListWidget;
+import dev.tamboui.widgets.list.ScrollMode;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Asks the user to answer an ACP {@code session/request_permission}: shows the tool title, kind and the first lines of
+ * its raw input, then the options exactly as the agent sent them. Enter selects, Esc picks a reject-once option when
+ * the agent offers one and otherwise answers "cancelled".
+ */
+final class AcpPermissionPopup {
+
+ private static final int MAX_INPUT_LINES = 8;
+
+ /** {@code optionId} is null when the request should be answered "cancelled". */
+ record Decision(String optionId) {
+ }
+
+ // open() is called from the ACP request thread while isVisible()/render()/handleKeyEvent() run on the TUI event
+ // thread; volatile so the write to visible (the last statement of open()) publishes the other fields set above it.
+ private volatile boolean visible;
+ private String title = "";
+ private String kind = "";
+ private List<String> inputLines = List.of();
+ private List<JsonObject> options = List.of();
+ private final ListState listState = new ListState();
+ private Decision pending;
+ private Rect listRect;
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ Rect listRectForTesting() {
+ return listRect;
+ }
+
+ void open(JsonObject toolCall, List<JsonObject> options) {
+ this.title = String.valueOf(toolCall.getStringOrDefault("title", "Use tool?"));
+ this.kind = String.valueOf(toolCall.getStringOrDefault("kind", "other"));
+ this.inputLines = formatInput(toolCall.get("rawInput"));
+ this.options = new ArrayList<>(options);
+ listState.selectFirst();
+ pending = null;
+ visible = true;
+ }
+
+ void close() {
+ visible = false;
+ }
+
+ Decision consumeDecision() {
+ Decision decision = pending;
+ pending = null;
+ return decision;
+ }
+
+ boolean handleMouseEvent(MouseEvent me) {
+ if (!visible) {
+ return false;
+ }
+ if (me.kind() == MouseEventKind.SCROLL_UP) {
+ handleKeyEvent(KeyEvent.ofKey(KeyCode.UP));
+ } else if (me.kind() == MouseEventKind.SCROLL_DOWN) {
+ handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN));
+ } else if (me.isClick() && listRect != null && listRect.contains(me.x(), me.y())) {
+ // only a click on an option row answers; a click elsewhere in the dialog must not grant anything.
+ // listRect is already the list's own content area (no border to skip), unlike the bordered popup
+ // rects TuiHelper.listItemAt expects everywhere else it is called, so the row is computed directly.
+ int idx = listState.offset() + (me.y() - listRect.y());
+ if (idx >= 0 && idx < options.size()) {
+ listState.select(idx);
+ handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+ }
+ }
+ return true;
+ }
+
Review Comment:
The comment at line 116–117 documents the `volatile` publication pattern well. Just a note for reviewers: this is a valid Java memory model pattern — the `volatile` write to `visible` (the last statement in `open()`) establishes a happens-before with the `volatile` read in `isVisible()`, guaranteeing that the other fields (`title`, `kind`, `inputLines`, `options`) set above it are visible to the reading thread. No issue here.
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClient.java:
##########
@@ -0,0 +1,687 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Consumer;
+
+import org.apache.camel.dsl.jbang.core.common.VersionHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Minimal Agent Client Protocol (ACP) version 1 client: newline-delimited JSON-RPC 2.0 over an agent process's stdin
+ * and stdout. Implements only what the AI panel needs: initialize, authenticate, session/new, session/prompt,
+ * session/cancel, the session/update notification and the session/request_permission callback. Every other
+ * agent-initiated request is answered with "method not found".
+ */
+final class AcpAgentClient implements AutoCloseable {
+
+ static final int PROTOCOL_VERSION = 1;
+ static final int AUTH_REQUIRED = -32000;
+ static final int METHOD_NOT_FOUND = -32601;
+ /** Local error codes (never sent on the wire). */
+ static final int CONNECTION = -1;
+ static final int TIMEOUT = -2;
+ static final int INTERRUPTED = -3;
+ private static final int STDERR_TAIL_LINES = 50;
+ /** How long the next prompt waits for the response the agent still owes the turn cancelled before it. */
+ private static final Duration CANCELLED_TURN_GRACE = Duration.ofSeconds(10);
+
+ /** Receives session/update notifications during a prompt turn. Called on the reader thread. */
+ interface Listener {
+ void onTextChunk(String text);
+
+ void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput);
+
+ void onToolCallUpdate(String toolCallId, String status, String contentText);
+
+ void onUsage(long used, long size);
+ }
+
+ /** Answers session/request_permission. Returns the chosen optionId, or null to answer "cancelled". */
+ interface PermissionHandler {
+ String decide(JsonObject toolCall, List<JsonObject> options);
+ }
+
+ record AuthMethod(String id, String name, String description, String type) {
+ }
+
+ record AgentInfo(int protocolVersion, String name, String version, boolean httpMcp, List<AuthMethod> authMethods) {
+ String label() {
+ if (name == null) {
+ return "ACP agent";
+ }
+ return version == null ? name : name + " " + version;
+ }
+ }
+
+ /** A slash command advertised by the agent through {@code available_commands_update}. */
+ record AgentCommand(String name, String description, String hint) {
+ }
+
+ /** A request already written to the agent, with the future its response completes. */
+ private record Sent(long id, CompletableFuture<JsonObject> future) {
+ }
+
+ static final class AcpException extends RuntimeException {
+ private final int code;
+
+ AcpException(int code, String message) {
+ super(message);
+ this.code = code;
+ }
+
+ int code() {
+ return code;
+ }
+ }
+
+ private final BufferedReader reader;
+ private final Writer writer;
+ private final Process process;
+ private final Consumer<String> diagnostics;
+ private final Deque<String> stderrTail = new ArrayDeque<>();
+ private final Map<Long, CompletableFuture<JsonObject>> pending = new ConcurrentHashMap<>();
+ private final AtomicLong nextId = new AtomicLong(1);
+ private final ExecutorService requestExecutor = Executors.newSingleThreadExecutor(r -> daemon(r, "tui-acp-requests"));
+ private final CountDownLatch streamClosed = new CountDownLatch(1);
+ private volatile Listener listener;
+ private volatile String listenerSession;
+ private volatile PermissionHandler permissionHandler = (toolCall, options) -> null;
+ private volatile boolean closed;
+ private volatile Thread stderrThread;
+ private volatile AcpException exitFailure;
+ private volatile List<AgentCommand> availableCommands = List.of();
+ /** The session {@link #availableCommands} came from, tracked because the update may precede session/new's reply. */
+ private volatile String commandsSession;
+ private volatile String currentSession;
+ private volatile CompletableFuture<JsonObject> cancelledTurn;
+
+ AcpAgentClient(InputStream fromAgent, OutputStream toAgent, Consumer<String> diagnostics) {
+ this(fromAgent, toAgent, null, diagnostics);
+ }
+
+ private AcpAgentClient(InputStream fromAgent, OutputStream toAgent, Process process, Consumer<String> diagnostics) {
+ this.reader = new BufferedReader(new InputStreamReader(fromAgent, StandardCharsets.UTF_8));
+ this.writer = new BufferedWriter(new OutputStreamWriter(toAgent, StandardCharsets.UTF_8));
+ this.process = process;
+ this.diagnostics = diagnostics != null ? diagnostics : s -> {
+ };
+ }
+
+ /** Starts {@code command} in {@code cwd}, wires its stdin/stdout to a client and starts reading. */
+ static AcpAgentClient spawn(List<String> command, Path cwd, Consumer<String> diagnostics) throws IOException {
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.directory(cwd.toFile());
+ Process process = builder.start();
+ AcpAgentClient client = new AcpAgentClient(process.getInputStream(), process.getOutputStream(), process, diagnostics);
+ client.drainStderr(process.getErrorStream());
+ client.start();
+ return client;
+ }
+
+ private void drainStderr(InputStream err) {
+ stderrThread = daemon(() -> {
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(err, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ synchronized (stderrTail) {
+ stderrTail.addLast(line);
+ if (stderrTail.size() > STDERR_TAIL_LINES) {
+ stderrTail.removeFirst();
+ }
+ }
+ }
+ } catch (IOException ignored) {
+ // process gone
+ }
+ }, "tui-acp-stderr");
+ stderrThread.start();
+ }
+
+ void start() {
+ daemon(this::readLoop, "tui-acp-reader").start();
+ }
+
+ void setPermissionHandler(PermissionHandler handler) {
+ this.permissionHandler = handler != null ? handler : (toolCall, options) -> null;
+ }
+
+ boolean isAlive() {
+ return !closed && (process == null || process.isAlive());
+ }
+
+ String stderrTail() {
+ synchronized (stderrTail) {
+ return String.join("\n", stderrTail);
+ }
+ }
+
+ /** The agent's current slash commands (Claude Code's include its skills); empty until the agent advertises them. */
+ List<AgentCommand> availableCommands() {
+ return availableCommands;
+ }
+
+ // ---- ACP calls ----
+
+ AgentInfo initialize(Duration timeout) {
+ JsonObject fs = new JsonObject();
+ fs.put("readTextFile", false);
+ fs.put("writeTextFile", false);
+ JsonObject capabilities = new JsonObject();
+ capabilities.put("fs", fs);
+ capabilities.put("terminal", false);
+ JsonObject clientInfo = new JsonObject();
+ clientInfo.put("name", "camel-tui");
+ clientInfo.put("title", "Apache Camel TUI");
+ clientInfo.put("version", VersionHelper.getJBangVersion());
+ JsonObject params = new JsonObject();
+ params.put("protocolVersion", PROTOCOL_VERSION);
+ params.put("clientCapabilities", capabilities);
+ params.put("clientInfo", clientInfo);
+ JsonObject result = request("initialize", params, timeout);
+
+ int version = result.getIntegerOrDefault("protocolVersion", -1);
+ JsonObject agentCapabilities = result.getJsonObject("agentCapabilities");
+ JsonObject mcp = agentCapabilities != null ? agentCapabilities.getJsonObject("mcpCapabilities") : null;
+ boolean http = mcp != null && mcp.getBooleanOrDefault("http", false);
+ JsonObject agentInfo = result.getJsonObject("agentInfo");
+ List<AuthMethod> methods = new ArrayList<>();
+ JsonArray authMethods = result.getJsonArray("authMethods");
+ if (authMethods != null) {
+ for (Object o : authMethods) {
+ if (o instanceof JsonObject m) {
+ JsonObject meta = m.get("_meta") instanceof JsonObject mo ? mo : null;
+ String type = meta != null && meta.getString("type") != null ? meta.getString("type") : m.getString("type");
+ methods.add(new AuthMethod(m.getString("id"), m.getString("name"), m.getString("description"), type));
+ }
+ }
+ }
+ return new AgentInfo(
+ version,
+ agentInfo != null ? agentInfo.getString("name") : null,
+ agentInfo != null ? agentInfo.getString("version") : null,
+ http, List.copyOf(methods));
+ }
+
+ // ---- ACP calls (continued) ----
+
+ void authenticate(String methodId, Duration timeout) {
+ JsonObject params = new JsonObject();
+ params.put("methodId", methodId);
+ request("authenticate", params, timeout);
+ }
+
+ String newSession(Path cwd, String mcpUrl, Duration timeout) {
+ JsonObject server = new JsonObject();
+ server.put("type", "http");
+ server.put("name", "camel-tui");
+ server.put("url", mcpUrl);
+ server.put("headers", new JsonArray());
+ JsonArray servers = new JsonArray();
+ servers.add(server);
+ JsonObject params = new JsonObject();
+ params.put("cwd", cwd.toAbsolutePath().toString());
+ params.put("mcpServers", servers);
+ // the agent may advertise the new session's commands before it answers, so the old list goes first
+ currentSession = null;
+ commandsSession = null;
+ availableCommands = List.of();
+ // an old session's owed response must not delay this session's first question; its updates are filtered anyway
+ cancelledTurn = null;
+ JsonObject result = request("session/new", params, timeout);
+ String sessionId = result.getString("sessionId");
+ if (sessionId == null) {
+ throw new AcpException(CONNECTION, "session/new returned no sessionId");
+ }
+ currentSession = sessionId;
+ if (commandsSession != null && !commandsSession.equals(sessionId)) {
+ availableCommands = List.of();
+ }
+ return sessionId;
+ }
+
+ /**
+ * Sends one user message and blocks until the agent ends the turn. Updates are delivered to {@code listener} on the
+ * reader thread while this call blocks. Interrupting the calling thread sends session/cancel and returns
+ * "cancelled" without waiting for the agent; the calling thread's interrupt flag stays set in that case. Only one
+ * prompt may be in flight per client, because the client keeps a single listener for the turn.
+ */
+ String prompt(String sessionId, String text, Listener listener) {
+ Sent sent = null;
+ try {
+ awaitCancelledTurn();
+ this.listenerSession = sessionId;
+ this.listener = listener;
+ JsonObject block = new JsonObject();
+ block.put("type", "text");
+ block.put("text", text);
+ JsonArray prompt = new JsonArray();
+ prompt.add(block);
+ JsonObject params = new JsonObject();
+ params.put("sessionId", sessionId);
+ params.put("prompt", prompt);
+ sent = sendRequest("session/prompt", params);
+ JsonObject result = await(sent, "session/prompt", null);
+ return result.getStringOrDefault("stopReason", "end_turn");
+ } catch (AcpException e) {
+ if (e.code() == INTERRUPTED) {
+ if (sent != null) {
+ cancelledTurn = sent.future();
+ }
+ cancel(sessionId);
+ return "cancelled";
+ }
+ throw e;
+ } finally {
+ this.listener = null;
+ this.listenerSession = null;
+ }
+ }
+
+ /**
+ * Waits for the agent to answer the prompt cancelled last: ACP lets it keep streaming until that response, and
+ * those late updates must not reach the next turn's listener. Bounded, so a stuck agent cannot block the panel; on
+ * timeout the old turn is forgotten and the new prompt goes ahead.
+ */
+ private void awaitCancelledTurn() {
+ CompletableFuture<JsonObject> turn = cancelledTurn;
+ if (turn == null) {
+ return;
+ }
+ try {
+ turn.get(CANCELLED_TURN_GRACE.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (ExecutionException e) {
+ // the agent died or the stream closed: the next request reports that
+ } catch (TimeoutException e) {
+ diagnostics.accept("The cancelled turn did not finish within " + CANCELLED_TURN_GRACE.toSeconds() + "s");
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AcpException(INTERRUPTED, "session/prompt interrupted");
+ }
+ cancelledTurn = null;
+ }
+
+ void cancel(String sessionId) {
+ JsonObject params = new JsonObject();
+ params.put("sessionId", sessionId);
+ try {
+ notify("session/cancel", params);
+ } catch (AcpException e) {
+ diagnostics.accept("Cannot send session/cancel: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ currentSession = null;
+ requestExecutor.shutdownNow();
+ synchronized (writer) {
+ try {
+ writer.close();
+ } catch (IOException ignored) {
+ // closing anyway
+ }
+ }
+ if (process != null) {
+ process.destroy();
+ try {
+ if (!process.waitFor(5, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ }
+ } catch (InterruptedException e) {
+ process.destroyForcibly();
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ // ---- JSON-RPC plumbing ----
+
+ private void awaitStreamClosed() {
+ try {
+ streamClosed.await(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ JsonObject request(String method, JsonObject params, Duration timeout) {
+ return await(sendRequest(method, params), method, timeout);
+ }
+
+ private Sent sendRequest(String method, JsonObject params) {
+ long id = nextId.getAndIncrement();
+ CompletableFuture<JsonObject> future = new CompletableFuture<>();
+ pending.put(id, future);
+ JsonObject msg = new JsonObject();
+ msg.put("jsonrpc", "2.0");
+ msg.put("id", id);
+ msg.put("method", method);
+ if (params != null) {
+ msg.put("params", params);
+ }
+ if (closed) {
+ pending.remove(id);
+ throw exitFailure != null ? exitFailure : new AcpException(CONNECTION, "agent exited");
+ }
+ try {
+ send(msg);
+ } catch (AcpException e) {
+ pending.remove(id);
+ if (process != null) {
+ awaitStreamClosed();
+ if (exitFailure != null) {
+ throw exitFailure;
+ }
+ }
+ throw e;
+ }
+ return new Sent(id, future);
+ }
+
+ private JsonObject await(Sent sent, String method, Duration timeout) {
+ CompletableFuture<JsonObject> future = sent.future();
+ try {
+ return timeout == null ? future.get() : future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ throw cause instanceof AcpException acp ? acp : new AcpException(CONNECTION, String.valueOf(cause));
+ } catch (TimeoutException e) {
+ pending.remove(sent.id());
+ throw new AcpException(TIMEOUT, method + " timed out after " + timeout.toSeconds() + "s");
+ } catch (InterruptedException e) {
+ // the entry stays pending on purpose: a late response completes an unread future, which is what the next
+ // prompt waits for before installing its own listener
+ Thread.currentThread().interrupt();
+ throw new AcpException(INTERRUPTED, method + " interrupted");
+ }
+ }
+
+ void notify(String method, JsonObject params) {
+ JsonObject msg = new JsonObject();
+ msg.put("jsonrpc", "2.0");
+ msg.put("method", method);
+ if (params != null) {
+ msg.put("params", params);
+ }
+ send(msg);
+ }
+
+ private void send(JsonObject msg) {
+ String line = Jsoner.serialize(msg);
+ synchronized (writer) {
+ try {
+ writer.write(line);
+ writer.write('\n');
+ writer.flush();
+ } catch (IOException e) {
+ throw new AcpException(CONNECTION, "Cannot write to agent: " + e.getMessage());
+ }
+ }
+ }
+
+ private void readLoop() {
+ try {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.isBlank()) {
+ continue;
+ }
+ JsonObject msg = Jsoner.deserialize(line, (JsonObject) null);
+ if (msg == null || !msg.containsKey("jsonrpc")) {
+ diagnostics.accept("Ignoring malformed line from agent: " + abbreviate(line));
+ continue;
+ }
+ try {
+ dispatch(msg);
+ } catch (RuntimeException e) {
+ diagnostics.accept("Cannot handle message from agent: " + e);
+ }
+ }
+ } catch (IOException e) {
+ if (!closed) {
+ diagnostics.accept("Agent stream error: " + e.getMessage());
+ }
+ } finally {
+ onStreamClosed();
+ }
+ }
+
+ private void dispatch(JsonObject msg) {
+ Object id = msg.get("id");
+ String method = msg.getString("method");
+ if (method == null && id != null) {
+ if (!(id instanceof Number number)) {
+ return;
+ }
+ CompletableFuture<JsonObject> future = pending.remove(number.longValue());
+ if (future == null) {
+ return;
+ }
+ JsonObject error = msg.getJsonObject("error");
+ if (error != null) {
+ future.completeExceptionally(new AcpException(
+ error.getIntegerOrDefault("code", CONNECTION), error.getStringOrDefault("message", "error")));
+ } else {
+ JsonObject result = msg.getJsonObject("result");
+ future.complete(result != null ? result : new JsonObject());
+ }
+ } else if (method != null && id != null) {
+ JsonObject params = msg.getJsonObject("params");
+ requestExecutor.execute(() -> handleRequest(id, method, params));
+ } else if (method != null) {
+ handleNotification(method, msg.getJsonObject("params"));
+ }
+ }
+
+ private void handleRequest(Object id, String method, JsonObject params) {
+ JsonObject response = new JsonObject();
+ response.put("jsonrpc", "2.0");
+ response.put("id", id);
+ if ("session/request_permission".equals(method) && params != null) {
+ JsonObject toolCall = params.getJsonObject("toolCall");
+ List<JsonObject> options = new ArrayList<>();
+ JsonArray array = params.getJsonArray("options");
+ if (array != null) {
+ for (Object o : array) {
+ if (o instanceof JsonObject option) {
+ options.add(option);
+ }
+ }
+ }
+ String optionId;
+ try {
+ optionId = permissionHandler.decide(toolCall != null ? toolCall : new JsonObject(), options);
+ } catch (RuntimeException e) {
+ diagnostics.accept("Permission handler failed: " + e.getMessage());
+ optionId = null;
+ }
+ JsonObject outcome = new JsonObject();
+ if (optionId == null) {
+ outcome.put("outcome", "cancelled");
+ } else {
+ outcome.put("outcome", "selected");
+ outcome.put("optionId", optionId);
+ }
+ JsonObject result = new JsonObject();
+ result.put("outcome", outcome);
+ response.put("result", result);
+ } else {
+ JsonObject error = new JsonObject();
+ error.put("code", METHOD_NOT_FOUND);
+ error.put("message", "Method not found: " + method);
+ response.put("error", error);
+ }
+ try {
Review Comment:
Nit: `Jsoner.deserialize(line, (JsonObject) null)` returns `null` both when the line is genuinely malformed *and* when it's valid JSON that isn't an object (e.g., a JSON array). The diagnostic below says "malformed" for both cases. In practice this doesn't matter — an ACP agent sending a JSON array is broken anyway — but the message could be slightly more precise (e.g., "Ignoring non-object line from agent"). Very minor.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "luigidemasi (via GitHub)" <gi...@apache.org>.
luigidemasi commented on PR #26244:
URL: https://github.com/apache/camel/pull/26244#issuecomment-5615004042
Pushed six commits answering the first review round (12d0d32..24188b5):
- Silent approval only for the registry's read-only allowlist (`tui_get_*`, catalog documentation, locate, list examples, validate source, wait for idle); every other TUI tool goes through the permission popup. Manual and PR description updated.
- The ACP agent's `usage_update` is shown as context usage; totals use per-turn deltas.
- Unused `isOnPath` removed; the logo cache stores a miss; the reader's diagnostic names what it ignores; `/context` says read-only.
Not changed, awaiting decisions: the bundled logos (a PMC/legal question; they can be dropped in one commit, the glyph fallback is tested) and the on-demand MCP server's authentication (thread on `CamelMonitor.ensureMcpServer`).
On `acp:custom` appearing in Ctrl+P only once `camel.tui.ai.acp.command` is set: intentional. The Ctrl+P list only offers providers that can start; an unconfigured custom entry would fail at once with a settings hint, and the F2 Settings row is where the command is entered, so the entry shows up as soon as there is something to run.
Module: 1315 tests green with `-Psourcecheck`.
_Claude Code on behalf of luigidemasi_
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "davsclaus (via GitHub)" <gi...@apache.org>.
davsclaus commented on PR #26244:
URL: https://github.com/apache/camel/pull/26244#issuecomment-5615099268
we need to remove those cloud vendors logos and use a neutral logo (we can of course use text to write the vendor name such as Claude / Bob / Gemini etc) but their logo we cannot include/show.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #26244:
URL: https://github.com/apache/camel/pull/26244#issuecomment-5606004742
:star2: Thank you for your contribution to the Apache Camel project! :star2:
:robot: CI automation will test this PR automatically.
:camel: Apache Camel Committers, please review the following items:
* First-time contributors **require MANUAL approval** for the GitHub Actions to run
* You can use the command `/component-test (camel-)component-name1 (camel-)component-name2..` to request a test from the test bot although they are normally detected and executed by CI.
* You can label PRs using `skip-tests` and `test-dependents` to fine-tune the checks executed by this PR.
* Build and test logs are available in the summary page. **Only** [Apache Camel committers](https://camel.apache.org/community/team/#committers) have access to the summary.
:warning: Be careful when sharing logs. Review their contents before sharing them publicly.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "luigidemasi (via GitHub)" <gi...@apache.org>.
luigidemasi commented on code in PR #26244:
URL: https://github.com/apache/camel/pull/26244#discussion_r3976734068
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java:
##########
@@ -1336,6 +1488,369 @@ private static String summarize(String text, int max) {
return flat.length() <= max ? flat : flat.substring(0, max) + "...";
}
+ private void submitAcpQuestion(String question) {
+ conversation.add(new ConversationEntry(AiRole.USER, question));
+ log(LogLevel.QUESTION, "Question", question);
+ thinkingVerb = THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size()));
+ thinkingStartTime = System.currentTimeMillis();
+ thinking.set(true);
+ // The panel keeps showing exactly what was typed (including an explicit /agent: escape), but the agent
+ // itself only ever sees its own command name: /agent:clear reaches it as /clear.
+ String wireQuestion = question.startsWith(AGENT_COMMAND_PREFIX)
+ ? "/" + question.substring(AGENT_COMMAND_PREFIX.length())
+ : question;
+ agentThread = new Thread(() -> {
+ try {
+ runAcpTurn(wireQuestion);
+ } catch (IOException | RuntimeException e) {
+ reportAcpTurnFailure(e);
+ } finally {
+ if (agentThread == Thread.currentThread()) {
+ thinking.set(false);
+ agentThread = null;
+ }
+ }
+ }, "tui-ai-agent");
+ agentThread.setDaemon(true);
+ agentThread.start();
+ }
+
+ /**
+ * Reports a failed ACP turn. The agent process and its session are only thrown away when the failure means they are
+ * gone anyway, so a JSON-RPC error from a healthy agent does not cost the user the conversation context. An
+ * interrupted turn is already reported as "(cancelled)" by {@link #interruptBusyOperation()}.
+ */
+ private void reportAcpTurnFailure(Exception e) {
+ AcpAgentClient.AcpException acp = e instanceof AcpAgentClient.AcpException a ? a : null;
+ if (acp != null && acp.code() == AcpAgentClient.INTERRUPTED) {
+ return;
+ }
+ String message = e.getMessage() != null ? e.getMessage() : e.toString();
+ conversation.add(new ConversationEntry(AiRole.ERROR, message));
+ log(LogLevel.ERROR, "ACP error", message);
+ AcpAgentClient agent = acpClient;
+ boolean dead = agent == null || !agent.isAlive()
+ || (acp != null && (acp.code() == AcpAgentClient.CONNECTION || acp.code() == AcpAgentClient.TIMEOUT));
+ if (dead) {
+ closeAcpClient();
+ }
+ }
+
+ private void runAcpTurn(String question) throws IOException {
+ AcpAgentClient agent = ensureAcpSession();
+ boolean agentCommand = question.startsWith("/");
+ String text = acpPreambleSent || agentCommand ? question : buildSystemPrompt() + "\n\n" + question;
+ AcpTurnListener listener = new AcpTurnListener();
+ String stopReason = agent.prompt(acpSessionId, text, listener);
+ if (!agentCommand) {
+ acpPreambleSent = true;
+ }
+ listener.finish(stopReason);
+ scrollOffset = 0;
+ }
+
+ /**
+ * The ACP agent's advertised commands as display-only descriptors (null executor: they are forwarded, never run
+ * locally). Displayed and completed under the {@code agent:} name so they never collide with a panel command of the
+ * same name; the bare name is registered as an alias so typing it still suggests the prefixed form.
+ */
+ private List<AiSlashCommandRegistry.Descriptor> agentCommandDescriptors() {
+ AcpAgentClient agent = acpClient;
+ if (acpPreset == null || agent == null) {
+ return List.of();
+ }
+ List<AiSlashCommandRegistry.Descriptor> out = new ArrayList<>();
+ for (AcpAgentClient.AgentCommand command : agent.availableCommands()) {
+ out.add(new AiSlashCommandRegistry.Descriptor(
+ "agent:" + command.name(), List.of(command.name()), command.description(), command.hint(), null));
+ }
+ return out;
+ }
+
+ /**
+ * Formats the {@code /agent:} listing shown when the prefix is typed alone, e.g. {@code /agent:review focus area}.
+ */
+ private String agentCommandListing() {
+ List<AiSlashCommandRegistry.Descriptor> commands = agentCommandDescriptors();
+ if (commands.isEmpty()) {
+ return "The agent has not advertised any commands yet.";
+ }
+ List<AiSlashCommandRegistry.Descriptor> sorted = commands.stream()
+ .sorted(Comparator.comparing(AiSlashCommandRegistry.Descriptor::name))
+ .toList();
+ int width = AiSlashCommandRegistry.commandColumnWidth(sorted);
+ StringBuilder sb = new StringBuilder("Agent commands (").append(sorted.size()).append(")\n\n```\n");
+ for (AiSlashCommandRegistry.Descriptor descriptor : sorted) {
+ sb.append(AiSlashCommandRegistry.formatAlignedLine(descriptor, width)).append('\n');
+ }
+ return sb.append("```").toString();
+ }
+
+ /**
+ * Spawns the agent and negotiates the protocol on first use, then opens a session when none is active (first
+ * prompt, or after /clear). Runs on the agent thread; every failure is reported by the caller.
+ */
+ private AcpAgentClient ensureAcpSession() throws IOException {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ AcpAgentClient agent = acpClient;
+ if (agent == null || !agent.isAlive()) {
+ closeAcpClient();
+ String mcpUrl;
+ try {
+ mcpUrl = mcpUrlSupplier != null ? mcpUrlSupplier.call() : null;
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not start the TUI MCP server: " + e.getMessage(), e);
+ }
+ if (mcpUrl == null) {
+ throw new IllegalStateException("The TUI MCP server is not available in this session.");
+ }
+ Path cwd = acpWorkingDir();
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, "Starting " + preset.label() + "…"));
+ agent = acpClientFactory.create(preset, cwd);
+ agent.setPermissionHandler(new AcpPanelPermissionHandler());
+ AcpAgentClient.AgentInfo info;
+ try {
+ info = agent.initialize(ACP_INIT_TIMEOUT);
+ if (info.protocolVersion() != AcpAgentClient.PROTOCOL_VERSION) {
+ String mismatch = preset.label() + " negotiated ACP protocol version " + info.protocolVersion()
+ + "; the TUI supports version " + AcpAgentClient.PROTOCOL_VERSION + ".";
+ throw new IllegalStateException(mismatch);
+ }
+ if (!info.httpMcp()) {
+ throw new IllegalStateException(
+ preset.label() + " does not support HTTP MCP servers, so it cannot reach the TUI tools.");
+ }
+ } catch (RuntimeException e) {
+ agent.close();
+ throw e;
+ }
+ acpAgentInfo = info;
+ // written before the volatile acpClient write below, which publishes them to the other threads
+ acpMcpUrl = mcpUrl;
+ acpCwd = cwd;
+ acpClient = agent;
+ acpSessionId = null;
+ log(LogLevel.RESULT, "ACP agent started", info.label() + " cwd=" + cwd);
+ }
+ if (acpSessionId == null) {
+ acpSessionId = openAcpSession(agent, acpAgentInfo, acpMcpUrl, acpCwd);
+ acpPreambleSent = false;
+ log(LogLevel.RESULT, "ACP session", acpSessionId);
+ }
+ return agent;
+ }
+
+ private String openAcpSession(AcpAgentClient agent, AcpAgentClient.AgentInfo info, String mcpUrl, Path cwd) {
+ try {
+ return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT);
+ } catch (AcpAgentClient.AcpException e) {
+ if (e.code() != AcpAgentClient.AUTH_REQUIRED) {
+ throw e;
+ }
+ AcpAgentClient.AuthMethod method = info.authMethods().stream()
+ .filter(m -> m.id() != null && (m.type() == null || "agent".equals(m.type())))
+ .findFirst()
+ .orElse(null);
+ if (method == null) {
+ throw new IllegalStateException("Authentication required. " + acpPreset.loginHint());
+ }
+ conversation.add(new ConversationEntry(
+ AiRole.SYSTEM, "Authenticating with " + info.label() + " (" + method.name() + ")…"));
+ try {
+ agent.authenticate(method.id(), ACP_AUTH_TIMEOUT);
+ return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT);
+ } catch (AcpAgentClient.AcpException retry) {
+ throw new IllegalStateException(
+ "Authentication failed: " + retry.getMessage() + ". " + acpPreset.loginHint());
+ }
+ }
+ }
+
+ private AcpHeaderStrip.Model acpHeaderModel() {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ AcpAgentClient agent = acpClient;
+ int commands = agent != null ? agent.availableCommands().size() : 0;
+ return new AcpHeaderStrip.Model(
+ preset.label(), preset.glyph(), preset.color(), preset.logo(), acpLabel(),
+ acpSessionId, acpCwd, commands);
+ }
+
+ private Path acpWorkingDir() {
+ IntegrationInfo info = ctx != null ? ctx.findSelectedIntegration() : null;
+ if (info != null && info.configProperties != null) {
+ Path dir = FilesBrowser.resolveSourceDirectory(info);
+ if (dir != null && Files.isDirectory(dir)) {
+ return dir.toAbsolutePath();
+ }
+ }
+ return Path.of("").toAbsolutePath();
+ }
+
+ private int replaceOrAppend(int index, ConversationEntry entry) {
+ if (index >= 0 && index < conversation.size()) {
+ conversation.set(index, entry);
+ return index;
+ }
+ conversation.add(entry);
+ return conversation.size() - 1;
+ }
+
+ /**
+ * Turns agent updates into conversation entries. The callbacks run on the ACP reader thread while
+ * {@link #finish(String)} runs on the agent thread, and cancelling a turn returns from
+ * {@link AcpAgentClient#prompt} without waiting for the reader, so every method is synchronized on the listener.
+ */
+ private final class AcpTurnListener implements AcpAgentClient.Listener {
+ private final StringBuilder text = new StringBuilder();
+ private int liveIndex = -1;
+ private final Map<String, Integer> toolLines = new HashMap<>();
+ private final Map<String, String> toolTitles = new HashMap<>();
+ private volatile long usedTokens;
+
+ @Override
+ public synchronized void onTextChunk(String chunk) {
+ text.append(chunk);
+ liveIndex = replaceOrAppend(liveIndex, new ConversationEntry(AiRole.ASSISTANT, text.toString()));
+ scrollOffset = 0;
+ }
+
+ @Override
+ public synchronized void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) {
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, TuiIcons.GEAR + " " + title));
+ if (toolCallId != null) {
+ toolLines.put(toolCallId, conversation.size() - 1);
+ toolTitles.put(toolCallId, title);
+ }
+ // text after a tool call starts a new assistant entry below the tool line
+ liveIndex = -1;
+ text.setLength(0);
+ log(LogLevel.TOOL, title, rawInput != null ? rawInput.toJson() : "");
+ }
+
+ @Override
+ public synchronized void onToolCallUpdate(String toolCallId, String status, String contentText) {
+ Integer index = toolCallId != null ? toolLines.get(toolCallId) : null;
+ if (index == null) {
+ return;
+ }
+ String title = toolTitles.getOrDefault(toolCallId, "tool");
+ if ("completed".equals(status)) {
+ replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CHECK + " " + title));
+ log(LogLevel.RESULT, title, contentText != null ? contentText : "completed");
+ } else if ("failed".equals(status)) {
+ replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CROSS + " " + title));
+ log(LogLevel.ERROR, title, contentText != null ? contentText : "failed");
+ }
+ }
+
+ @Override
+ public synchronized void onUsage(long used, long size) {
+ usedTokens = used;
+ }
+
+ synchronized void finish(String stopReason) {
+ long elapsed = System.currentTimeMillis() - thinkingStartTime;
+ int tokens = (int) Math.min(Integer.MAX_VALUE, usedTokens);
+ if (liveIndex >= 0) {
+ // the agent runs its own tools, so ACP reports no ai/tool split to fill in
+ replaceOrAppend(liveIndex,
+ new ConversationEntry(AiRole.ASSISTANT, text.toString(), elapsed, 0, 0, 0, tokens));
+ }
+ if (tokens > 0) {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ sessionTotalTokens = tokens;
+ usageHistory.add(new AiUsageEntry(
+ acpLabel(), preset != null ? preset.id() : "acp", 0, 0, tokens, elapsed,
+ stopReason, Instant.now()));
+ }
+ switch (stopReason) {
+ case "end_turn", "cancelled" -> {
+ // cancelled is reported by interruptBusyOperation()
+ }
+ case "refusal" -> conversation.add(new ConversationEntry(AiRole.ERROR, "The agent refused to continue."));
+ default -> conversation.add(new ConversationEntry(AiRole.SYSTEM, "(stopped: " + stopReason + ")"));
+ }
+ log(LogLevel.RESPONSE, "Response (" + formatSeconds(elapsed) + ", " + stopReason + ")", text.toString());
+ }
+ }
+
+ /**
+ * Policy A from the design: calls to the camel-tui MCP server are approved silently (allow-always preferred),
+ * everything else is put in front of the user. Runs on the ACP request thread and blocks until the user answers or
+ * the turn is cancelled.
+ */
+ private final class AcpPanelPermissionHandler implements AcpAgentClient.PermissionHandler {
+ @Override
+ public String decide(JsonObject toolCall, List<JsonObject> options) {
+ String name = String.valueOf(toolCall.getStringOrDefault("name", ""));
+ String title = String.valueOf(toolCall.getStringOrDefault("title", ""));
+ String kind = String.valueOf(toolCall.getStringOrDefault("kind", ""));
+ if (isTuiTool(name, title, kind)) {
+ String optionId = firstOptionOfKind(options, "allow_always");
+ if (optionId == null) {
+ optionId = firstOptionOfKind(options, "allow_once");
+ }
+ if (optionId == null && !options.isEmpty()) {
+ optionId = options.get(0).getString("optionId");
+ }
+ log(LogLevel.TOOL, "Auto-approved TUI tool", title);
+ return optionId;
+ }
+ CompletableFuture<String> decision = new CompletableFuture<>();
+ pendingPermission = decision;
+ permissionPopup.open(toolCall, options);
+ log(LogLevel.TOOL, "Permission requested", title);
+ try {
+ return decision.get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return null;
+ } catch (ExecutionException e) {
+ return null;
+ } finally {
+ pendingPermission = null;
+ permissionPopup.close();
+ }
+ }
+ }
+
+ /**
+ * Only a call to a tool the TUI itself registers counts as a camel-tui tool: the name the Claude adapter sends
+ * ({@code mcp__camel-tui__tui_get_state}) or a title of the form {@code tui_get_state (camel-tui MCP Server)}.
+ * Kinds that touch files or run commands never qualify, whatever the title says: a path containing "camel-tui" is
+ * not a tool identity.
+ */
+ private boolean isTuiTool(String name, String title, String kind) {
+ if (FILE_OR_SHELL_KINDS.contains(kind)) {
+ return false;
+ }
+ String tool = null;
+ if (name.startsWith(TUI_TOOL_PREFIX)) {
+ tool = name.substring(TUI_TOOL_PREFIX.length());
+ } else {
+ int paren = title.indexOf("(camel-tui");
+ if (paren > 0) {
+ tool = title.substring(0, paren).strip();
+ }
+ }
+ return tool != null && isRegisteredTuiTool(tool);
+ }
+
+ private boolean isRegisteredTuiTool(String tool) {
+ TuiToolRegistry registry = toolRegistry;
+ // no registry wired yet: nothing is a known TUI tool, so the user is asked rather than the call approved
+ return registry != null && registry.getToolDefinitions().stream().anyMatch(td -> td.name().equals(tool));
Review Comment:
Changed in 12d0d32 and 24188b5. Silent approval now requires the tool to be in `TuiToolRegistry.READ_ONLY_TOOLS` (the `tui_get_*` tools, catalog documentation, locate, list examples, validate source, wait for idle). `tui_control`, `tui_execute_sql`, `tui_send_message`, `tui_update_row`, `tui_send_keys`, `tui_action`, `tui_run_example`, `tui_open_project` and every other tool go through the permission popup, where "Always allow" keeps it a one-time question per tool.
The registry test pins both directions: every allowlisted name is registered, and every registered `tui_get_*` tool must be in the allowlist, so a new getter has to be classified deliberately. A new panel test asserts that `mcp__camel-tui__tui_control` opens the popup although its name and title would otherwise qualify.
On the open question: the Claude adapter's tool mapping has no case for `mcp__` names and falls through to `Other`, so MCP calls do arrive as kind `other`. The kind guard cannot carry this; the allowlist is the identity now. The manual, `/context` and the PR description say read-only.
_Claude Code on behalf of luigidemasi_
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java:
##########
@@ -1336,6 +1488,369 @@ private static String summarize(String text, int max) {
return flat.length() <= max ? flat : flat.substring(0, max) + "...";
}
+ private void submitAcpQuestion(String question) {
+ conversation.add(new ConversationEntry(AiRole.USER, question));
+ log(LogLevel.QUESTION, "Question", question);
+ thinkingVerb = THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size()));
+ thinkingStartTime = System.currentTimeMillis();
+ thinking.set(true);
+ // The panel keeps showing exactly what was typed (including an explicit /agent: escape), but the agent
+ // itself only ever sees its own command name: /agent:clear reaches it as /clear.
+ String wireQuestion = question.startsWith(AGENT_COMMAND_PREFIX)
+ ? "/" + question.substring(AGENT_COMMAND_PREFIX.length())
+ : question;
+ agentThread = new Thread(() -> {
+ try {
+ runAcpTurn(wireQuestion);
+ } catch (IOException | RuntimeException e) {
+ reportAcpTurnFailure(e);
+ } finally {
+ if (agentThread == Thread.currentThread()) {
+ thinking.set(false);
+ agentThread = null;
+ }
+ }
+ }, "tui-ai-agent");
+ agentThread.setDaemon(true);
+ agentThread.start();
+ }
+
+ /**
+ * Reports a failed ACP turn. The agent process and its session are only thrown away when the failure means they are
+ * gone anyway, so a JSON-RPC error from a healthy agent does not cost the user the conversation context. An
+ * interrupted turn is already reported as "(cancelled)" by {@link #interruptBusyOperation()}.
+ */
+ private void reportAcpTurnFailure(Exception e) {
+ AcpAgentClient.AcpException acp = e instanceof AcpAgentClient.AcpException a ? a : null;
+ if (acp != null && acp.code() == AcpAgentClient.INTERRUPTED) {
+ return;
+ }
+ String message = e.getMessage() != null ? e.getMessage() : e.toString();
+ conversation.add(new ConversationEntry(AiRole.ERROR, message));
+ log(LogLevel.ERROR, "ACP error", message);
+ AcpAgentClient agent = acpClient;
+ boolean dead = agent == null || !agent.isAlive()
+ || (acp != null && (acp.code() == AcpAgentClient.CONNECTION || acp.code() == AcpAgentClient.TIMEOUT));
+ if (dead) {
+ closeAcpClient();
+ }
+ }
+
+ private void runAcpTurn(String question) throws IOException {
+ AcpAgentClient agent = ensureAcpSession();
+ boolean agentCommand = question.startsWith("/");
+ String text = acpPreambleSent || agentCommand ? question : buildSystemPrompt() + "\n\n" + question;
+ AcpTurnListener listener = new AcpTurnListener();
+ String stopReason = agent.prompt(acpSessionId, text, listener);
+ if (!agentCommand) {
+ acpPreambleSent = true;
+ }
+ listener.finish(stopReason);
+ scrollOffset = 0;
+ }
+
+ /**
+ * The ACP agent's advertised commands as display-only descriptors (null executor: they are forwarded, never run
+ * locally). Displayed and completed under the {@code agent:} name so they never collide with a panel command of the
+ * same name; the bare name is registered as an alias so typing it still suggests the prefixed form.
+ */
+ private List<AiSlashCommandRegistry.Descriptor> agentCommandDescriptors() {
+ AcpAgentClient agent = acpClient;
+ if (acpPreset == null || agent == null) {
+ return List.of();
+ }
+ List<AiSlashCommandRegistry.Descriptor> out = new ArrayList<>();
+ for (AcpAgentClient.AgentCommand command : agent.availableCommands()) {
+ out.add(new AiSlashCommandRegistry.Descriptor(
+ "agent:" + command.name(), List.of(command.name()), command.description(), command.hint(), null));
+ }
+ return out;
+ }
+
+ /**
+ * Formats the {@code /agent:} listing shown when the prefix is typed alone, e.g. {@code /agent:review focus area}.
+ */
+ private String agentCommandListing() {
+ List<AiSlashCommandRegistry.Descriptor> commands = agentCommandDescriptors();
+ if (commands.isEmpty()) {
+ return "The agent has not advertised any commands yet.";
+ }
+ List<AiSlashCommandRegistry.Descriptor> sorted = commands.stream()
+ .sorted(Comparator.comparing(AiSlashCommandRegistry.Descriptor::name))
+ .toList();
+ int width = AiSlashCommandRegistry.commandColumnWidth(sorted);
+ StringBuilder sb = new StringBuilder("Agent commands (").append(sorted.size()).append(")\n\n```\n");
+ for (AiSlashCommandRegistry.Descriptor descriptor : sorted) {
+ sb.append(AiSlashCommandRegistry.formatAlignedLine(descriptor, width)).append('\n');
+ }
+ return sb.append("```").toString();
+ }
+
+ /**
+ * Spawns the agent and negotiates the protocol on first use, then opens a session when none is active (first
+ * prompt, or after /clear). Runs on the agent thread; every failure is reported by the caller.
+ */
+ private AcpAgentClient ensureAcpSession() throws IOException {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ AcpAgentClient agent = acpClient;
+ if (agent == null || !agent.isAlive()) {
+ closeAcpClient();
+ String mcpUrl;
+ try {
+ mcpUrl = mcpUrlSupplier != null ? mcpUrlSupplier.call() : null;
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not start the TUI MCP server: " + e.getMessage(), e);
+ }
+ if (mcpUrl == null) {
+ throw new IllegalStateException("The TUI MCP server is not available in this session.");
+ }
+ Path cwd = acpWorkingDir();
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, "Starting " + preset.label() + "…"));
+ agent = acpClientFactory.create(preset, cwd);
+ agent.setPermissionHandler(new AcpPanelPermissionHandler());
+ AcpAgentClient.AgentInfo info;
+ try {
+ info = agent.initialize(ACP_INIT_TIMEOUT);
+ if (info.protocolVersion() != AcpAgentClient.PROTOCOL_VERSION) {
+ String mismatch = preset.label() + " negotiated ACP protocol version " + info.protocolVersion()
+ + "; the TUI supports version " + AcpAgentClient.PROTOCOL_VERSION + ".";
+ throw new IllegalStateException(mismatch);
+ }
+ if (!info.httpMcp()) {
+ throw new IllegalStateException(
+ preset.label() + " does not support HTTP MCP servers, so it cannot reach the TUI tools.");
+ }
+ } catch (RuntimeException e) {
+ agent.close();
+ throw e;
+ }
+ acpAgentInfo = info;
+ // written before the volatile acpClient write below, which publishes them to the other threads
+ acpMcpUrl = mcpUrl;
+ acpCwd = cwd;
+ acpClient = agent;
+ acpSessionId = null;
+ log(LogLevel.RESULT, "ACP agent started", info.label() + " cwd=" + cwd);
+ }
+ if (acpSessionId == null) {
+ acpSessionId = openAcpSession(agent, acpAgentInfo, acpMcpUrl, acpCwd);
+ acpPreambleSent = false;
+ log(LogLevel.RESULT, "ACP session", acpSessionId);
+ }
+ return agent;
+ }
+
+ private String openAcpSession(AcpAgentClient agent, AcpAgentClient.AgentInfo info, String mcpUrl, Path cwd) {
+ try {
+ return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT);
+ } catch (AcpAgentClient.AcpException e) {
+ if (e.code() != AcpAgentClient.AUTH_REQUIRED) {
+ throw e;
+ }
+ AcpAgentClient.AuthMethod method = info.authMethods().stream()
+ .filter(m -> m.id() != null && (m.type() == null || "agent".equals(m.type())))
+ .findFirst()
+ .orElse(null);
+ if (method == null) {
+ throw new IllegalStateException("Authentication required. " + acpPreset.loginHint());
+ }
+ conversation.add(new ConversationEntry(
+ AiRole.SYSTEM, "Authenticating with " + info.label() + " (" + method.name() + ")…"));
+ try {
+ agent.authenticate(method.id(), ACP_AUTH_TIMEOUT);
+ return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT);
+ } catch (AcpAgentClient.AcpException retry) {
+ throw new IllegalStateException(
+ "Authentication failed: " + retry.getMessage() + ". " + acpPreset.loginHint());
+ }
+ }
+ }
+
+ private AcpHeaderStrip.Model acpHeaderModel() {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ AcpAgentClient agent = acpClient;
+ int commands = agent != null ? agent.availableCommands().size() : 0;
+ return new AcpHeaderStrip.Model(
+ preset.label(), preset.glyph(), preset.color(), preset.logo(), acpLabel(),
+ acpSessionId, acpCwd, commands);
+ }
+
+ private Path acpWorkingDir() {
+ IntegrationInfo info = ctx != null ? ctx.findSelectedIntegration() : null;
+ if (info != null && info.configProperties != null) {
+ Path dir = FilesBrowser.resolveSourceDirectory(info);
+ if (dir != null && Files.isDirectory(dir)) {
+ return dir.toAbsolutePath();
+ }
+ }
+ return Path.of("").toAbsolutePath();
+ }
+
+ private int replaceOrAppend(int index, ConversationEntry entry) {
+ if (index >= 0 && index < conversation.size()) {
+ conversation.set(index, entry);
+ return index;
+ }
+ conversation.add(entry);
+ return conversation.size() - 1;
+ }
+
+ /**
+ * Turns agent updates into conversation entries. The callbacks run on the ACP reader thread while
+ * {@link #finish(String)} runs on the agent thread, and cancelling a turn returns from
+ * {@link AcpAgentClient#prompt} without waiting for the reader, so every method is synchronized on the listener.
+ */
+ private final class AcpTurnListener implements AcpAgentClient.Listener {
+ private final StringBuilder text = new StringBuilder();
+ private int liveIndex = -1;
+ private final Map<String, Integer> toolLines = new HashMap<>();
+ private final Map<String, String> toolTitles = new HashMap<>();
+ private volatile long usedTokens;
+
+ @Override
+ public synchronized void onTextChunk(String chunk) {
+ text.append(chunk);
+ liveIndex = replaceOrAppend(liveIndex, new ConversationEntry(AiRole.ASSISTANT, text.toString()));
+ scrollOffset = 0;
+ }
+
+ @Override
+ public synchronized void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) {
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, TuiIcons.GEAR + " " + title));
+ if (toolCallId != null) {
+ toolLines.put(toolCallId, conversation.size() - 1);
+ toolTitles.put(toolCallId, title);
+ }
+ // text after a tool call starts a new assistant entry below the tool line
+ liveIndex = -1;
+ text.setLength(0);
+ log(LogLevel.TOOL, title, rawInput != null ? rawInput.toJson() : "");
+ }
+
+ @Override
+ public synchronized void onToolCallUpdate(String toolCallId, String status, String contentText) {
+ Integer index = toolCallId != null ? toolLines.get(toolCallId) : null;
+ if (index == null) {
+ return;
+ }
+ String title = toolTitles.getOrDefault(toolCallId, "tool");
+ if ("completed".equals(status)) {
+ replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CHECK + " " + title));
+ log(LogLevel.RESULT, title, contentText != null ? contentText : "completed");
+ } else if ("failed".equals(status)) {
+ replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CROSS + " " + title));
+ log(LogLevel.ERROR, title, contentText != null ? contentText : "failed");
+ }
+ }
+
+ @Override
+ public synchronized void onUsage(long used, long size) {
+ usedTokens = used;
+ }
+
+ synchronized void finish(String stopReason) {
+ long elapsed = System.currentTimeMillis() - thinkingStartTime;
+ int tokens = (int) Math.min(Integer.MAX_VALUE, usedTokens);
+ if (liveIndex >= 0) {
+ // the agent runs its own tools, so ACP reports no ai/tool split to fill in
+ replaceOrAppend(liveIndex,
+ new ConversationEntry(AiRole.ASSISTANT, text.toString(), elapsed, 0, 0, 0, tokens));
+ }
+ if (tokens > 0) {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ sessionTotalTokens = tokens;
Review Comment:
Changed in 68cfc43. The ACP path keeps the agent's `used` and `size` as context usage: the title shows `context: <used>/<size> tokens`, `/context` shows `Context: <used> of <size> tokens in the agent's window`, and the per-turn figure recorded in the conversation, in `/usage` and in the session total is the delta between consecutive `used` values, clamped at zero, so a compaction adds nothing. The test covers three turns including a compaction.
_Claude Code on behalf of luigidemasi_
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java:
##########
@@ -32,6 +36,145 @@
*/
final class AiProviderSelector {
+ static final String ACP_PREFIX = "acp:";
+ static final String ACP_CUSTOM = "acp:custom";
+ private static final String NPX_HINT = "npx not found: install Node.js 22 or newer (https://nodejs.org) and try again.";
+
+ /**
+ * One external ACP agent the panel knows how to launch. {@code executable} is the first token of the command,
+ * checked on the PATH before spawning so a missing tool yields {@code installHint} instead of an obscure error.
+ * {@code glyph}, {@code color} and {@code logo} identify the agent in the panel's header strip: the logo names a
+ * {@code /tui/logos/<logo>.png} resource drawn in terminals with native graphics, the glyph is the fallback
+ * everywhere else.
+ */
+ record AcpPreset(String id, String label, List<String> command, String executable, String loginHint,
+ String installHint, String glyph, Color color, String logo) {
+ }
+
+ private static final List<AcpPreset> ACP_PRESETS = List.of(
+ new AcpPreset(
+ "acp:claude", "Claude Code (ACP)",
+ List.of("npx", "-y", "@agentclientprotocol/claude-agent-acp"), "npx",
+ "Log in with the claude CLI or set ANTHROPIC_API_KEY, then ask again.", NPX_HINT,
+ "✱", Color.rgb(0xD9, 0x77, 0x57), "claude"),
+ new AcpPreset(
+ "acp:codex", "Codex (ACP)",
+ List.of("npx", "-y", "@agentclientprotocol/codex-acp"), "npx",
+ "Run `codex login` or set OPENAI_API_KEY, then ask again.", NPX_HINT,
+ "⬢", Color.rgb(0x10, 0xA3, 0x7F), "codex"),
+ new AcpPreset(
+ "acp:bob", "IBM Bob (ACP)",
+ List.of("bob", "acp"), "bob",
+ "Set BOBSHELL_API_KEY or run `bob` once to sign in, then ask again.",
+ "bob not found: install Bob Shell (https://bob.ibm.com/docs/shell) and try again.",
+ "◆", Color.rgb(0x0F, 0x62, 0xFE), "bob"),
+ new AcpPreset(
+ "acp:qwen", "Qwen Code (ACP)",
+ List.of("qwen", "--acp"), "qwen",
+ "Set OPENAI_API_KEY and OPENAI_BASE_URL for Qwen Code, then ask again.",
+ "qwen not found: npm install -g @qwen-code/qwen-code and try again.",
+ "✦", Color.rgb(0x61, 0x5C, 0xED), "qwen"),
+ new AcpPreset(
+ "acp:opencode", "OpenCode (ACP)",
+ List.of("opencode", "acp"), "opencode",
+ "Run `opencode auth login`, then ask again.",
+ "opencode not found: install it from https://opencode.ai and try again.",
+ "▣", Color.rgb(0x9F, 0xD3, 0x5B), "opencode"),
+ new AcpPreset(
+ "acp:dsh", "DeepSeek Harness (ACP, preview)",
+ List.of("npx", "-y", "@deepseek-ai/dsh", "--profile", "acp"), "npx",
+ "Configure the model key in DeepSeek Harness, then ask again.", NPX_HINT,
+ "◉", Color.rgb(0x4D, 0x6B, 0xFE), "dsh"));
+
+ static List<AcpPreset> acpPresets() {
+ return ACP_PRESETS;
+ }
+
+ static boolean isAcp(String provider) {
+ return provider != null && provider.startsWith(ACP_PREFIX);
+ }
+
+ static String acpLabel(String provider) {
+ for (AcpPreset preset : ACP_PRESETS) {
+ if (preset.id().equals(provider)) {
+ return preset.label();
+ }
+ }
+ return ACP_CUSTOM.equals(provider) ? "Custom (ACP)" : provider;
+ }
+
+ /**
+ * Resolves the preset for an {@code acp:*} provider id. The custom provider builds its command from
+ * {@code camel.tui.ai.acp.command}, split on whitespace (no quoting support).
+ *
+ * @throws IllegalArgumentException for an unknown id, or the custom id without a configured command
+ */
+ AcpPreset acpPreset(String provider, TuiSettings settings) {
+ for (AcpPreset preset : ACP_PRESETS) {
+ if (preset.id().equals(provider)) {
+ return preset;
+ }
+ }
+ if (ACP_CUSTOM.equals(provider)) {
+ String raw = settings.getAiAcpCommand();
+ if (raw == null || raw.isBlank()) {
+ throw new IllegalArgumentException(
+ "No custom ACP command configured. Set camel.tui.ai.acp.command in F2 -> Settings.");
+ }
+ List<String> command = List.of(raw.trim().split("\\s+"));
+ return new AcpPreset(
+ ACP_CUSTOM, "Custom (ACP)", command, command.get(0),
+ "Check the agent's own login instructions, then ask again.",
+ command.get(0) + " not found: check camel.tui.ai.acp.command.",
+ "●", Theme.ACCENT, null);
+ }
+ throw new IllegalArgumentException("Unknown ACP provider '" + provider + "'.");
+ }
+
+ /**
+ * Where {@code executable} is found on {@code path}, with the {@code .cmd}/{@code .exe} suffix Windows needs; null
+ * when absent. An absolute {@code executable} resolves to itself when it is executable.
+ */
+ static String resolveExecutable(String executable, String path) {
+ Path direct = Path.of(executable);
+ if (direct.isAbsolute()) {
+ return firstExecutable(direct);
+ }
+ if (path == null) {
+ return null;
+ }
+ for (String dir : path.split(File.pathSeparator)) {
+ if (dir.isBlank()) {
+ continue;
+ }
+ String found = firstExecutable(Path.of(dir).resolve(executable));
+ if (found != null) {
+ return found;
+ }
+ }
+ return null;
+ }
+
+ /** The candidate itself or its {@code .cmd}/{@code .exe} sibling, whichever is executable; null when none is. */
+ private static String firstExecutable(Path candidate) {
+ for (Path variant : List.of(candidate, Path.of(candidate + ".cmd"), Path.of(candidate + ".exe"))) {
+ if (Files.isExecutable(variant)) {
+ return variant.toString();
+ }
+ }
+ return null;
+ }
+
+ /** Same, on the process PATH: what {@code ProcessBuilder} must be given so a Windows shim is actually launched. */
+ static String resolveExecutable(String executable) {
+ return resolveExecutable(executable, System.getenv("PATH"));
+ }
+
+ /** True when {@code executable} is an absolute path to an executable file or is found on the PATH. */
+ static boolean isOnPath(String executable) {
Review Comment:
Removed in c547ab4; the test now exercises `resolveExecutable`. Showing availability in the Ctrl+P list would be a separate change and is not added here.
_Claude Code on behalf of luigidemasi_
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStrip.java:
##########
@@ -0,0 +1,357 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.image.Image;
+import dev.tamboui.image.ImageData;
+import dev.tamboui.image.ImageScaling;
+import dev.tamboui.image.capability.TerminalImageCapabilities;
+import dev.tamboui.image.capability.TerminalImageProtocol;
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.terminal.FrameInternal;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.widget.RawOutputCapable;
+import dev.tamboui.widget.Widget;
+import dev.tamboui.widgets.paragraph.Paragraph;
+
+/**
+ * Two-row header shown under the AI panel title while an ACP session is open: the agent's logo (native terminal
+ * graphics only) or a coloured glyph, the preset and agent labels, and a dimmed line with session, working directory
+ * and command count. ACP agents run headless and never draw a start screen of their own.
+ */
+final class AcpHeaderStrip {
+
+ static final int ROWS = 2;
+ private static final int LOGO_COLUMNS = 5;
+ private static final int LOGO_PIXELS = 64;
+ private static final long RESEND_INTERVAL_MS = 2_000;
+ private static final String APC = "\033_G";
+ private static final String ST = "\033\\";
+ private static final int KITTY_CHUNK = 4096;
+ private static final int KITTY_ID_BASE = 0x43_4D_00;
+
+ enum LogoMode {
+ AUTO,
+ ON,
+ OFF;
+
+ static LogoMode parse(String value) {
+ if (value == null) {
+ return AUTO;
+ }
+ try {
+ return valueOf(value.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ return AUTO;
+ }
+ }
+ }
+
+ record Model(String presetLabel, String glyph, Color color, String logo, String agentLabel, String sessionId,
+ Path cwd, int commandCount) {
+ }
+
+ private final TerminalImageCapabilities capabilities;
+ private final Map<String, ImageData> logos = new HashMap<>();
+ private final Map<String, byte[]> logoBytes = new HashMap<>();
+ private final Set<Integer> uploaded = new HashSet<>();
+ private Rect lastLogoRect;
+ private long lastLogoSentAt;
+ private int placedImageId;
+
+ AcpHeaderStrip() {
+ this(TerminalImageCapabilities.detect());
+ }
+
+ AcpHeaderStrip(TerminalImageCapabilities capabilities) {
+ this.capabilities = capabilities;
+ }
+
+ boolean logosEnabled(LogoMode mode) {
+ return switch (mode) {
+ case ON -> true;
+ case OFF -> false;
+ default -> capabilities.supportsNativeImages();
+ };
+ }
+
+ /** The preset's logo scaled to a small square, cached; null when there is no such resource or it cannot be read. */
+ ImageData logoFor(String logo) {
+ if (logo == null) {
+ return null;
+ }
+ return logos.computeIfAbsent(logo, name -> {
Review Comment:
Fixed in 879e0b6: both caches store the null result explicitly (containsKey, then put), with a test asserting that a missing logo is cached after the first lookup.
_Claude Code on behalf of luigidemasi_
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClient.java:
##########
@@ -0,0 +1,687 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Consumer;
+
+import org.apache.camel.dsl.jbang.core.common.VersionHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Minimal Agent Client Protocol (ACP) version 1 client: newline-delimited JSON-RPC 2.0 over an agent process's stdin
+ * and stdout. Implements only what the AI panel needs: initialize, authenticate, session/new, session/prompt,
+ * session/cancel, the session/update notification and the session/request_permission callback. Every other
+ * agent-initiated request is answered with "method not found".
+ */
+final class AcpAgentClient implements AutoCloseable {
+
+ static final int PROTOCOL_VERSION = 1;
+ static final int AUTH_REQUIRED = -32000;
+ static final int METHOD_NOT_FOUND = -32601;
+ /** Local error codes (never sent on the wire). */
+ static final int CONNECTION = -1;
+ static final int TIMEOUT = -2;
+ static final int INTERRUPTED = -3;
+ private static final int STDERR_TAIL_LINES = 50;
+ /** How long the next prompt waits for the response the agent still owes the turn cancelled before it. */
+ private static final Duration CANCELLED_TURN_GRACE = Duration.ofSeconds(10);
+
+ /** Receives session/update notifications during a prompt turn. Called on the reader thread. */
+ interface Listener {
+ void onTextChunk(String text);
+
+ void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput);
+
+ void onToolCallUpdate(String toolCallId, String status, String contentText);
+
+ void onUsage(long used, long size);
+ }
+
+ /** Answers session/request_permission. Returns the chosen optionId, or null to answer "cancelled". */
+ interface PermissionHandler {
+ String decide(JsonObject toolCall, List<JsonObject> options);
+ }
+
+ record AuthMethod(String id, String name, String description, String type) {
+ }
+
+ record AgentInfo(int protocolVersion, String name, String version, boolean httpMcp, List<AuthMethod> authMethods) {
+ String label() {
+ if (name == null) {
+ return "ACP agent";
+ }
+ return version == null ? name : name + " " + version;
+ }
+ }
+
+ /** A slash command advertised by the agent through {@code available_commands_update}. */
+ record AgentCommand(String name, String description, String hint) {
+ }
+
+ /** A request already written to the agent, with the future its response completes. */
+ private record Sent(long id, CompletableFuture<JsonObject> future) {
+ }
+
+ static final class AcpException extends RuntimeException {
+ private final int code;
+
+ AcpException(int code, String message) {
+ super(message);
+ this.code = code;
+ }
+
+ int code() {
+ return code;
+ }
+ }
+
+ private final BufferedReader reader;
+ private final Writer writer;
+ private final Process process;
+ private final Consumer<String> diagnostics;
+ private final Deque<String> stderrTail = new ArrayDeque<>();
+ private final Map<Long, CompletableFuture<JsonObject>> pending = new ConcurrentHashMap<>();
+ private final AtomicLong nextId = new AtomicLong(1);
+ private final ExecutorService requestExecutor = Executors.newSingleThreadExecutor(r -> daemon(r, "tui-acp-requests"));
+ private final CountDownLatch streamClosed = new CountDownLatch(1);
+ private volatile Listener listener;
+ private volatile String listenerSession;
+ private volatile PermissionHandler permissionHandler = (toolCall, options) -> null;
+ private volatile boolean closed;
+ private volatile Thread stderrThread;
+ private volatile AcpException exitFailure;
+ private volatile List<AgentCommand> availableCommands = List.of();
+ /** The session {@link #availableCommands} came from, tracked because the update may precede session/new's reply. */
+ private volatile String commandsSession;
+ private volatile String currentSession;
+ private volatile CompletableFuture<JsonObject> cancelledTurn;
+
+ AcpAgentClient(InputStream fromAgent, OutputStream toAgent, Consumer<String> diagnostics) {
+ this(fromAgent, toAgent, null, diagnostics);
+ }
+
+ private AcpAgentClient(InputStream fromAgent, OutputStream toAgent, Process process, Consumer<String> diagnostics) {
+ this.reader = new BufferedReader(new InputStreamReader(fromAgent, StandardCharsets.UTF_8));
+ this.writer = new BufferedWriter(new OutputStreamWriter(toAgent, StandardCharsets.UTF_8));
+ this.process = process;
+ this.diagnostics = diagnostics != null ? diagnostics : s -> {
+ };
+ }
+
+ /** Starts {@code command} in {@code cwd}, wires its stdin/stdout to a client and starts reading. */
+ static AcpAgentClient spawn(List<String> command, Path cwd, Consumer<String> diagnostics) throws IOException {
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.directory(cwd.toFile());
+ Process process = builder.start();
+ AcpAgentClient client = new AcpAgentClient(process.getInputStream(), process.getOutputStream(), process, diagnostics);
+ client.drainStderr(process.getErrorStream());
+ client.start();
+ return client;
+ }
+
+ private void drainStderr(InputStream err) {
+ stderrThread = daemon(() -> {
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(err, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ synchronized (stderrTail) {
+ stderrTail.addLast(line);
+ if (stderrTail.size() > STDERR_TAIL_LINES) {
+ stderrTail.removeFirst();
+ }
+ }
+ }
+ } catch (IOException ignored) {
+ // process gone
+ }
+ }, "tui-acp-stderr");
+ stderrThread.start();
+ }
+
+ void start() {
+ daemon(this::readLoop, "tui-acp-reader").start();
+ }
+
+ void setPermissionHandler(PermissionHandler handler) {
+ this.permissionHandler = handler != null ? handler : (toolCall, options) -> null;
+ }
+
+ boolean isAlive() {
+ return !closed && (process == null || process.isAlive());
+ }
+
+ String stderrTail() {
+ synchronized (stderrTail) {
+ return String.join("\n", stderrTail);
+ }
+ }
+
+ /** The agent's current slash commands (Claude Code's include its skills); empty until the agent advertises them. */
+ List<AgentCommand> availableCommands() {
+ return availableCommands;
+ }
+
+ // ---- ACP calls ----
+
+ AgentInfo initialize(Duration timeout) {
+ JsonObject fs = new JsonObject();
+ fs.put("readTextFile", false);
+ fs.put("writeTextFile", false);
+ JsonObject capabilities = new JsonObject();
+ capabilities.put("fs", fs);
+ capabilities.put("terminal", false);
+ JsonObject clientInfo = new JsonObject();
+ clientInfo.put("name", "camel-tui");
+ clientInfo.put("title", "Apache Camel TUI");
+ clientInfo.put("version", VersionHelper.getJBangVersion());
+ JsonObject params = new JsonObject();
+ params.put("protocolVersion", PROTOCOL_VERSION);
+ params.put("clientCapabilities", capabilities);
+ params.put("clientInfo", clientInfo);
+ JsonObject result = request("initialize", params, timeout);
+
+ int version = result.getIntegerOrDefault("protocolVersion", -1);
+ JsonObject agentCapabilities = result.getJsonObject("agentCapabilities");
+ JsonObject mcp = agentCapabilities != null ? agentCapabilities.getJsonObject("mcpCapabilities") : null;
+ boolean http = mcp != null && mcp.getBooleanOrDefault("http", false);
+ JsonObject agentInfo = result.getJsonObject("agentInfo");
+ List<AuthMethod> methods = new ArrayList<>();
+ JsonArray authMethods = result.getJsonArray("authMethods");
+ if (authMethods != null) {
+ for (Object o : authMethods) {
+ if (o instanceof JsonObject m) {
+ JsonObject meta = m.get("_meta") instanceof JsonObject mo ? mo : null;
+ String type = meta != null && meta.getString("type") != null ? meta.getString("type") : m.getString("type");
+ methods.add(new AuthMethod(m.getString("id"), m.getString("name"), m.getString("description"), type));
+ }
+ }
+ }
+ return new AgentInfo(
+ version,
+ agentInfo != null ? agentInfo.getString("name") : null,
+ agentInfo != null ? agentInfo.getString("version") : null,
+ http, List.copyOf(methods));
+ }
+
+ // ---- ACP calls (continued) ----
+
+ void authenticate(String methodId, Duration timeout) {
+ JsonObject params = new JsonObject();
+ params.put("methodId", methodId);
+ request("authenticate", params, timeout);
+ }
+
+ String newSession(Path cwd, String mcpUrl, Duration timeout) {
+ JsonObject server = new JsonObject();
+ server.put("type", "http");
+ server.put("name", "camel-tui");
+ server.put("url", mcpUrl);
+ server.put("headers", new JsonArray());
+ JsonArray servers = new JsonArray();
+ servers.add(server);
+ JsonObject params = new JsonObject();
+ params.put("cwd", cwd.toAbsolutePath().toString());
+ params.put("mcpServers", servers);
+ // the agent may advertise the new session's commands before it answers, so the old list goes first
+ currentSession = null;
+ commandsSession = null;
+ availableCommands = List.of();
+ // an old session's owed response must not delay this session's first question; its updates are filtered anyway
+ cancelledTurn = null;
+ JsonObject result = request("session/new", params, timeout);
+ String sessionId = result.getString("sessionId");
+ if (sessionId == null) {
+ throw new AcpException(CONNECTION, "session/new returned no sessionId");
+ }
+ currentSession = sessionId;
+ if (commandsSession != null && !commandsSession.equals(sessionId)) {
+ availableCommands = List.of();
+ }
+ return sessionId;
+ }
+
+ /**
+ * Sends one user message and blocks until the agent ends the turn. Updates are delivered to {@code listener} on the
+ * reader thread while this call blocks. Interrupting the calling thread sends session/cancel and returns
+ * "cancelled" without waiting for the agent; the calling thread's interrupt flag stays set in that case. Only one
+ * prompt may be in flight per client, because the client keeps a single listener for the turn.
+ */
+ String prompt(String sessionId, String text, Listener listener) {
+ Sent sent = null;
+ try {
+ awaitCancelledTurn();
+ this.listenerSession = sessionId;
+ this.listener = listener;
+ JsonObject block = new JsonObject();
+ block.put("type", "text");
+ block.put("text", text);
+ JsonArray prompt = new JsonArray();
+ prompt.add(block);
+ JsonObject params = new JsonObject();
+ params.put("sessionId", sessionId);
+ params.put("prompt", prompt);
+ sent = sendRequest("session/prompt", params);
+ JsonObject result = await(sent, "session/prompt", null);
+ return result.getStringOrDefault("stopReason", "end_turn");
+ } catch (AcpException e) {
+ if (e.code() == INTERRUPTED) {
+ if (sent != null) {
+ cancelledTurn = sent.future();
+ }
+ cancel(sessionId);
+ return "cancelled";
+ }
+ throw e;
+ } finally {
+ this.listener = null;
+ this.listenerSession = null;
+ }
+ }
+
+ /**
+ * Waits for the agent to answer the prompt cancelled last: ACP lets it keep streaming until that response, and
+ * those late updates must not reach the next turn's listener. Bounded, so a stuck agent cannot block the panel; on
+ * timeout the old turn is forgotten and the new prompt goes ahead.
+ */
+ private void awaitCancelledTurn() {
+ CompletableFuture<JsonObject> turn = cancelledTurn;
+ if (turn == null) {
+ return;
+ }
+ try {
+ turn.get(CANCELLED_TURN_GRACE.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (ExecutionException e) {
+ // the agent died or the stream closed: the next request reports that
+ } catch (TimeoutException e) {
+ diagnostics.accept("The cancelled turn did not finish within " + CANCELLED_TURN_GRACE.toSeconds() + "s");
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AcpException(INTERRUPTED, "session/prompt interrupted");
+ }
+ cancelledTurn = null;
+ }
+
+ void cancel(String sessionId) {
+ JsonObject params = new JsonObject();
+ params.put("sessionId", sessionId);
+ try {
+ notify("session/cancel", params);
+ } catch (AcpException e) {
+ diagnostics.accept("Cannot send session/cancel: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ currentSession = null;
+ requestExecutor.shutdownNow();
+ synchronized (writer) {
+ try {
+ writer.close();
+ } catch (IOException ignored) {
+ // closing anyway
+ }
+ }
+ if (process != null) {
+ process.destroy();
+ try {
+ if (!process.waitFor(5, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ }
+ } catch (InterruptedException e) {
+ process.destroyForcibly();
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ // ---- JSON-RPC plumbing ----
+
+ private void awaitStreamClosed() {
+ try {
+ streamClosed.await(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ JsonObject request(String method, JsonObject params, Duration timeout) {
+ return await(sendRequest(method, params), method, timeout);
+ }
+
+ private Sent sendRequest(String method, JsonObject params) {
+ long id = nextId.getAndIncrement();
+ CompletableFuture<JsonObject> future = new CompletableFuture<>();
+ pending.put(id, future);
+ JsonObject msg = new JsonObject();
+ msg.put("jsonrpc", "2.0");
+ msg.put("id", id);
+ msg.put("method", method);
+ if (params != null) {
+ msg.put("params", params);
+ }
+ if (closed) {
+ pending.remove(id);
+ throw exitFailure != null ? exitFailure : new AcpException(CONNECTION, "agent exited");
+ }
+ try {
+ send(msg);
+ } catch (AcpException e) {
+ pending.remove(id);
+ if (process != null) {
+ awaitStreamClosed();
+ if (exitFailure != null) {
+ throw exitFailure;
+ }
+ }
+ throw e;
+ }
+ return new Sent(id, future);
+ }
+
+ private JsonObject await(Sent sent, String method, Duration timeout) {
+ CompletableFuture<JsonObject> future = sent.future();
+ try {
+ return timeout == null ? future.get() : future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ throw cause instanceof AcpException acp ? acp : new AcpException(CONNECTION, String.valueOf(cause));
+ } catch (TimeoutException e) {
+ pending.remove(sent.id());
+ throw new AcpException(TIMEOUT, method + " timed out after " + timeout.toSeconds() + "s");
+ } catch (InterruptedException e) {
+ // the entry stays pending on purpose: a late response completes an unread future, which is what the next
+ // prompt waits for before installing its own listener
+ Thread.currentThread().interrupt();
+ throw new AcpException(INTERRUPTED, method + " interrupted");
+ }
+ }
+
+ void notify(String method, JsonObject params) {
+ JsonObject msg = new JsonObject();
+ msg.put("jsonrpc", "2.0");
+ msg.put("method", method);
+ if (params != null) {
+ msg.put("params", params);
+ }
+ send(msg);
+ }
+
+ private void send(JsonObject msg) {
+ String line = Jsoner.serialize(msg);
+ synchronized (writer) {
+ try {
+ writer.write(line);
+ writer.write('\n');
+ writer.flush();
+ } catch (IOException e) {
+ throw new AcpException(CONNECTION, "Cannot write to agent: " + e.getMessage());
+ }
+ }
+ }
+
+ private void readLoop() {
+ try {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.isBlank()) {
+ continue;
+ }
+ JsonObject msg = Jsoner.deserialize(line, (JsonObject) null);
+ if (msg == null || !msg.containsKey("jsonrpc")) {
+ diagnostics.accept("Ignoring malformed line from agent: " + abbreviate(line));
+ continue;
+ }
+ try {
+ dispatch(msg);
+ } catch (RuntimeException e) {
+ diagnostics.accept("Cannot handle message from agent: " + e);
+ }
+ }
+ } catch (IOException e) {
+ if (!closed) {
+ diagnostics.accept("Agent stream error: " + e.getMessage());
+ }
+ } finally {
+ onStreamClosed();
+ }
+ }
+
+ private void dispatch(JsonObject msg) {
+ Object id = msg.get("id");
+ String method = msg.getString("method");
+ if (method == null && id != null) {
+ if (!(id instanceof Number number)) {
+ return;
+ }
+ CompletableFuture<JsonObject> future = pending.remove(number.longValue());
+ if (future == null) {
+ return;
+ }
+ JsonObject error = msg.getJsonObject("error");
+ if (error != null) {
+ future.completeExceptionally(new AcpException(
+ error.getIntegerOrDefault("code", CONNECTION), error.getStringOrDefault("message", "error")));
+ } else {
+ JsonObject result = msg.getJsonObject("result");
+ future.complete(result != null ? result : new JsonObject());
+ }
+ } else if (method != null && id != null) {
+ JsonObject params = msg.getJsonObject("params");
+ requestExecutor.execute(() -> handleRequest(id, method, params));
+ } else if (method != null) {
+ handleNotification(method, msg.getJsonObject("params"));
+ }
+ }
+
+ private void handleRequest(Object id, String method, JsonObject params) {
+ JsonObject response = new JsonObject();
+ response.put("jsonrpc", "2.0");
+ response.put("id", id);
+ if ("session/request_permission".equals(method) && params != null) {
+ JsonObject toolCall = params.getJsonObject("toolCall");
+ List<JsonObject> options = new ArrayList<>();
+ JsonArray array = params.getJsonArray("options");
+ if (array != null) {
+ for (Object o : array) {
+ if (o instanceof JsonObject option) {
+ options.add(option);
+ }
+ }
+ }
+ String optionId;
+ try {
+ optionId = permissionHandler.decide(toolCall != null ? toolCall : new JsonObject(), options);
+ } catch (RuntimeException e) {
+ diagnostics.accept("Permission handler failed: " + e.getMessage());
+ optionId = null;
+ }
+ JsonObject outcome = new JsonObject();
+ if (optionId == null) {
+ outcome.put("outcome", "cancelled");
+ } else {
+ outcome.put("outcome", "selected");
+ outcome.put("optionId", optionId);
+ }
+ JsonObject result = new JsonObject();
+ result.put("outcome", outcome);
+ response.put("result", result);
+ } else {
+ JsonObject error = new JsonObject();
+ error.put("code", METHOD_NOT_FOUND);
+ error.put("message", "Method not found: " + method);
+ response.put("error", error);
+ }
+ try {
Review Comment:
Changed in cc56491: the diagnostic now reads "Ignoring line that is not a JSON-RPC object".
_Claude Code on behalf of luigidemasi_
##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopup.java:
##########
@@ -0,0 +1,207 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.MouseEvent;
+import dev.tamboui.tui.event.MouseEventKind;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.list.ListItem;
+import dev.tamboui.widgets.list.ListState;
+import dev.tamboui.widgets.list.ListWidget;
+import dev.tamboui.widgets.list.ScrollMode;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Asks the user to answer an ACP {@code session/request_permission}: shows the tool title, kind and the first lines of
+ * its raw input, then the options exactly as the agent sent them. Enter selects, Esc picks a reject-once option when
+ * the agent offers one and otherwise answers "cancelled".
+ */
+final class AcpPermissionPopup {
+
+ private static final int MAX_INPUT_LINES = 8;
+
+ /** {@code optionId} is null when the request should be answered "cancelled". */
+ record Decision(String optionId) {
+ }
+
+ // open() is called from the ACP request thread while isVisible()/render()/handleKeyEvent() run on the TUI event
+ // thread; volatile so the write to visible (the last statement of open()) publishes the other fields set above it.
+ private volatile boolean visible;
+ private String title = "";
+ private String kind = "";
+ private List<String> inputLines = List.of();
+ private List<JsonObject> options = List.of();
+ private final ListState listState = new ListState();
+ private Decision pending;
+ private Rect listRect;
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ Rect listRectForTesting() {
+ return listRect;
+ }
+
+ void open(JsonObject toolCall, List<JsonObject> options) {
+ this.title = String.valueOf(toolCall.getStringOrDefault("title", "Use tool?"));
+ this.kind = String.valueOf(toolCall.getStringOrDefault("kind", "other"));
+ this.inputLines = formatInput(toolCall.get("rawInput"));
+ this.options = new ArrayList<>(options);
+ listState.selectFirst();
+ pending = null;
+ visible = true;
+ }
+
+ void close() {
+ visible = false;
+ }
+
+ Decision consumeDecision() {
+ Decision decision = pending;
+ pending = null;
+ return decision;
+ }
+
+ boolean handleMouseEvent(MouseEvent me) {
+ if (!visible) {
+ return false;
+ }
+ if (me.kind() == MouseEventKind.SCROLL_UP) {
+ handleKeyEvent(KeyEvent.ofKey(KeyCode.UP));
+ } else if (me.kind() == MouseEventKind.SCROLL_DOWN) {
+ handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN));
+ } else if (me.isClick() && listRect != null && listRect.contains(me.x(), me.y())) {
+ // only a click on an option row answers; a click elsewhere in the dialog must not grant anything.
+ // listRect is already the list's own content area (no border to skip), unlike the bordered popup
+ // rects TuiHelper.listItemAt expects everywhere else it is called, so the row is computed directly.
+ int idx = listState.offset() + (me.y() - listRect.y());
+ if (idx >= 0 && idx < options.size()) {
+ listState.select(idx);
+ handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+ }
+ }
+ return true;
+ }
+
Review Comment:
No change: the publication pattern is intentional, as the comment says.
_Claude Code on behalf of luigidemasi_
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "davsclaus (via GitHub)" <gi...@apache.org>.
davsclaus merged PR #26244:
URL: https://github.com/apache/camel/pull/26244
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #26244:
URL: https://github.com/apache/camel/pull/26244#issuecomment-5609317133
<!-- ci-tested-modules -->
:test_tube: **CI tested the following changed modules:**
- `docs`
- `dsl/camel-jbang/camel-jbang-plugin-tui`
---
<details><summary>:microscope: Scalpel shadow comparison — Scalpel: 3 tested, 1 compile-only — current: 2 all tested</summary>
[Maveniverse Scalpel](https://github.com/maveniverse/scalpel) detected **4 affected modules** (current approach: 2).
<details><summary>:warning: Modules only in Scalpel (2)</summary>
- `camel-launcher`
- `docs`
</details>
**Skip-tests mode would test 3 modules** (2 direct + 1 downstream), **skip tests for 1** (generated code, meta-modules)
<details><summary>Modules Scalpel would test (3)</summary>
- `camel-jbang-plugin-tui`
- `camel-launcher-container`
- `docs`
</details>
<details><summary>Modules with tests skipped (1)</summary>
- `camel-launcher`
</details>
> :information_source: Shadow mode — Scalpel observes but does not affect test execution. [Learn more](https://github.com/maveniverse/scalpel)
</details>
<details><summary>All tested modules (4 modules, 1m 42s total)</summary>
**Total reactor time:** 1m 42s
| Module | Duration | Status |
| --- | --- | --- |
| Camel :: JBang :: Plugin :: TUI | 42.7s | SUCCESS |
| Camel :: Launcher | 36.5s | SUCCESS |
| Camel :: Docs | 21.6s | SUCCESS |
| Camel :: Launcher :: Container | 0.8s | SUCCESS |
**Top 20 slowest modules:**
- `Camel :: JBang :: Plugin :: TUI` (42.7s)
- `Camel :: Launcher` (36.5s)
- `Camel :: Docs` (21.6s)
- `Camel :: Launcher :: Container` (0.8s)
</details>
---
:gear: [View full build and test results](https://github.com/apache/camel/actions/runs/34392097057)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "davsclaus (via GitHub)" <gi...@apache.org>.
davsclaus commented on PR #26244:
URL: https://github.com/apache/camel/pull/26244#issuecomment-5607130116
rebased so the tui test with the budget is fixed
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "davsclaus (via GitHub)" <gi...@apache.org>.
davsclaus commented on PR #26244:
URL: https://github.com/apache/camel/pull/26244#issuecomment-5615530313
@luigidemasi can you rebase and fix the merge conflict - I did some TUI work as well ... sorry ;)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-24663: camel-jbang-plugin-tui - drive the TUI from coding agents over the Agent Client Protocol (ACP) [camel]
Posted by "luigidemasi (via GitHub)" <gi...@apache.org>.
luigidemasi commented on PR #26244:
URL: https://github.com/apache/camel/pull/26244#issuecomment-5615838766
Understood, the logos are gone.
`src/main/resources/tui/logos/` is deleted (six PNGs), and with it the image and kitty-graphics code in `AcpHeaderStrip`, the `camel.tui.ai.acp.logos` setting and its F2 Settings row. The per-preset glyph and colour pairs are gone too: those were stylised vendor identities as well, so the header now draws one neutral symbol in the panel's own style, and the vendor is named in the text the header already showed (`Claude Code (ACP)`, the agent's own name and version from `initialize`, the session id, the working directory and the command count).
No dependency change either way: `tamboui-image` is declared in the module `pom.xml` on `main` already (added by CAMEL-23420), so this PR neither adds nor removes it.
_Claude Code on behalf of luigidemasi_
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org