You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/09/10 11:05:40 UTC
(camel) branch main updated: CAMEL-24663: Camel TUI - drive the TUI from coding agents over ACP (#26244)
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new e69a5860507d CAMEL-24663: Camel TUI - drive the TUI from coding agents over ACP (#26244)
e69a5860507d is described below
commit e69a5860507db37e4e352192a9ae413ad6a68096
Author: Luigi De Masi <55...@users.noreply.github.com>
AuthorDate: Thu Sep 10 13:05:33 2026 +0200
CAMEL-24663: Camel TUI - drive the TUI from coding agents over ACP (#26244)
Adds an external coding agent as a new AI provider for the camel tui F8 panel,
spoken to over the Agent Client Protocol (ACP v1) as newline-delimited JSON-RPC
over stdio. The agent runs as a TUI subprocess, receives the TUI's own MCP server
in session/new, and drives the TUI through the same tui_* tools external MCP
clients already use, while its answer streams into the panel.
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 existing LLM providers are untouched.
Includes presets plus acp:custom, a new F2 "ACP Command" settings row (and a
Settings popup that now scrolls the selected row into view on short terminals),
an on-demand embedded MCP server when --mcp was not given, a permission popup
that silently approves only registered read-only camel-tui tools, agent slash
commands surfaced as /agent:<name>, a two-row header, user-manual docs and new
tests around an in-process fake ACP agent.
Closes #26244
Co-authored-by: Luigi De Masi <ld...@redhat.com>
Co-Authored-By: Claude Fable 5.1 <no...@anthropic.com>
---
.../modules/ROOT/pages/camel-jbang-tui.adoc | 61 ++
.../jbang/core/commands/tui/AcpAgentClient.java | 687 ++++++++++++++++
.../jbang/core/commands/tui/AcpHeaderStrip.java | 77 ++
.../core/commands/tui/AcpPermissionPopup.java | 207 +++++
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 701 ++++++++++++++++-
.../core/commands/tui/AiProviderSelector.java | 162 +++-
.../core/commands/tui/AiProviderSwitchPopup.java | 3 +
.../core/commands/tui/AiSlashCommandRegistry.java | 32 +
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 35 +-
.../dsl/jbang/core/commands/tui/SettingsPopup.java | 69 +-
.../dsl/jbang/core/commands/tui/TuiIcons.java | 2 +
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 9 +-
.../dsl/jbang/core/commands/tui/TuiSettings.java | 12 +
.../jbang/core/commands/tui/TuiToolRegistry.java | 12 +
.../core/commands/tui/AcpAgentClientTest.java | 553 +++++++++++++
.../core/commands/tui/AcpHeaderStripTest.java | 66 ++
.../core/commands/tui/AcpPermissionPopupTest.java | 160 ++++
.../jbang/core/commands/tui/AiPanelAcpTest.java | 863 +++++++++++++++++++++
.../core/commands/tui/AiProviderSelectorTest.java | 87 ++-
.../commands/tui/AiProviderSwitchPopupTest.java | 8 +
.../commands/tui/AiSlashCommandRegistryTest.java | 33 +
.../dsl/jbang/core/commands/tui/FakeAcpAgent.java | 267 +++++++
.../core/commands/tui/SettingsPopupRenderTest.java | 39 +-
.../jbang/core/commands/tui/SettingsPopupTest.java | 9 +
.../core/commands/tui/TuiMcpServerPortTest.java | 35 +
.../jbang/core/commands/tui/TuiSettingsTest.java | 2 +
.../commands/tui/TuiToolRegistryCoreToolsTest.java | 19 +
27 files changed, 4175 insertions(+), 35 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index 7ef06c41ef64..ac2c78c22f4a 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -792,6 +792,7 @@ A question can span several lines: *Ctrl+N* starts a new line (terminals deliver
The same guidance, together with the provider that is currently detected, is available inside the TUI
via *F2* -> _AI & MCP_ -> _Setup AI_. Use *F2* -> _Settings_ to pin a provider, model or base URL
(`camel.tui.ai.provider`, `camel.tui.ai.model`, `camel.tui.ai.url`) regardless of the environment.
+`camel.tui.ai.acp.command` sets the command line of the custom ACP agent (`acp:custom`).
==== Using Ollama (local, no API key)
@@ -1018,6 +1019,66 @@ prompt was processed again. For OpenAI, Anthropic and Gemini it prints the numbe
tokens the provider reported. Use it to check that follow-up questions are cheap before blaming the
model for being slow.
+=== Using a coding agent (ACP)
+
+Instead of talking to a model directly, the AI panel can hand the conversation to an external coding agent that
+speaks the https://agentclientprotocol.com[Agent Client Protocol] (ACP). The TUI starts the agent as a subprocess,
+gives it the TUI's own MCP server, and shows the agent's answer, tool calls and questions in the panel. The agent
+drives the TUI through the same `tui_*` tools that external MCP clients use, and can also read and edit your
+route sources with its own tools.
+
+Press *Ctrl+P* in the AI panel and pick one of the agents:
+
+[options="header"]
+|===
+| Provider | Command the TUI runs | Before the first question
+| `acp:claude` | `npx -y @agentclientprotocol/claude-agent-acp` | log in with the `claude` CLI, or set `ANTHROPIC_API_KEY`
+| `acp:codex` | `npx -y @agentclientprotocol/codex-acp` | `codex login`, or set `OPENAI_API_KEY`
+| `acp:bob` | `bob acp` | set `BOBSHELL_API_KEY`, or run `bob` once to sign in
+| `acp:qwen` | `qwen --acp` | set `OPENAI_API_KEY` and `OPENAI_BASE_URL`
+| `acp:opencode` | `opencode acp` | `opencode auth login`
+| `acp:dsh` | `npx -y @deepseek-ai/dsh --profile acp` | configure the model key in DeepSeek Harness (developer preview)
+| `acp:custom` | the value of `camel.tui.ai.acp.command` | depends on the agent
+|===
+
+The `npx` entries need Node.js 22 or newer (the Claude adapter requires it). The agent starts with your first
+question; the first start can take a while when `npx` has to download the adapter. Once the session is open the
+panel shows a two-row header with the agent's coloured glyph, the agent's name and version, the session id, the
+working directory and the number of commands it advertises. Use *F2* -> _Settings_ to make an
+agent the default provider or to set the custom command (a plain command line split on whitespace, no quoting).
+
+The MCP server is started automatically on a random localhost port when an agent needs it, so `--mcp` is not
+required. *F2* -> _MCP Info_ shows the port and the tool calls the agent makes. Like the server started with
+`--mcp`, it is bound to `127.0.0.1` with no authentication, and it rejects requests that carry an `Origin`
+header (so a web page cannot reach it) or that are not JSON.
+
+Permissions: calls to the TUI's read-only tools (the `tui_get_*` tools, catalog documentation, locate, validate)
+are approved without asking. A tool that changes anything, such as `tui_control`, `tui_send_message` or
+`tui_execute_sql`, opens the same popup as any other request; answering "Always allow" makes it a one-time
+question per tool. For anything else the agent wants to do,
+such as editing a file or running a command, a popup shows the agent's options; *Enter* selects, *Esc* rejects
+that one call and lets the turn continue, and *Ctrl+C* cancels the whole turn.
+"Always allow" choices are remembered by the agent for the session. The model, reasoning settings and any
+"always allow" rules are configured in the agent, not in the TUI; `/model` only reports the agent in use.
+
+The agent's own slash commands, for example Claude Code skills, appear as `/agent:<name>` in the `/` completion
+hints once the session is open; `/agent:` alone lists them. `/agent:<name> …` always reaches the agent, even when
+the name is also a panel command (`/agent:clear` clears the agent's context, `/clear` the panel). Anything else
+starting with `/` that is not a panel command is sent to the agent as is.
+
+The panel's own `/retry` resends the last question (or `/agent:` command) to the agent. `/context` shows the agent,
+its session, working directory, MCP server and the size of the preamble instead of a model history, and `/prompt`
+shows that preamble, sent once per session ahead of the first prompt. `/compact` and `/tools` do not apply while an
+agent is selected: the agent manages its own history and reaches the camel-tui tools through the MCP server; the panel
+says so and points to `/agent:compact` when the agent offers that command.
+
+If the agent asks for authentication and can sign you in itself, the TUI triggers that flow once; otherwise the
+panel shows the login command from the table above.
+
+NOTE: Gemini CLI, Google Antigravity and Pi are not offered as presets. Gemini CLI stopped serving personal Google
+accounts in June 2026, Antigravity's CLI has no ACP mode yet, and Pi's community adapter does not pass MCP servers
+through. Any ACP agent can still be tried through `acp:custom`.
+
=== Connecting an AI Agent
To connect Claude Code to the TUI, add the MCP server to your project configuration
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClient.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClient.java
new file mode 100644
index 000000000000..d3466bdc601f
--- /dev/null
+++ b/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 line that is not a JSON-RPC object: " + 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 {
+ send(response);
+ } catch (AcpException e) {
+ diagnostics.accept("Cannot answer " + method + ": " + e.getMessage());
+ }
+ }
+
+ private void handleNotification(String method, JsonObject params) {
+ if (!"session/update".equals(method) || params == null) {
+ return;
+ }
+ JsonObject update = params.getJsonObject("update");
+ if (update == null) {
+ return;
+ }
+ String kind = update.getStringOrDefault("sessionUpdate", "");
+ if ("available_commands_update".equals(kind)) {
+ String current = currentSession;
+ String updateSession = params.getString("sessionId");
+ if (current != null && updateSession != null && !current.equals(updateSession)) {
+ // an update from a session abandoned (for example after /clear) must not reach the new session
+ return;
+ }
+ commandsSession = updateSession;
+ List<AgentCommand> commands = new ArrayList<>();
+ if (update.get("availableCommands") instanceof JsonArray array) {
+ for (Object o : array) {
+ if (o instanceof JsonObject c && c.getString("name") != null) {
+ JsonObject input = c.get("input") instanceof JsonObject in ? in : null;
+ commands.add(new AgentCommand(
+ c.getString("name"), c.getStringOrDefault("description", ""),
+ input != null ? input.getString("hint") : null));
+ }
+ }
+ }
+ availableCommands = List.copyOf(commands);
+ return;
+ }
+ Listener target = listener;
+ String session = listenerSession;
+ if (target == null) {
+ return;
+ }
+ String updateSession = params.getString("sessionId");
+ if (session != null && updateSession != null && !session.equals(updateSession)) {
+ // an update from an abandoned session (for example after /clear) must not reach the current turn
+ return;
+ }
+ switch (kind) {
+ case "agent_message_chunk" -> {
+ JsonObject content = update.getJsonObject("content");
+ if (content != null && "text".equals(content.getString("type"))) {
+ target.onTextChunk(content.getStringOrDefault("text", ""));
+ }
+ }
+ case "tool_call" -> {
+ Object raw = update.get("rawInput");
+ target.onToolCall(update.getString("toolCallId"), update.getStringOrDefault("title", "tool"),
+ update.getStringOrDefault("kind", "other"), raw instanceof JsonObject jo ? jo : null);
+ }
+ case "tool_call_update" -> target.onToolCallUpdate(update.getString("toolCallId"),
+ update.getStringOrDefault("status", ""), textOf(update.get("content")));
+ case "usage_update" -> target.onUsage(update.getLongOrDefault("used", 0), update.getLongOrDefault("size", 0));
+ default -> {
+ // thoughts, plans, mode/command/config updates and unknown kinds are ignored on purpose
+ }
+ }
+ }
+
+ /** Concatenates the text of {@code {type:"content", content:{type:"text", text}}} blocks; null when none. */
+ private static String textOf(Object content) {
+ if (!(content instanceof JsonArray blocks)) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (Object o : blocks) {
+ if (o instanceof JsonObject block && block.get("content") instanceof JsonObject inner
+ && "text".equals(inner.getString("type"))) {
+ sb.append(inner.getStringOrDefault("text", ""));
+ }
+ }
+ return sb.isEmpty() ? null : sb.toString();
+ }
+
+ private void onStreamClosed() {
+ try {
+ Thread drain = stderrThread;
+ if (drain != null) {
+ try {
+ drain.join(2_000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ String reason = "agent exited";
+ if (process != null) {
+ try {
+ process.waitFor(2, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ if (!process.isAlive()) {
+ reason = "agent exited with code " + process.exitValue();
+ }
+ }
+ String tail = stderrTail();
+ if (!tail.isBlank()) {
+ reason += "\n" + tail;
+ }
+ AcpException failure = new AcpException(CONNECTION, reason);
+ exitFailure = failure;
+ closed = true;
+ for (CompletableFuture<JsonObject> future : pending.values()) {
+ future.completeExceptionally(failure);
+ }
+ pending.clear();
+ } finally {
+ streamClosed.countDown();
+ }
+ }
+
+ private static String abbreviate(String line) {
+ return line.length() <= 120 ? line : line.substring(0, 117) + "...";
+ }
+
+ private static Thread daemon(Runnable task, String name) {
+ Thread t = new Thread(task, name);
+ t.setDaemon(true);
+ return t;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStrip.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStrip.java
new file mode 100644
index 000000000000..c015fe50709a
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStrip.java
@@ -0,0 +1,77 @@
+/*
+ * 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.nio.file.Path;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.widgets.paragraph.Paragraph;
+
+/**
+ * Two-row header shown under the AI panel title while an ACP session is open: the agent's 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;
+
+ record Model(String presetLabel, String glyph, Color color, String agentLabel, String sessionId, Path cwd,
+ int commandCount) {
+ }
+
+ void render(Frame frame, Rect area, Model model) {
+ if (area.height() < ROWS || area.width() < 20) {
+ return;
+ }
+ Style accent = Style.EMPTY.fg(model.color()).bold();
+ String title = model.presetLabel() + " · " + model.agentLabel();
+ Line first = Line.from(Span.styled(model.glyph() + " ", accent), Span.styled(title, accent));
+ Line second = Line.from(Span.styled(" " + metaLine(model), Style.EMPTY.dim()));
+ frame.renderWidget(Paragraph.from(first), new Rect(area.x(), area.y(), area.width(), 1));
+ frame.renderWidget(Paragraph.from(second), new Rect(area.x(), area.y() + 1, area.width(), 1));
+ }
+
+ static String metaLine(Model model) {
+ String session = model.sessionId() == null ? "?" : model.sessionId();
+ if (session.length() > 8) {
+ session = session.substring(0, 8);
+ }
+ String commands = model.commandCount() == 0
+ ? "no commands yet"
+ : model.commandCount() + (model.commandCount() == 1 ? " command" : " commands");
+ return "session " + session + " · " + homeRelative(model.cwd()) + " · " + commands + " · /agent: lists them";
+ }
+
+ static String homeRelative(Path path) {
+ if (path == null) {
+ return "?";
+ }
+ String home = System.getProperty("user.home");
+ String value = path.toAbsolutePath().toString();
+ if (home != null && !home.isBlank() && value.startsWith(home)) {
+ return "~" + value.substring(home.length());
+ }
+ return value;
+ }
+
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopup.java
new file mode 100644
index 000000000000..aeeb893f63bb
--- /dev/null
+++ b/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;
+ }
+
+ void handleKeyEvent(KeyEvent ke) {
+ if (ke.isCancel()) {
+ pending = new Decision(firstOptionOfKind("reject_once"));
+ close();
+ } else if (ke.isUp()) {
+ listState.selectPrevious();
+ } else if (ke.isDown()) {
+ listState.selectNext(options.size());
+ } else if (ke.isConfirm()) {
+ Integer selected = listState.selected();
+ if (selected != null && selected < options.size()) {
+ pending = new Decision(options.get(selected).getString("optionId"));
+ close();
+ }
+ }
+ }
+
+ private String firstOptionOfKind(String optionKind) {
+ for (JsonObject option : options) {
+ if (optionKind.equals(option.getString("kind"))) {
+ return option.getString("optionId");
+ }
+ }
+ return null;
+ }
+
+ void render(Frame frame, Rect area) {
+ int headerRows = 2 + inputLines.size() + 1;
+ int popupW = Math.max(20, Math.min(80, area.width() - 4));
+ int popupH = Math.max(6, Math.min(2 + headerRows + options.size(), area.height() - 2));
+ Rect popup = DialogHelper.centered(area, popupW, popupH);
+ frame.renderWidget(Clear.INSTANCE, popup);
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+ .title(" Permission ")
+ .build();
+ frame.renderWidget(block, popup);
+ Rect inner = block.inner(popup);
+ // the choices come first: on a short screen the header is clipped (title first, so it survives longest)
+ int optionRows = Math.min(options.size(), Math.max(1, inner.height() - 1));
+ int headerHeight = Math.max(0, Math.min(headerRows, inner.height() - optionRows));
+ List<Rect> parts = Layout.vertical()
+ .constraints(Constraint.length(headerHeight), Constraint.fill())
+ .split(inner);
+
+ List<Line> header = new ArrayList<>();
+ header.add(Line.from(Span.styled(title, Style.EMPTY.bold())));
+ header.add(Line.from(Span.styled("kind: " + kind, Style.EMPTY.dim())));
+ for (String line : inputLines) {
+ header.add(Line.from(Span.styled(line, Style.EMPTY.dim())));
+ }
+ header.add(Line.from(Span.styled("Allow the agent to run this tool?", Style.EMPTY)));
+ frame.renderWidget(Paragraph.builder().text(Text.from(header.toArray(Line[]::new))).build(), parts.get(0));
+
+ List<ListItem> items = new ArrayList<>();
+ for (JsonObject option : options) {
+ items.add(ListItem.from(Line.from(Span.styled(" " + option.getStringOrDefault("name", "?"), Style.EMPTY))));
+ }
+ ListWidget list = ListWidget.builder()
+ .items(items.toArray(ListItem[]::new))
+ .scrollMode(ScrollMode.AUTO_SCROLL)
+ .highlightStyle(Theme.selectionBg())
+ .highlightSymbol("")
+ .build();
+ this.listRect = parts.get(1);
+ frame.renderStatefulWidget(list, parts.get(1), listState);
+ }
+
+ void renderFooter(List<Span> spans) {
+ TuiHelper.hint(spans, "Enter", "select");
+ TuiHelper.hint(spans, "Ctrl+C", "cancel turn");
+ TuiHelper.hintLast(spans, "Esc", "reject");
+ }
+
+ private static List<String> formatInput(Object rawInput) {
+ if (rawInput == null) {
+ return List.of();
+ }
+ String[] lines = Jsoner.prettyPrint(Jsoner.serialize(rawInput)).split("\n");
+ List<String> out = new ArrayList<>();
+ for (int i = 0; i < lines.length && i < MAX_INPUT_LINES; i++) {
+ out.add(lines[i]);
+ }
+ if (lines.length > MAX_INPUT_LINES) {
+ out.add("…");
+ }
+ return out;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index aa5087c567d1..02eee700d536 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -19,12 +19,14 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -32,8 +34,11 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
@@ -190,6 +195,9 @@ class AiPanel {
private long thinkingStartTime;
private volatile String thinkingVerb;
private volatile int sessionTotalTokens;
+ /** What the ACP agent last reported in its context window, and how big that window is (0 = not reported). */
+ private volatile long acpContextUsed;
+ private volatile long acpContextSize;
private volatile long sessionToolTimeMs;
private volatile int sessionToolCalls;
/** Per answered question: [aiMs, toolMs, toolCalls], in order, for the time chart in the usage view. */
@@ -250,6 +258,32 @@ class AiPanel {
private List<AiProviderSwitchPopup.ProviderChoice> providerChoicesForTesting;
private boolean testingClientInjected;
+ // ACP backend: set when the selected provider is an external coding agent (provider id "acp:*"). The agent
+ // process is spawned lazily on the first prompt so a slow first npx download never freezes the UI.
+ private static final Duration ACP_INIT_TIMEOUT = Duration.ofSeconds(120);
+ private static final Duration ACP_SESSION_TIMEOUT = Duration.ofSeconds(60);
+ private static final Duration ACP_AUTH_TIMEOUT = Duration.ofSeconds(300);
+ private static final String TUI_TOOL_PREFIX = "mcp__camel-tui__";
+ private static final Set<String> FILE_OR_SHELL_KINDS = Set.of("edit", "delete", "move", "execute", "fetch");
+ private static final String AGENT_COMMAND_PREFIX = "/agent:";
+ private volatile AiProviderSelector.AcpPreset acpPreset;
+ private volatile AcpAgentClient acpClient;
+ private volatile AcpAgentClient.AgentInfo acpAgentInfo;
+ private volatile String acpSessionId;
+ private volatile boolean acpPreambleSent;
+ private final AcpHeaderStrip acpHeader = new AcpHeaderStrip();
+ private String acpMcpUrl;
+ private Path acpCwd;
+ private AcpClientFactory acpClientFactory = this::spawnAcpAgent;
+ private Callable<String> mcpUrlSupplier;
+ private final AcpPermissionPopup permissionPopup = new AcpPermissionPopup();
+ private volatile CompletableFuture<String> pendingPermission;
+
+ /** Creates the client for a preset; replaced in tests with one backed by {@code FakeAcpAgent}. */
+ interface AcpClientFactory {
+ AcpAgentClient create(AiProviderSelector.AcpPreset preset, Path cwd) throws IOException;
+ }
+
// MCP facade for TUI tool access from the AI panel
private McpFacade mcpFacade;
// /write: confirm (dialog per write), auto (the model may skip it with confirm=false) or live (the edit is replayed
@@ -457,10 +491,26 @@ class AiPanel {
void destroy() {
close();
stopAgentThread();
+ closeAcpClient();
}
private void initClient() {
modelCompletionCache = null;
+ String provider = sessionProviderChoice != null
+ ? sessionProviderChoice.provider() : TuiSettings.load().getAiProvider();
+ if (AiProviderSelector.isAcp(provider)) {
+ try {
+ TuiSettings settings = TuiSettings.load();
+ acpPreset = providerSelector.acpPreset(provider, settings);
+ initError = null;
+ } catch (IllegalArgumentException e) {
+ acpPreset = null;
+ initError = e.getMessage();
+ }
+ client = null;
+ return;
+ }
+ acpPreset = null;
try {
LlmClient created = LlmClient.create()
.withTemperature(0.3)
@@ -495,7 +545,26 @@ class AiPanel {
private void applyProviderChoice(AiProviderSwitchPopup.ProviderChoice choice) {
stopAgentThread();
+ closeAcpClient();
sessionProviderChoice = choice;
+ if (AiProviderSelector.isAcp(choice.provider())) {
+ client = null;
+ initError = null;
+ try {
+ TuiSettings settings = TuiSettings.load();
+ acpPreset = providerSelector.acpPreset(choice.provider(), settings);
+ } catch (IllegalArgumentException e) {
+ acpPreset = null;
+ sessionProviderChoice = null;
+ conversation.add(new ConversationEntry(AiRole.ERROR, e.getMessage()));
+ return;
+ }
+ conversation.add(new ConversationEntry(
+ AiRole.SYSTEM,
+ "Selected " + acpPreset.label() + ". The agent starts with your first question."));
+ return;
+ }
+ acpPreset = null;
if (testingClientInjected && client != null) {
// Keep tests independent of the locally installed camel-jbang-core artifact.
} else {
@@ -514,6 +583,32 @@ class AiPanel {
}
}
+ private void closeAcpClient() {
+ AcpAgentClient agent = acpClient;
+ acpClient = null;
+ acpSessionId = null;
+ acpAgentInfo = null;
+ acpPreambleSent = false;
+ acpContextUsed = 0;
+ acpContextSize = 0;
+ if (agent != null) {
+ // Do not block the TUI event thread: close() destroys the agent process and waits up to 5 seconds for
+ // it to die. Nothing here observes the shutdown, so it runs on a short-lived daemon thread.
+ Thread closer = new Thread(agent::close, "tui-acp-close");
+ closer.setDaemon(true);
+ closer.start();
+ }
+ }
+
+ private String acpLabel() {
+ AcpAgentClient.AgentInfo info = acpAgentInfo;
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ if (info != null) {
+ return info.label();
+ }
+ return preset != null ? preset.label() : "ACP agent";
+ }
+
private String displayModel(AiProviderSwitchPopup.ProviderChoice choice) {
return choice.model() == null || choice.model().isBlank() ? "auto" : choice.model();
}
@@ -552,6 +647,15 @@ class AiPanel {
copyChoice(copyPopup.consumePendingChoice());
return handled;
}
+ if (permissionPopup.isVisible()) {
+ boolean handled = permissionPopup.handleMouseEvent(me);
+ AcpPermissionPopup.Decision decision = permissionPopup.consumeDecision();
+ CompletableFuture<String> pending = pendingPermission;
+ if (decision != null && pending != null) {
+ pending.complete(decision.optionId());
+ }
+ return handled;
+ }
if (!TuiHelper.contains(lastArea, me.x(), me.y())) {
return false;
}
@@ -567,6 +671,21 @@ class AiPanel {
}
boolean handleKeyEvent(KeyEvent ke) {
+ if (permissionPopup.isVisible()) {
+ if (ke.isCtrlC()) {
+ interruptBusyOperation();
+ return true;
+ }
+ permissionPopup.handleKeyEvent(ke);
+ AcpPermissionPopup.Decision decision = permissionPopup.consumeDecision();
+ if (decision != null) {
+ CompletableFuture<String> pending = pendingPermission;
+ if (pending != null) {
+ pending.complete(decision.optionId());
+ }
+ }
+ return true;
+ }
if (providerSwitchPopup.isVisible()) {
providerSwitchPopup.handleKeyEvent(ke);
AiProviderSwitchPopup.ProviderChoice choice = providerSwitchPopup.consumePendingChoice();
@@ -916,7 +1035,7 @@ class AiPanel {
currentToken = argument.token();
completionStart = argument.start();
} else {
- names = slashCommands.completionsFor(text).stream()
+ names = slashCommands.completionsFor(text, agentCommandDescriptors()).stream()
.map(AiSlashCommandRegistry.Descriptor::name)
.toList();
currentToken = text.length() > 1 ? text.substring(1) : "";
@@ -1084,6 +1203,17 @@ class AiPanel {
}
private void executeSlashCommand(String input) {
+ if (acpPreset != null && input.startsWith(AGENT_COMMAND_PREFIX)) {
+ String rest = input.substring(AGENT_COMMAND_PREFIX.length()).strip();
+ if (rest.isEmpty()) {
+ conversation.add(new ConversationEntry(AiRole.SYSTEM, agentCommandListing()));
+ return;
+ }
+ // explicit escape: reaches the agent even when the name is also a panel command
+ submitQuestion(input);
+ return;
+ }
+
Optional<AiSlashCommandRegistry.ParsedCommand> parsed = slashCommands.parse(input);
if (parsed.isPresent() && (thinking.get() || activeCliCommand != null)) {
String name = parsed.get().descriptor().name();
@@ -1096,6 +1226,12 @@ class AiPanel {
}
}
+ if (parsed.isEmpty() && acpPreset != null) {
+ // not a panel command: hand it to the agent verbatim (its own slash commands and skills)
+ submitQuestion(input);
+ return;
+ }
+
AiSlashCommandRegistry.CommandResult result = slashCommands.execute(input, slashCommandContext);
if (result.cliRequest() != null) {
AiCliCommandExecutor.Request request = result.cliRequest();
@@ -1171,6 +1307,20 @@ class AiPanel {
conversation.add(new ConversationEntry(AiRole.SYSTEM, "(command cancelled)"));
}
if (thinking.get()) {
+ CompletableFuture<String> permission = pendingPermission;
+ if (permission != null) {
+ permission.complete(null);
+ permissionPopup.close();
+ }
+ AcpAgentClient agent = acpClient;
+ String session = acpSessionId;
+ if (agent != null && session != null) {
+ // Do not block the TUI event thread: cancel() writes to the agent under the client's writer lock.
+ // prompt() sends the same notification on its own interrupt path; a duplicate cancel is harmless.
+ Thread canceller = new Thread(() -> agent.cancel(session), "tui-acp-cancel");
+ canceller.setDaemon(true);
+ canceller.start();
+ }
stopAgentThread();
conversation.add(new ConversationEntry(AiRole.SYSTEM, "(cancelled)"));
}
@@ -1201,6 +1351,10 @@ class AiPanel {
private void submitQuestion(String question) {
stopAgentThread();
+ if (acpPreset != null) {
+ submitAcpQuestion(question);
+ return;
+ }
if (client == null) {
conversation.add(new ConversationEntry(
AiRole.ERROR,
@@ -1451,6 +1605,372 @@ class AiPanel {
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;
+ acpContextUsed = 0;
+ acpContextSize = 0;
+ 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(), 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;
+ private volatile long contextSize;
+
+ @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;
+ contextSize = size;
+ }
+
+ synchronized void finish(String stopReason) {
+ long elapsed = System.currentTimeMillis() - thinkingStartTime;
+ // the agent reports what its context holds now, not what the turn spent: the turn is the growth, and a
+ // context that shrank because the agent compacted spends nothing
+ long previous = acpContextUsed;
+ acpContextUsed = usedTokens;
+ acpContextSize = contextSize;
+ int tokens = (int) Math.min(Integer.MAX_VALUE, Math.max(0, usedTokens - previous));
+ 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 (usedTokens > 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 TUI's read-only tools are approved silently (allow-always preferred),
+ * everything else, including a TUI tool that changes something, 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 read-only 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 one of the TUI's {@link TuiToolRegistry#READ_ONLY_TOOLS} counts as auto-approvable: 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)}. A TUI tool that changes anything is not in that set and goes to the
+ * user. 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 && TuiToolRegistry.READ_ONLY_TOOLS.contains(tool);
+ }
+
+ private static String firstOptionOfKind(List<JsonObject> options, String kind) {
+ for (JsonObject option : options) {
+ if (kind.equals(option.getString("kind"))) {
+ return option.getString("optionId");
+ }
+ }
+ return null;
+ }
+
private void recordUsage(LlmClient.ChatResponse response, long latencyMs) {
if (client == null || response.usage().totalTokens() == 0) {
return;
@@ -1478,6 +1998,10 @@ class AiPanel {
titleLine = Line.from(
Span.styled(" AI ", Style.EMPTY.bold()),
Span.styled("(" + formatSeconds(titleElapsed) + tokenSuffix + ") ", Style.EMPTY.dim()));
+ } else if (acpPreset != null) {
+ titleLine = Line.from(
+ Span.styled(" AI ", Style.EMPTY.bold()),
+ Span.styled("· " + acpLabel() + describeAcpContextSuffix() + " ", Style.EMPTY.dim()));
} else if (sessionTotalTokens > 0) {
titleLine = Line.from(
Span.styled(" AI ", Style.EMPTY.bold()),
@@ -1506,10 +2030,24 @@ class AiPanel {
if (copyPopup.isVisible()) {
copyPopup.render(frame, inner);
}
+ if (permissionPopup.isVisible()) {
+ permissionPopup.render(frame, inner);
+ }
return;
}
- // Split inner area: conversation (fill) + optional slash hints + separator (1 row) + input (1 row per line)
+ Rect body = inner;
+ if (acpPreset != null && acpSessionId != null && inner.height() >= 8) {
+ List<Rect> top = Layout.vertical()
+ .constraints(Constraint.length(AcpHeaderStrip.ROWS), Constraint.length(1), Constraint.fill())
+ .split(inner);
+ acpHeader.render(frame, top.get(0), acpHeaderModel());
+ frame.renderWidget(Paragraph.from(Line.from(Span.styled("─".repeat(top.get(1).width()), Style.EMPTY.dim()))),
+ top.get(1));
+ body = top.get(2);
+ }
+
+ // Split the body area: conversation (fill) + optional slash hints + separator (1 row) + input (1 row per line)
List<AiSlashCommandRegistry.Descriptor> slashHints = slashCommandHints();
int hintRows = slashHints.isEmpty() ? 0 : slashHints.size();
int inputRows = historySearchActive ? 1 : inputRows();
@@ -1517,12 +2055,12 @@ class AiPanel {
if (hintRows == 0) {
parts = Layout.vertical()
.constraints(Constraint.fill(), Constraint.length(1), Constraint.length(inputRows))
- .split(inner);
+ .split(body);
} else {
parts = Layout.vertical()
.constraints(Constraint.fill(), Constraint.length(hintRows), Constraint.length(1),
Constraint.length(inputRows))
- .split(inner);
+ .split(body);
}
Rect conversationArea = parts.get(0);
Rect separatorArea = parts.get(hintRows == 0 ? 1 : 2);
@@ -1543,13 +2081,16 @@ class AiPanel {
if (copyPopup.isVisible()) {
copyPopup.render(frame, inner);
}
+ if (permissionPopup.isVisible()) {
+ permissionPopup.render(frame, inner);
+ }
}
private List<AiSlashCommandRegistry.Descriptor> slashCommandHints() {
if (thinking.get() || statsView || providerSwitchPopup.isVisible()) {
return List.of();
}
- return slashCommands.completionsFor(inputBuffer.toString());
+ return slashCommands.completionsFor(inputBuffer.toString(), agentCommandDescriptors());
}
private void renderSlashCommandHints(Frame frame, Rect area, List<AiSlashCommandRegistry.Descriptor> hints) {
@@ -1800,6 +2341,10 @@ class AiPanel {
}
void renderFooter(List<Span> spans) {
+ if (permissionPopup.isVisible()) {
+ permissionPopup.renderFooter(spans);
+ return;
+ }
if (providerSwitchPopup.isVisible()) {
providerSwitchPopup.renderFooter(spans);
return;
@@ -2373,6 +2918,10 @@ class AiPanel {
}
private String describeToolMode() {
+ if (acpPreset != null) {
+ return acpLabel() + " reaches the camel-tui tools through the MCP server directly; "
+ + "the tool set only applies to LLM providers";
+ }
if (toolRegistry == null) {
return "no tools available";
}
@@ -2550,6 +3099,9 @@ class AiPanel {
}
String describeContext() {
+ if (acpPreset != null) {
+ return describeAcpContext();
+ }
StringBuilder sb = new StringBuilder();
if (client == null) {
sb.append("Provider: none (").append(initError != null ? initError : "no LLM client").append(")\n");
@@ -2579,7 +3131,71 @@ class AiPanel {
return sb.toString();
}
+ /** The title's context figure, empty until the agent reports one. */
+ private String describeAcpContextSuffix() {
+ long used = acpContextUsed;
+ if (used <= 0) {
+ return "";
+ }
+ long size = acpContextSize;
+ return " (context: " + formatAcpTokens(used) + (size > 0 ? "/" + formatAcpTokens(size) : "") + " tokens)";
+ }
+
+ private static String formatAcpTokens(long tokens) {
+ return LlmClient.formatTokens((int) Math.min(Integer.MAX_VALUE, tokens));
+ }
+
+ /**
+ * The {@code /context} answer while an ACP agent is selected. The agent owns the conversation history and runs its
+ * own tools, so the interesting figures are its session, the MCP server it was given and the preamble the panel
+ * prepends to the first prompt.
+ */
+ private String describeAcpContext() {
+ AiProviderSelector.AcpPreset preset = acpPreset;
+ AcpAgentClient agent = acpClient;
+ StringBuilder sb = new StringBuilder();
+ sb.append("Agent: ").append(acpLabel())
+ .append(" (preset ").append(preset != null ? preset.id() : "acp").append(")\n");
+ if (acpSessionId != null) {
+ sb.append("Session: ").append(acpSessionId).append(" in ").append(acpCwd).append('\n');
+ sb.append("MCP: ").append(acpMcpUrl).append(" (read-only camel-tui tools are approved automatically)\n");
+ } else {
+ sb.append("Session: not started yet; the next prompt opens one and starts the MCP server on demand\n");
+ }
+ sb.append("Agent commands: ").append(agent != null ? agent.availableCommands().size() : 0)
+ .append(" (/agent: lists them)\n");
+ sb.append("Preamble: ~").append(LlmClient.formatTokens(estimateTokens(buildSystemPrompt().length())))
+ .append(" tokens, sent once per session ahead of the first prompt (/prompt shows it); ")
+ .append(acpPreambleSent ? "sent" : "not sent yet").append('\n');
+ if (acpContextUsed > 0) {
+ sb.append("Context: ").append(formatAcpTokens(acpContextUsed));
+ if (acpContextSize > 0) {
+ sb.append(" of ").append(formatAcpTokens(acpContextSize));
+ }
+ sb.append(" tokens in the agent's window\n");
+ } else {
+ sb.append("Context: not reported yet\n");
+ }
+ sb.append("History and tools are managed by the agent: /compact and /tools do not apply here");
+ return sb.toString();
+ }
+
+ /**
+ * Explains that a panel command has no meaning while an agent is selected, pointing at the agent's own command of
+ * the same name when it advertises one.
+ */
+ private String acpNotApplicable(String command, String what) {
+ AcpAgentClient agent = acpClient;
+ boolean offered = agent != null
+ && agent.availableCommands().stream().anyMatch(c -> command.equals(c.name()));
+ return "/" + command + " does not apply here: " + acpLabel() + " " + what + "."
+ + (offered ? " Use /agent:" + command + "." : "");
+ }
+
String compactHistoryNow() {
+ if (acpPreset != null) {
+ return acpNotApplicable("compact", "manages its own conversation history");
+ }
if (messages == null || messages.isEmpty()) {
return "History is empty, nothing to compact";
}
@@ -2593,10 +3209,11 @@ class AiPanel {
/**
* Resends the last question. Any messages from the previous attempt (the question and whatever followed it) are
- * removed from the model history first so the retry starts from a clean turn.
+ * removed from the model history first so the retry starts from a clean turn; an ACP agent keeps its own history,
+ * so the question is simply sent again.
*/
boolean retryLastQuestion() {
- if (client == null || thinking.get()) {
+ if ((client == null && acpPreset == null) || thinking.get()) {
return false;
}
String question = null;
@@ -2609,7 +3226,7 @@ class AiPanel {
if (question == null || question.isBlank()) {
return false;
}
- if (messages != null) {
+ if (acpPreset == null && messages != null) {
for (int i = messages.size() - 1; i >= 0; i--) {
LlmClient.Message m = messages.get(i);
if ("user".equals(m.role()) && m.toolCalls() == null && m.toolResults() == null) {
@@ -2737,6 +3354,10 @@ class AiPanel {
}
void clearConversation() {
+ if (acpPreset != null && thinking.get()) {
+ // an abandoned ACP turn would keep writing into the list we are about to clear
+ interruptBusyOperation();
+ }
conversation.clear();
activityLog.clear();
inputBuffer.setLength(0);
@@ -2751,6 +3372,11 @@ class AiPanel {
if (messages != null) {
messages.clear();
}
+ // the next prompt opens a fresh agent session (context reset), keeping the process
+ acpSessionId = null;
+ acpPreambleSent = false;
+ acpContextUsed = 0;
+ acpContextSize = 0;
}
void setPromptHistoryForTesting(TuiPromptHistory history) {
@@ -2768,6 +3394,43 @@ class AiPanel {
this.testingClientInjected = true;
}
+ void selectProviderForTesting(String providerId) {
+ applyProviderChoice(new AiProviderSwitchPopup.ProviderChoice(providerId, "", "", false));
+ }
+
+ boolean isAcpProviderForTesting() {
+ return acpPreset != null;
+ }
+
+ void setAcpClientFactoryForTesting(AcpClientFactory factory) {
+ this.acpClientFactory = factory;
+ }
+
+ void setMcpUrlSupplierForTestingOrRuntime(Callable<String> supplier) {
+ this.mcpUrlSupplier = supplier;
+ }
+
+ String acpSessionIdForTesting() {
+ return acpSessionId;
+ }
+
+ boolean isPermissionPopupVisibleForTesting() {
+ return permissionPopup.isVisible();
+ }
+
+ private AcpAgentClient spawnAcpAgent(AiProviderSelector.AcpPreset preset, Path cwd) throws IOException {
+ // ProcessBuilder only tries .exe on Windows, so the resolved file (npx.cmd and friends) has to be launched
+ String resolved = AiProviderSelector.resolveExecutable(preset.executable());
+ if (resolved == null) {
+ throw new IOException(preset.installHint());
+ }
+ List<String> command = new ArrayList<>(preset.command());
+ if (!command.isEmpty() && command.get(0).equals(preset.executable())) {
+ command.set(0, resolved);
+ }
+ return AcpAgentClient.spawn(command, cwd, line -> log(LogLevel.ERROR, "ACP", line));
+ }
+
void setSlashCommandContextForTesting(AiSlashCommandContext context) {
this.slashCommandContext = context;
}
@@ -2784,6 +3447,10 @@ class AiPanel {
return sessionTotalTokens;
}
+ long[] acpContextForTesting() {
+ return new long[] { acpContextUsed, acpContextSize };
+ }
+
int messageCountForTesting() {
return messages == null ? 0 : messages.size();
}
@@ -2907,16 +3574,26 @@ class AiPanel {
@Override
public String currentModel() {
+ if (acpPreset != null) {
+ return acpLabel();
+ }
return client != null && client.model() != null ? client.model() : "unknown";
}
@Override
public List<String> availableModels() {
+ if (acpPreset != null) {
+ return List.of();
+ }
return client != null ? client.listModels() : List.of();
}
@Override
public boolean switchModel(String model) {
+ if (acpPreset != null) {
+ throw new IllegalStateException(
+ "The model is configured in the agent (" + acpLabel() + "), not in the TUI.");
+ }
if (client == null) {
return false;
}
@@ -2937,6 +3614,11 @@ class AiPanel {
@Override
public boolean switchToolMode(String mode) {
+ if (acpPreset != null) {
+ throw new IllegalStateException(
+ "The tool set only applies to LLM providers; " + acpLabel()
+ + " reaches the camel-tui tools through the MCP server directly.");
+ }
String normalized = normalizeToolMode(mode);
if (normalized == null) {
return false;
@@ -3009,6 +3691,9 @@ class AiPanel {
@Override
public String systemPrompt() {
+ if (acpPreset != null) {
+ return "Sent to " + acpLabel() + " ahead of the first prompt of each session:\n\n" + buildSystemPrompt();
+ }
return buildSystemPrompt();
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java
index d691a583b3b7..e65678a61ea8 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelector.java
@@ -16,11 +16,15 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
+import dev.tamboui.style.Color;
import org.apache.camel.dsl.jbang.core.commands.LlmClient;
/**
@@ -32,6 +36,138 @@ import org.apache.camel.dsl.jbang.core.commands.LlmClient;
*/
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} and {@code color} identify the agent in the panel's header strip.
+ */
+ record AcpPreset(String id, String label, List<String> command, String executable, String loginHint,
+ String installHint, String glyph, Color color) {
+ }
+
+ 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)),
+ 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)),
+ 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)),
+ 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)),
+ 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)),
+ 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)));
+
+ 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);
+ }
+ 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"));
+ }
+
/**
* Builds the ordered provider choices for the switch popup: the persisted default first, followed by every other
* known provider (regardless of whether an API key is currently detected for it, so it stays available for manual
@@ -46,20 +182,19 @@ final class AiProviderSelector {
settings.getAiModel() != null ? settings.getAiModel() : "",
settings.getAiUrl() != null ? settings.getAiUrl() : "",
true));
- if (!"anthropic".equals(defaultProvider)) {
- choices.add(new AiProviderSwitchPopup.ProviderChoice("anthropic", "", "", false));
- }
- if (!"openai".equals(defaultProvider)) {
- choices.add(new AiProviderSwitchPopup.ProviderChoice("openai", "", "", false));
+ for (String provider : List.of("anthropic", "openai", "gemini", "ollama", "watsonx")) {
+ if (!provider.equals(defaultProvider)) {
+ choices.add(new AiProviderSwitchPopup.ProviderChoice(provider, "", "", false));
+ }
}
- if (!"gemini".equals(defaultProvider)) {
- choices.add(new AiProviderSwitchPopup.ProviderChoice("gemini", "", "", false));
+ for (AcpPreset preset : ACP_PRESETS) {
+ if (!preset.id().equals(defaultProvider)) {
+ choices.add(new AiProviderSwitchPopup.ProviderChoice(preset.id(), "", "", false));
+ }
}
- if (!"ollama".equals(defaultProvider)) {
- choices.add(new AiProviderSwitchPopup.ProviderChoice("ollama", "", "", false));
- }
- if (!"watsonx".equals(defaultProvider)) {
- choices.add(new AiProviderSwitchPopup.ProviderChoice("watsonx", "", "", false));
+ String custom = settings.getAiAcpCommand();
+ if (custom != null && !custom.isBlank() && !ACP_CUSTOM.equals(defaultProvider)) {
+ choices.add(new AiProviderSwitchPopup.ProviderChoice(ACP_CUSTOM, "", "", false));
}
return choices;
}
@@ -71,6 +206,9 @@ final class AiProviderSelector {
* @throws IllegalArgumentException if {@code provider} is set and not a recognized {@link LlmClient.ApiType}
*/
void applyChoice(LlmClient target, String provider, String model, String url) {
+ if (isAcp(provider)) {
+ return;
+ }
if (provider != null && !provider.isBlank() && !"auto".equals(provider)) {
target.withApiType(parseApiType(provider));
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopup.java
index ac19042dedcd..6233d61ae015 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopup.java
@@ -47,6 +47,9 @@ final class AiProviderSwitchPopup {
}
String label() {
+ if (AiProviderSelector.isAcp(provider)) {
+ return AiProviderSelector.acpLabel(provider) + (persistedDefault ? " default" : "");
+ }
String modelLabel = model == null || model.isBlank() ? "auto" : model;
return provider + " " + modelLabel + (persistedDefault ? " default" : "");
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
index 833629380725..118482badba8 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
@@ -242,6 +242,38 @@ final class AiSlashCommandRegistry {
return matchDescriptors(body);
}
+ /**
+ * Like {@link #completionsFor(String)} but also offers {@code extra} descriptors, for example the slash commands an
+ * ACP agent advertised. Registry commands win: an extra descriptor whose name is a registry command or alias is
+ * dropped, and a complete extra command followed by a space hides the hints like a registry command does.
+ */
+ List<Descriptor> completionsFor(String input, List<Descriptor> extra) {
+ List<Descriptor> own = completionsFor(input);
+ if (extra.isEmpty() || input == null || !input.startsWith("/")) {
+ return own;
+ }
+ String body = input.substring(1);
+ int separator = firstWhitespace(body);
+ if (separator >= 0) {
+ String command = body.substring(0, separator);
+ if (!body.substring(separator).isBlank() || lookup(command).isPresent()
+ || extra.stream().anyMatch(d -> d.name().equalsIgnoreCase(command)
+ || d.aliases().stream().anyMatch(a -> a.equalsIgnoreCase(command)))) {
+ return own;
+ }
+ body = command;
+ }
+ String needle = body.strip().toLowerCase(Locale.ROOT);
+ List<Descriptor> merged = new ArrayList<>(own);
+ for (Descriptor descriptor : extra) {
+ if (lookup(descriptor.name()).isEmpty() && matchesPrefix(descriptor, needle)
+ && merged.stream().noneMatch(m -> m.name().equals(descriptor.name()))) {
+ merged.add(descriptor);
+ }
+ }
+ return merged;
+ }
+
private List<Descriptor> matchDescriptors(String prefix) {
String needle = prefix.strip().toLowerCase(Locale.ROOT);
return descriptors.stream().filter(descriptor -> matchesPrefix(descriptor, needle)).toList();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 244a6c46cd89..7003642b7ac8 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -141,7 +141,8 @@ public class CamelMonitor extends CamelCommand {
@CommandLine.Option(names = { "--mcp-port" },
description = "MCP server port (default: ${DEFAULT-VALUE})",
defaultValue = "8123")
- int mcpPort = 8123;
+ // written by ensureMcpServer() on the AI panel's agent thread, read on the event and main threads
+ volatile int mcpPort = 8123;
@CommandLine.Option(names = { "--web" },
description = "Enable browser-accessible terminal (WebSocket) server")
@@ -170,7 +171,7 @@ public class CamelMonitor extends CamelCommand {
private final WaveTextState notificationWaveState = new WaveTextState();
private String lastWaveNotification;
private boolean mcpInjectedKey;
- private TuiMcpServer mcpServer;
+ private volatile TuiMcpServer mcpServer;
private Path mcpJsonFile;
private TuiWebServer webServer;
private McpFacade mcpFacade;
@@ -967,9 +968,7 @@ public class CamelMonitor extends CamelCommand {
mcpServer = new TuiMcpServer(mcpPort, mcpFacade);
try {
mcpServer.start();
- mcpFacade.setMcpActivityLog(mcpServer::getActivityLog, mcpServer::getToolCallCount);
- actionsPopup.setMcpEnabled(true, mcpPort, mcpServer::getConnectedClient,
- mcpServer::getActivityLog, mcpServer::getToolCallCount);
+ wireMcpServer(mcpServer);
mcpJsonFile = writeMcpJson(mcpPort);
} catch (BindException e) {
System.err.println("MCP server failed to start: port " + mcpPort + " is already in use.");
@@ -979,6 +978,7 @@ public class CamelMonitor extends CamelCommand {
}
}
aiPanel.setMcpInfo(mcp, mcpPort);
+ aiPanel.setMcpUrlSupplierForTestingOrRuntime(this::ensureMcpServer);
}
/**
@@ -2962,6 +2962,31 @@ public class CamelMonitor extends CamelCommand {
// ---- 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 {
+ TuiMcpServer server = mcpServer;
+ if (server == null) {
+ server = new TuiMcpServer(mcp ? mcpPort : 0, mcpFacade);
+ server.start();
+ mcpServer = server;
+ mcpPort = server.getPort();
+ wireMcpServer(server);
+ }
+ return "http://127.0.0.1:" + server.getPort() + "/mcp";
+ }
+
+ /** Hands a started MCP server to the facade, the actions popup and the AI panel. */
+ private void wireMcpServer(TuiMcpServer server) {
+ mcpFacade.setMcpActivityLog(server::getActivityLog, server::getToolCallCount);
+ actionsPopup.setMcpEnabled(true, mcpPort, server::getConnectedClient,
+ server::getActivityLog, server::getToolCallCount);
+ aiPanel.setMcpInfo(true, mcpPort);
+ }
+
private static Path writeMcpJson(int port) {
Path path = Path.of(".mcp.json");
if (Files.exists(path)) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
index aea9a61533c3..bdd8130afcd0 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
@@ -63,7 +63,10 @@ class SettingsPopup {
private static final int ROW_AI_URL = 15;
private static final int ROW_AI_TOOLS = 16;
private static final int ROW_AI_PROMPT_HISTORY = 17;
- private static final int ROW_COUNT = 18;
+ private static final int ROW_AI_ACP_COMMAND = 18;
+ static final int ROW_COUNT = 19;
+ /** Separator lines drawn between the row groups, after rows 2, 6, 9 and 12. */
+ static final int DIVIDERS = 4;
private static final String[] LOG_PIN_OPTIONS = { "off", "25", "50", "75" };
private static final String[] RATE_PER_OPTIONS = { "seconds", "minutes" };
@@ -78,12 +81,20 @@ class SettingsPopup {
for (LlmClient.ApiType apiType : LlmClient.ApiType.values()) {
providers.add(apiType.name());
}
+ for (AiProviderSelector.AcpPreset preset : AiProviderSelector.acpPresets()) {
+ providers.add(preset.id());
+ }
+ providers.add(AiProviderSelector.ACP_CUSTOM);
providers.add("auto");
return providers;
}
private boolean visible;
private int selectedRow;
+ /** First content line drawn, so the selected row stays inside a popup shorter than its 24 lines. */
+ private int scrollTop;
+ private int clipTop;
+ private int clipBottom;
private TuiSettings settings;
private int themeIndex;
@@ -104,6 +115,7 @@ class SettingsPopup {
private TextInputState aiModelInput;
private TextInputState aiUrlInput;
private TextInputState aiPromptHistoryInput;
+ private TextInputState aiAcpCommandInput;
private List<String> tabNames = new ArrayList<>();
private List<TabRegistry.TabEntry> tabEntries;
@@ -190,7 +202,9 @@ class SettingsPopup {
aiToolsIndex = Math.max(0, toolsIdx);
aiPromptHistoryInput = new TextInputState(
settings.getAiPromptHistory() != null ? settings.getAiPromptHistory() : "");
+ aiAcpCommandInput = new TextInputState(settings.getAiAcpCommand() != null ? settings.getAiAcpCommand() : "");
selectedRow = ROW_THEME;
+ scrollTop = 0;
visible = true;
}
@@ -342,6 +356,10 @@ class SettingsPopup {
handleTextInput(ke, aiPromptHistoryInput);
return true;
}
+ if (selectedRow == ROW_AI_ACP_COMMAND) {
+ handleTextInput(ke, aiAcpCommandInput);
+ return true;
+ }
return true;
}
@@ -386,6 +404,7 @@ class SettingsPopup {
String aiToolsValue = AI_TOOLS_OPTIONS[aiToolsIndex];
settings.setAiTools(AiPanel.TOOL_MODE_AUTO.equals(aiToolsValue) ? null : aiToolsValue);
settings.setAiPromptHistory(stripControlChars(aiPromptHistoryInput.text().trim()));
+ settings.setAiAcpCommand(stripControlChars(aiAcpCommandInput.text().trim()));
settings.save();
if (Theme.mode().equals(selectedThemeId)) {
// Already active via live preview (or unchanged): just persist and clear the preview marker.
@@ -399,9 +418,8 @@ class SettingsPopup {
}
void render(Frame frame, Rect area) {
- int dividers = 4;
int popupW = Math.min(70, area.width() - 4);
- int popupH = 2 + ROW_COUNT + dividers;
+ int popupH = 2 + ROW_COUNT + DIVIDERS;
int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
int y = area.top() + 2;
Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height() - 2));
@@ -414,11 +432,23 @@ class SettingsPopup {
.build();
frame.renderWidget(block, popup);
+ int visibleLines = popup.height() - 2;
+ int contentLines = ROW_COUNT + DIVIDERS;
+ int selectedLine = lineOf(selectedRow);
+ if (selectedLine < scrollTop) {
+ scrollTop = selectedLine;
+ } else if (selectedLine >= scrollTop + visibleLines) {
+ scrollTop = selectedLine - visibleLines + 1;
+ }
+ scrollTop = Math.max(0, Math.min(scrollTop, Math.max(0, contentLines - visibleLines)));
+ clipTop = popup.top() + 1;
+ clipBottom = popup.top() + popup.height() - 2;
+
int innerX = popup.left() + 2;
int innerW = popup.width() - 4;
int labelW = 24;
int fieldW = innerW - labelW;
- int rowY = popup.top() + 1;
+ int rowY = popup.top() + 1 - scrollTop;
// --- Appearance ---
renderLabel(frame, innerX, rowY, labelW, "Theme:", selectedRow == ROW_THEME);
@@ -519,6 +549,11 @@ class SettingsPopup {
renderLabel(frame, innerX, rowY, labelW, "AI History:", selectedRow == ROW_AI_PROMPT_HISTORY);
renderTextInput(frame, innerX + labelW, rowY, fieldW, aiPromptHistoryInput,
selectedRow == ROW_AI_PROMPT_HISTORY, "(100)");
+ rowY++;
+
+ renderLabel(frame, innerX, rowY, labelW, "ACP Command:", selectedRow == ROW_AI_ACP_COMMAND);
+ renderTextInput(frame, innerX + labelW, rowY, fieldW, aiAcpCommandInput,
+ selectedRow == ROW_AI_ACP_COMMAND, "(none)");
}
void renderFooter(List<Span> spans) {
@@ -597,15 +632,34 @@ class SettingsPopup {
return sb.toString();
}
+ /** The content line a row is drawn on, counting the dividers that follow rows 2, 6, 9 and 12. */
+ static int lineOf(int row) {
+ return row + (row > 2 ? 1 : 0) + (row > 6 ? 1 : 0) + (row > 9 ? 1 : 0) + (row > 12 ? 1 : 0);
+ }
+
+ /** True when a scrolled line falls outside the popup's inner area and must not be drawn. */
+ private boolean clipped(int y) {
+ return y < clipTop || y > clipBottom;
+ }
+
private void renderDivider(Frame frame, int x, int y, int w) {
+ if (clipped(y)) {
+ return;
+ }
frame.renderWidget(Paragraph.from(Line.from(Span.styled("─".repeat(w), Style.EMPTY.dim()))), new Rect(x, y, w, 1));
}
private void renderLabel(Frame frame, int x, int y, int w, String label, boolean selected) {
+ if (clipped(y)) {
+ return;
+ }
FormHelper.renderLabel(frame, x, y, w, label, selected);
}
private void renderValue(Frame frame, int x, int y, int w, String text, boolean selected) {
+ if (clipped(y)) {
+ return;
+ }
Style style = selected ? Style.EMPTY.bold() : Style.EMPTY;
frame.renderWidget(Paragraph.from(Line.from(Span.styled("[" + text + "]", style))), new Rect(x, y, w, 1));
}
@@ -617,6 +671,9 @@ class SettingsPopup {
private void renderTextInput(
Frame frame, int x, int y, int w, TextInputState input, boolean active,
String placeholder) {
+ if (clipped(y)) {
+ return;
+ }
FormHelper.renderTextField(frame, new Rect(x, y, w, 1), input, active, placeholder);
}
@@ -670,6 +727,10 @@ class SettingsPopup {
};
}
+ List<String> aiProviderOptionsForTesting() {
+ return AI_PROVIDERS;
+ }
+
String aiModelText() {
return aiModelInput != null ? aiModelInput.text() : "";
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
index d82da6b06404..c222298b545c 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
@@ -44,6 +44,8 @@ final class TuiIcons {
static final String HEALTH_WARN = "⚠";
static final String STOPPED = "✖";
static final String CROSS = "✗";
+ static final String GEAR = "⚙";
+ static final String CHECK = "✓";
// ---- Files & folders ----
static final String FOLDER = "📁";
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index a49bb1aff14f..2c39d7c18768 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -91,7 +91,7 @@ class TuiMcpServer {
return t;
}));
server.start();
- log(LogLevel.INFO, "Server started on port " + port);
+ log(LogLevel.INFO, "Server started on port " + getPort());
}
void stop() {
@@ -103,6 +103,13 @@ class TuiMcpServer {
}
}
+ /**
+ * The port the server is bound to. Differs from the constructor argument when {@code 0} (ephemeral) was requested.
+ */
+ int getPort() {
+ return server != null ? server.getAddress().getPort() : port;
+ }
+
synchronized List<LogEntry> getActivityLog() {
return new ArrayList<>(activityLog);
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java
index c6ababc82217..a790019dfdff 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettings.java
@@ -40,6 +40,7 @@ final class TuiSettings {
static final String PROP_AI_MODEL = "camel.tui.ai.model";
static final String PROP_AI_URL = "camel.tui.ai.url";
static final String PROP_AI_TOOLS = "camel.tui.ai.tools";
+ static final String PROP_AI_ACP_COMMAND = "camel.tui.ai.acp.command";
static final String PROP_PROXY_HOST = "camel.tui.proxyHost";
static final String PROP_PROXY_PORT = "camel.tui.proxyPort";
static final String PROP_SHELL_HISTORY = "camel.tui.shell.history";
@@ -61,6 +62,7 @@ final class TuiSettings {
private String aiModel;
private String aiUrl;
private String aiTools;
+ private String aiAcpCommand;
private String shellHistory;
private String aiPromptHistory;
private String confirmActions;
@@ -168,6 +170,14 @@ final class TuiSettings {
this.aiTools = aiTools;
}
+ String getAiAcpCommand() {
+ return aiAcpCommand;
+ }
+
+ void setAiAcpCommand(String aiAcpCommand) {
+ this.aiAcpCommand = aiAcpCommand;
+ }
+
String getShellHistory() {
return shellHistory;
}
@@ -262,6 +272,7 @@ final class TuiSettings {
settings.aiModel = trimToNull(TuiUserConfig.read(PROP_AI_MODEL));
settings.aiUrl = trimToNull(TuiUserConfig.read(PROP_AI_URL));
settings.aiTools = trimToNull(TuiUserConfig.read(PROP_AI_TOOLS));
+ settings.aiAcpCommand = trimToNull(TuiUserConfig.read(PROP_AI_ACP_COMMAND));
settings.shellHistory = trimToNull(TuiUserConfig.read(PROP_SHELL_HISTORY));
settings.aiPromptHistory = trimToNull(TuiUserConfig.read(PROP_AI_PROMPT_HISTORY));
settings.confirmActions = trimToNull(TuiUserConfig.read(PROP_CONFIRM_ACTIONS));
@@ -293,6 +304,7 @@ final class TuiSettings {
TuiUserConfig.write(PROP_AI_MODEL, aiModel);
TuiUserConfig.write(PROP_AI_URL, aiUrl);
TuiUserConfig.write(PROP_AI_TOOLS, aiTools);
+ TuiUserConfig.write(PROP_AI_ACP_COMMAND, aiAcpCommand);
TuiUserConfig.write(PROP_SHELL_HISTORY, shellHistory);
TuiUserConfig.write(PROP_AI_PROMPT_HISTORY, aiPromptHistory);
TuiUserConfig.write(PROP_CONFIRM_ACTIONS, confirmActions);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 4e874cfd13da..64ba553d60eb 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -98,6 +98,18 @@ class TuiToolRegistry {
"tui_get_readme", "tui_navigate", "tui_set_log_level", "tui_filter", "tui_get_status",
"tui_infra");
+ /**
+ * Tools that only return information and never change the TUI, the integration or its data. The ACP permission
+ * handler approves calls to these without asking; anything else, including tui_control, tui_send_message and
+ * tui_execute_sql, is put in front of the user.
+ */
+ static final Set<String> READ_ONLY_TOOLS = Set.of(
+ "tui_catalog_doc", "tui_get_ai_log", "tui_get_diagram", "tui_get_errors", "tui_get_events",
+ "tui_get_files", "tui_get_history", "tui_get_log", "tui_get_mcp_log", "tui_get_options",
+ "tui_get_processor_detail", "tui_get_readme", "tui_get_screen", "tui_get_spans", "tui_get_state",
+ "tui_get_status", "tui_get_table", "tui_get_themes", "tui_get_topology", "tui_list_examples",
+ "tui_locate", "tui_validate_source", "tui_wait_for_idle");
+
/**
* Returns all tool definitions. The result is cached since it is immutable.
*/
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClientTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClientTest.java
new file mode 100644
index 000000000000..bc77f198f925
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClientTest.java
@@ -0,0 +1,553 @@
+/*
+ * 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.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AcpAgentClientTest {
+
+ private static final Duration T = Duration.ofSeconds(5);
+
+ private FakeAcpAgent agent;
+ private AcpAgentClient client;
+ private final List<String> diagnostics = new CopyOnWriteArrayList<>();
+
+ @BeforeEach
+ void setUp() throws Exception {
+ agent = new FakeAcpAgent();
+ client = new AcpAgentClient(agent.clientInput(), agent.clientOutput(), diagnostics::add);
+ client.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ client.close();
+ agent.close();
+ }
+
+ @Test
+ void initializeSendsVersion1WithNoFsOrTerminalCapability() {
+ AcpAgentClient.AgentInfo info = client.initialize(T);
+ assertEquals(1, info.protocolVersion());
+ assertTrue(info.httpMcp());
+ assertEquals("fake-agent", info.name());
+ assertEquals("1.2.3", info.version());
+ JsonObject sent = agent.awaitReceived("initialize", T);
+ JsonObject params = sent.getJsonObject("params");
+ assertEquals(1, params.getInteger("protocolVersion"));
+ JsonObject fs = params.getJsonObject("clientCapabilities").getJsonObject("fs");
+ assertFalse(fs.getBoolean("readTextFile"));
+ assertFalse(fs.getBoolean("writeTextFile"));
+ assertFalse(params.getJsonObject("clientCapabilities").getBoolean("terminal"));
+ assertEquals("camel-tui", params.getJsonObject("clientInfo").getString("name"));
+ }
+
+ @Test
+ void unknownIncomingRequestIsAnsweredWithMethodNotFound() {
+ JsonObject params = new JsonObject();
+ params.put("path", "/etc/passwd");
+ JsonObject response = agent.sendRequest("fs/read_text_file", params);
+ assertNotNull(response.getJsonObject("error"));
+ assertEquals(AcpAgentClient.METHOD_NOT_FOUND, response.getJsonObject("error").getInteger("code"));
+ }
+
+ @Test
+ void malformedLineIsSkippedAndReported() {
+ agent.sendRaw("this is not json");
+ agent.sendRaw("[1,2,3]");
+ AcpAgentClient.AgentInfo info = client.initialize(T);
+ assertEquals(1, info.protocolVersion());
+ assertTrue(diagnostics.stream()
+ .anyMatch(d -> d.contains("not a JSON-RPC object") && d.contains("this is not json")), diagnostics.toString());
+ assertTrue(diagnostics.stream()
+ .anyMatch(d -> d.contains("not a JSON-RPC object") && d.contains("[1,2,3]")), diagnostics.toString());
+ }
+
+ @Test
+ void requestAfterStreamClosedFailsFastWithConnectionError() {
+ agent.close();
+ await().atMost(5, TimeUnit.SECONDS).until(() -> !client.isAlive());
+ AcpAgentClient.AcpException e = assertThrows(AcpAgentClient.AcpException.class, () -> client.initialize(T));
+ assertEquals(AcpAgentClient.CONNECTION, e.code());
+ }
+
+ @Test
+ void readerSurvivesARejectedIncomingRequestAfterClose() {
+ client.close();
+ agent.sendRaw("{\"jsonrpc\":\"2.0\",\"id\":77,\"method\":\"session/request_permission\",\"params\":{}}");
+ await().atMost(5, TimeUnit.SECONDS)
+ .until(() -> diagnostics.stream().anyMatch(d -> d.contains("Cannot handle message")));
+ }
+
+ @Test
+ void streamClosedFailsPendingRequests() {
+ agent.onRequest("session/prompt", params -> {
+ try {
+ new CountDownLatch(1).await(); // an agent that never answers: blocks until the pipe is closed
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return new JsonObject();
+ });
+ client.initialize(T);
+ String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ Thread closer = new Thread(() -> {
+ agent.awaitReceived("session/prompt", T);
+ agent.close();
+ });
+ closer.setDaemon(true);
+ closer.start();
+ AcpAgentClient.AcpException e = assertThrows(AcpAgentClient.AcpException.class,
+ () -> client.prompt(session, "hi", new NoopListener()));
+ assertEquals(AcpAgentClient.CONNECTION, e.code());
+ assertTrue(e.getMessage().contains("agent exited"), e.getMessage());
+ assertFalse(client.isAlive());
+ }
+
+ @Test
+ void newSessionPassesCwdAndTheTuiMcpServer() {
+ client.initialize(T);
+ String id = client.newSession(Path.of("/tmp/proj"), "http://127.0.0.1:8123/mcp", T);
+ assertTrue(id.startsWith("sess-"));
+ JsonObject params = agent.awaitReceived("session/new", T).getJsonObject("params");
+ assertEquals("/tmp/proj", params.getString("cwd"));
+ JsonObject server = (JsonObject) params.getJsonArray("mcpServers").get(0);
+ assertEquals("http", server.getString("type"));
+ assertEquals("camel-tui", server.getString("name"));
+ assertEquals("http://127.0.0.1:8123/mcp", server.getString("url"));
+ }
+
+ @Test
+ void authRequiredSurfacesAsAcpExceptionWithCode() {
+ client.initialize(T);
+ agent.failRequest("session/new", AcpAgentClient.AUTH_REQUIRED, "Authentication required");
+ AcpAgentClient.AcpException e = assertThrows(AcpAgentClient.AcpException.class,
+ () -> client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T));
+ assertEquals(AcpAgentClient.AUTH_REQUIRED, e.code());
+ client.authenticate("agent-login", T);
+ assertEquals("agent-login", agent.awaitReceived("authenticate", T).getJsonObject("params").getString("methodId"));
+ }
+
+ @Test
+ void promptStreamsUpdatesInOrderAndReturnsStopReason() {
+ agent.onRequest("session/prompt", params -> {
+ agent.sendNotification("session/update", update(params.getString("sessionId"), chunk("Hel")));
+ agent.sendNotification("session/update", update(params.getString("sessionId"), chunk("lo")));
+ JsonObject call = new JsonObject();
+ call.put("sessionUpdate", "tool_call");
+ call.put("toolCallId", "t1");
+ call.put("title", "Read file");
+ call.put("kind", "read");
+ agent.sendNotification("session/update", update(params.getString("sessionId"), call));
+ JsonObject done = new JsonObject();
+ done.put("sessionUpdate", "tool_call_update");
+ done.put("toolCallId", "t1");
+ done.put("status", "completed");
+ agent.sendNotification("session/update", update(params.getString("sessionId"), done));
+ JsonObject usage = new JsonObject();
+ usage.put("sessionUpdate", "usage_update");
+ usage.put("used", 1234);
+ usage.put("size", 200000);
+ agent.sendNotification("session/update", update(params.getString("sessionId"), usage));
+ JsonObject unknown = new JsonObject();
+ unknown.put("sessionUpdate", "agent_thought_chunk");
+ agent.sendNotification("session/update", update(params.getString("sessionId"), unknown));
+ JsonObject r = new JsonObject();
+ r.put("stopReason", "end_turn");
+ return r;
+ });
+ client.initialize(T);
+ String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ RecordingListener listener = new RecordingListener();
+ assertEquals("end_turn", client.prompt(session, "hi", listener));
+ assertEquals(List.of("text:Hel", "text:lo", "call:t1:Read file:read", "update:t1:completed", "usage:1234"),
+ listener.events);
+ JsonObject prompt = agent.awaitReceived("session/prompt", T).getJsonObject("params");
+ assertEquals(session, prompt.getString("sessionId"));
+ assertEquals("hi", ((JsonObject) prompt.getJsonArray("prompt").get(0)).getString("text"));
+ }
+
+ @Test
+ void updatesForAnotherSessionAreIgnored() {
+ agent.onRequest("session/prompt", params -> {
+ agent.sendNotification("session/update", update("another-session", chunk("stale")));
+ agent.sendNotification("session/update", update(params.getString("sessionId"), chunk("mine")));
+ JsonObject r = new JsonObject();
+ r.put("stopReason", "end_turn");
+ return r;
+ });
+ client.initialize(T);
+ String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ RecordingListener listener = new RecordingListener();
+ assertEquals("end_turn", client.prompt(session, "hi", listener));
+ assertEquals(List.of("text:mine"), listener.events);
+ }
+
+ @Test
+ void permissionRequestIsAnsweredWithTheHandlerChoice() {
+ client.setPermissionHandler((toolCall, options) -> "opt-allow");
+ agent.onRequest("session/prompt", params -> {
+ JsonObject answer = agent.sendRequest("session/request_permission", permission("Write file", "edit"));
+ JsonObject r = new JsonObject();
+ r.put("stopReason", answer.getJsonObject("result").getJsonObject("outcome").getString("optionId"));
+ return r;
+ });
+ client.initialize(T);
+ String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ assertEquals("opt-allow", client.prompt(session, "hi", new NoopListener()));
+ }
+
+ @Test
+ void permissionHandlerReturningNullAnswersCancelled() {
+ client.setPermissionHandler((toolCall, options) -> null);
+ agent.onRequest("session/prompt", params -> {
+ JsonObject answer = agent.sendRequest("session/request_permission", permission("Write file", "edit"));
+ JsonObject r = new JsonObject();
+ r.put("stopReason", answer.getJsonObject("result").getJsonObject("outcome").getString("outcome"));
+ return r;
+ });
+ client.initialize(T);
+ String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ assertEquals("cancelled", client.prompt(session, "hi", new NoopListener()));
+ }
+
+ @Test
+ void cancelSendsNotificationAndPromptReturnsCancelled() {
+ agent.onRequest("session/prompt", params -> {
+ agent.awaitReceived("session/cancel", T);
+ JsonObject r = new JsonObject();
+ r.put("stopReason", "cancelled");
+ return r;
+ });
+ client.initialize(T);
+ String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ Thread canceller = new Thread(() -> {
+ agent.awaitReceived("session/prompt", T);
+ client.cancel(session);
+ });
+ canceller.setDaemon(true);
+ canceller.start();
+ assertEquals("cancelled", client.prompt(session, "hi", new NoopListener()));
+ assertEquals(session, agent.awaitReceived("session/cancel", T).getJsonObject("params").getString("sessionId"));
+ }
+
+ @Test
+ void interruptedPromptSendsCancelAndReturnsCancelled() throws Exception {
+ agent.onRequest("session/prompt", params -> {
+ agent.awaitReceived("session/cancel", Duration.ofSeconds(30));
+ JsonObject r = new JsonObject();
+ r.put("stopReason", "cancelled");
+ return r;
+ });
+ client.initialize(T);
+ String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ String[] result = new String[1];
+ Thread caller = new Thread(() -> result[0] = client.prompt(session, "hi", new NoopListener()));
+ caller.start();
+ agent.awaitReceived("session/prompt", T);
+ caller.interrupt();
+ caller.join(5_000);
+ assertEquals("cancelled", result[0]);
+ agent.awaitReceived("session/cancel", T);
+ assertEquals(1, agent.receivedCount("session/cancel"));
+ }
+
+ @Test
+ @Timeout(30)
+ void nextPromptWaitsForTheCancelledTurnToFinish() throws Exception {
+ CountDownLatch lateSent = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicInteger calls = new AtomicInteger();
+ agent.onRequest("session/prompt", params -> {
+ String sessionId = params.getString("sessionId");
+ JsonObject r = new JsonObject();
+ if (calls.incrementAndGet() == 1) {
+ agent.awaitReceived("session/cancel", Duration.ofSeconds(30));
+ agent.sendNotification("session/update", update(sessionId, chunk("late-old")));
+ lateSent.countDown();
+ try {
+ release.await(30, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ r.put("stopReason", "cancelled");
+ return r;
+ }
+ agent.sendNotification("session/update", update(sessionId, chunk("new")));
+ r.put("stopReason", "end_turn");
+ return r;
+ });
+ client.initialize(T);
+ String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ // the first turn runs on the test thread, like cancelSendsNotificationAndPromptReturnsCancelled: a piped
+ // stream breaks as soon as the thread that last wrote to it dies, so the writer has to outlive the test
+ Thread caller = Thread.currentThread();
+ Thread esc = new Thread(() -> {
+ agent.awaitReceived("session/prompt", T);
+ caller.interrupt();
+ });
+ esc.setDaemon(true);
+ esc.start();
+ assertEquals("cancelled", client.prompt(session, "one", new NoopListener()));
+ boolean interrupted = Thread.interrupted();
+ assertTrue(interrupted, "prompt leaves the caller interrupted");
+ RecordingListener second = new RecordingListener();
+ String[] result = new String[1];
+ Thread next = new Thread(() -> result[0] = client.prompt(session, "two", second));
+ next.start();
+ assertTrue(lateSent.await(5, TimeUnit.SECONDS));
+ assertEquals(1, agent.receivedCount("session/prompt"), "the second prompt waits for the cancelled turn");
+ release.countDown();
+ next.join(10_000);
+ assertEquals("end_turn", result[0]);
+ assertEquals(List.of("text:new"), second.events);
+ assertEquals(2, agent.receivedCount("session/prompt"));
+ }
+
+ @Test
+ @Timeout(30)
+ void newSessionForgetsTheCancelledTurn() {
+ CountDownLatch never = new CountDownLatch(1);
+ AtomicInteger calls = new AtomicInteger();
+ agent.onRequest("session/prompt", params -> {
+ JsonObject r = new JsonObject();
+ if (calls.incrementAndGet() == 1) {
+ try {
+ never.await(30, TimeUnit.SECONDS); // the cancelled turn is never answered
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ r.put("stopReason", "cancelled");
+ return r;
+ }
+ r.put("stopReason", "end_turn");
+ return r;
+ });
+ client.initialize(T);
+ String first = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ Thread caller = Thread.currentThread();
+ Thread esc = new Thread(() -> {
+ agent.awaitReceived("session/prompt", T);
+ caller.interrupt();
+ });
+ esc.setDaemon(true);
+ esc.start();
+ assertEquals("cancelled", client.prompt(first, "one", new NoopListener()));
+ Thread.interrupted();
+ String second = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ assertEquals("end_turn", client.prompt(second, "two", new NoopListener()));
+ assertFalse(diagnostics.stream().anyMatch(d -> d.contains("cancelled turn")), diagnostics.toString());
+ }
+
+ @Test
+ @EnabledOnOs({ OS.LINUX, OS.MAC })
+ void spawnedProcessExitReportsCodeAndStderr() throws Exception {
+ AcpAgentClient spawned = AcpAgentClient.spawn(
+ List.of("sh", "-c", "echo boom >&2; exit 3"), Path.of("."), diagnostics::add);
+ try {
+ AcpAgentClient.AcpException e = assertThrows(AcpAgentClient.AcpException.class,
+ () -> spawned.initialize(T));
+ assertTrue(e.getMessage().contains("code 3"), e.getMessage());
+ assertTrue(e.getMessage().contains("boom"), e.getMessage());
+ assertFalse(spawned.isAlive());
+ } finally {
+ spawned.close();
+ }
+ }
+
+ @Test
+ void availableCommandsUpdateIsStoredEvenWithoutAPromptInFlight() {
+ agent.onRequest("session/new", params -> {
+ JsonObject review = new JsonObject();
+ review.put("name", "review");
+ review.put("description", "Review the current changes");
+ JsonObject input = new JsonObject();
+ input.put("hint", "focus area");
+ review.put("input", input);
+ JsonObject commit = new JsonObject();
+ commit.put("name", "commit");
+ commit.put("description", "Commit staged changes");
+ JsonArray commands = new JsonArray();
+ commands.add(review);
+ commands.add(commit);
+ JsonObject update = new JsonObject();
+ update.put("sessionUpdate", "available_commands_update");
+ update.put("availableCommands", commands);
+ JsonObject params2 = new JsonObject();
+ params2.put("sessionId", "sess-1");
+ params2.put("update", update);
+ agent.sendNotification("session/update", params2);
+ JsonObject r = new JsonObject();
+ r.put("sessionId", "sess-1");
+ return r;
+ });
+ client.initialize(T);
+ client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ await().atMost(5, TimeUnit.SECONDS).until(() -> client.availableCommands().size() == 2);
+ AcpAgentClient.AgentCommand review = client.availableCommands().get(0);
+ assertEquals("review", review.name());
+ assertEquals("Review the current changes", review.description());
+ assertEquals("focus area", review.hint());
+ assertNull(client.availableCommands().get(1).hint());
+ }
+
+ @Test
+ void commandsFromAnotherSessionAreIgnored() {
+ client.initialize(T);
+ String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ agent.sendNotification("session/update", update("sess-OLD", commandsUpdate("review")));
+ agent.sendNotification("session/update", update(session, commandsUpdate("commit")));
+ await().atMost(5, TimeUnit.SECONDS).until(() -> client.availableCommands().size() == 1);
+ assertEquals("commit", client.availableCommands().get(0).name());
+ }
+
+ @Test
+ void commandsSentWhileTheSessionIsCreatedReplaceTheOldOnes() {
+ AtomicInteger sessions = new AtomicInteger();
+ agent.onRequest("session/new", params -> {
+ String id = "sess-" + sessions.incrementAndGet();
+ agent.sendNotification("session/update", update(id, commandsUpdate("cmd-" + id)));
+ JsonObject r = new JsonObject();
+ r.put("sessionId", id);
+ return r;
+ });
+ client.initialize(T);
+ client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ assertEquals("cmd-sess-1", client.availableCommands().get(0).name());
+ client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T);
+ assertEquals(1, client.availableCommands().size());
+ assertEquals("cmd-sess-2", client.availableCommands().get(0).name());
+ }
+
+ private static JsonObject update(String sessionId, JsonObject update) {
+ JsonObject params = new JsonObject();
+ params.put("sessionId", sessionId);
+ params.put("update", update);
+ return params;
+ }
+
+ /** An available_commands_update carrying a single command with that name. */
+ private static JsonObject commandsUpdate(String name) {
+ JsonObject command = new JsonObject();
+ command.put("name", name);
+ JsonArray commands = new JsonArray();
+ commands.add(command);
+ JsonObject update = new JsonObject();
+ update.put("sessionUpdate", "available_commands_update");
+ update.put("availableCommands", commands);
+ return update;
+ }
+
+ private static JsonObject chunk(String text) {
+ JsonObject content = new JsonObject();
+ content.put("type", "text");
+ content.put("text", text);
+ JsonObject update = new JsonObject();
+ update.put("sessionUpdate", "agent_message_chunk");
+ update.put("content", content);
+ return update;
+ }
+
+ static JsonObject permission(String title, String kind) {
+ JsonObject toolCall = new JsonObject();
+ toolCall.put("toolCallId", "t9");
+ toolCall.put("title", title);
+ toolCall.put("kind", kind);
+ JsonArray options = new JsonArray();
+ options.add(option("opt-allow", "Allow", "allow_once"));
+ options.add(option("opt-always", "Always allow", "allow_always"));
+ options.add(option("opt-reject", "Reject", "reject_once"));
+ JsonObject params = new JsonObject();
+ params.put("sessionId", "sess");
+ params.put("toolCall", toolCall);
+ params.put("options", options);
+ return params;
+ }
+
+ static JsonObject option(String id, String name, String kind) {
+ JsonObject o = new JsonObject();
+ o.put("optionId", id);
+ o.put("name", name);
+ o.put("kind", kind);
+ return o;
+ }
+
+ static final class RecordingListener implements AcpAgentClient.Listener {
+ final List<String> events = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void onTextChunk(String text) {
+ events.add("text:" + text);
+ }
+
+ @Override
+ public void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) {
+ events.add("call:" + toolCallId + ":" + title + ":" + kind);
+ }
+
+ @Override
+ public void onToolCallUpdate(String toolCallId, String status, String contentText) {
+ events.add("update:" + toolCallId + ":" + status);
+ }
+
+ @Override
+ public void onUsage(long used, long size) {
+ events.add("usage:" + used);
+ }
+ }
+
+ static final class NoopListener implements AcpAgentClient.Listener {
+ @Override
+ public void onTextChunk(String text) {
+ }
+
+ @Override
+ public void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) {
+ }
+
+ @Override
+ public void onToolCallUpdate(String toolCallId, String status, String contentText) {
+ }
+
+ @Override
+ public void onUsage(long used, long size) {
+ }
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStripTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStripTest.java
new file mode 100644
index 000000000000..3c8798815194
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStripTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.nio.file.Path;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.terminal.Frame;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AcpHeaderStripTest {
+
+ private static AcpHeaderStrip.Model model(int commands) {
+ return new AcpHeaderStrip.Model(
+ "IBM Bob (ACP)", "◆", Color.rgb(0x0F, 0x62, 0xFE),
+ "bob-shell 2.0.2", "58b65aea-1234", Path.of(System.getProperty("user.home"), "Work", "camel"), commands);
+ }
+
+ @Test
+ void glyphModeRendersGlyphAndMetadata() {
+ AcpHeaderStrip strip = new AcpHeaderStrip();
+ Rect area = new Rect(0, 0, 100, AcpHeaderStrip.ROWS);
+ Buffer buffer = Buffer.empty(area);
+ strip.render(Frame.forTesting(buffer), area, model(24));
+ String rendered = TuiTestHelper.bufferToString(buffer);
+ assertTrue(rendered.contains("◆ IBM Bob (ACP) · bob-shell 2.0.2"), rendered);
+ assertTrue(rendered.contains("session 58b65aea"), rendered);
+ assertTrue(rendered.contains("~/Work/camel"), rendered);
+ assertTrue(rendered.contains("24 commands"), rendered);
+ }
+
+ @Test
+ void narrowAreaRendersNothing() {
+ AcpHeaderStrip strip = new AcpHeaderStrip();
+ Rect area = new Rect(0, 0, 19, AcpHeaderStrip.ROWS);
+ Buffer buffer = Buffer.empty(area);
+ strip.render(Frame.forTesting(buffer), area, model(3));
+ assertTrue(TuiTestHelper.bufferToString(buffer).isBlank(), "nothing fits below 20 columns");
+ }
+
+ @Test
+ void metaLineAndHomeRelativePath() {
+ assertEquals("~/Work/camel", AcpHeaderStrip.homeRelative(Path.of(System.getProperty("user.home"), "Work", "camel")));
+ assertEquals("/opt/x", AcpHeaderStrip.homeRelative(Path.of("/opt/x")));
+ assertEquals("session 58b65aea · ~/Work/camel · 1 command · /agent: lists them", AcpHeaderStrip.metaLine(model(1)));
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopupTest.java
new file mode 100644
index 000000000000..0e4b27e9a58c
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopupTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import dev.tamboui.tui.event.MouseButton;
+import dev.tamboui.tui.event.MouseEvent;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AcpPermissionPopupTest {
+
+ private static List<JsonObject> options() {
+ return List.of(
+ AcpAgentClientTest.option("opt-allow", "Allow once", "allow_once"),
+ AcpAgentClientTest.option("opt-always", "Always allow", "allow_always"),
+ AcpAgentClientTest.option("opt-reject", "Reject", "reject_once"));
+ }
+
+ private static JsonObject toolCall() {
+ JsonObject rawInput = new JsonObject();
+ rawInput.put("file_path", "/tmp/route.yaml");
+ JsonObject toolCall = new JsonObject();
+ toolCall.put("title", "Write /tmp/route.yaml");
+ toolCall.put("kind", "edit");
+ toolCall.put("rawInput", rawInput);
+ return toolCall;
+ }
+
+ @Test
+ void rendersTitleKindInputAndOptions() {
+ AcpPermissionPopup popup = new AcpPermissionPopup();
+ popup.open(toolCall(), options());
+ Rect area = new Rect(0, 0, 100, 30);
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+ String rendered = TuiTestHelper.bufferToString(buffer);
+ assertTrue(rendered.contains("Write /tmp/route.yaml"));
+ assertTrue(rendered.contains("edit"));
+ assertTrue(rendered.contains("file_path"));
+ assertTrue(rendered.contains("Allow once"));
+ assertTrue(rendered.contains("Always allow"));
+ assertTrue(rendered.contains("Reject"));
+ }
+
+ @Test
+ void optionsStayVisibleOnAShortScreen() {
+ AcpPermissionPopup popup = new AcpPermissionPopup();
+ popup.open(toolCall(), options());
+ Rect area = new Rect(0, 0, 80, 8);
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+ String rendered = TuiTestHelper.bufferToString(buffer);
+ assertTrue(rendered.contains("Write /tmp/route.yaml"), rendered);
+ assertTrue(rendered.contains("Allow once"), rendered);
+ assertTrue(rendered.contains("Always allow"), rendered);
+ assertTrue(rendered.contains("Reject"), rendered);
+ }
+
+ @Test
+ void enterSelectsTheHighlightedOption() {
+ AcpPermissionPopup popup = new AcpPermissionPopup();
+ popup.open(toolCall(), options());
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ AcpPermissionPopup.Decision decision = popup.consumeDecision();
+ assertNotNull(decision);
+ assertEquals("opt-always", decision.optionId());
+ assertFalse(popup.isVisible());
+ assertNull(popup.consumeDecision(), "decision is consumed once");
+ }
+
+ @Test
+ void escapePicksRejectOnceWhenOffered() {
+ AcpPermissionPopup popup = new AcpPermissionPopup();
+ popup.open(toolCall(), options());
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE));
+ assertEquals("opt-reject", popup.consumeDecision().optionId());
+ }
+
+ @Test
+ void escapeAnswersCancelledWhenNoRejectOptionExists() {
+ AcpPermissionPopup popup = new AcpPermissionPopup();
+ popup.open(toolCall(), List.of(AcpAgentClientTest.option("opt-allow", "Allow", "allow_once")));
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE));
+ AcpPermissionPopup.Decision decision = popup.consumeDecision();
+ assertNotNull(decision);
+ assertNull(decision.optionId());
+ }
+
+ @Test
+ void footerOffersSelectCancelTurnAndReject() {
+ AcpPermissionPopup popup = new AcpPermissionPopup();
+ popup.open(toolCall(), options());
+ List<Span> spans = new ArrayList<>();
+ popup.renderFooter(spans);
+ String footer = spans.stream().map(Span::content).reduce("", String::concat);
+ assertTrue(footer.contains("Enter"));
+ assertTrue(footer.contains("Ctrl+C"));
+ assertTrue(footer.contains("cancel turn"));
+ assertTrue(footer.contains("Esc"));
+ }
+
+ @Test
+ void clickOnAnOptionRowSelectsThatOption() {
+ AcpPermissionPopup popup = new AcpPermissionPopup();
+ popup.open(toolCall(), options());
+ Rect area = new Rect(0, 0, 100, 30);
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+ Rect listRect = popup.listRectForTesting();
+ popup.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, listRect.x() + 1, listRect.y() + 1));
+ AcpPermissionPopup.Decision decision = popup.consumeDecision();
+ assertNotNull(decision);
+ assertEquals("opt-always", decision.optionId());
+ assertFalse(popup.isVisible());
+ }
+
+ @Test
+ void clickOutsideTheOptionRowsDoesNothing() {
+ AcpPermissionPopup popup = new AcpPermissionPopup();
+ popup.open(toolCall(), options());
+ Rect area = new Rect(0, 0, 100, 30);
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+ Rect listRect = popup.listRectForTesting();
+ popup.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, listRect.x(), listRect.y() - 2));
+ assertNull(popup.consumeDecision());
+ assertTrue(popup.isVisible());
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java
new file mode 100644
index 000000000000..9601c22d0884
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java
@@ -0,0 +1,863 @@
+/*
+ * 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.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.dsl.jbang.core.commands.LlmClient;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+@Isolated
+class AiPanelAcpTest {
+
+ private static final Duration T = Duration.ofSeconds(5);
+
+ private String originalHome;
+ private FakeAcpAgent agent;
+
+ @BeforeEach
+ void isolateHome(@TempDir Path tempDir) {
+ originalHome = CommandLineHelper.getHomeDir().toString();
+ CommandLineHelper.useHomeDir(tempDir.toString());
+ }
+
+ @AfterEach
+ void restoreHome() {
+ CommandLineHelper.useHomeDir(originalHome);
+ }
+
+ @AfterEach
+ void closeAgent() {
+ if (agent != null) {
+ agent.close();
+ }
+ }
+
+ private static void type(AiPanel panel, String text) {
+ for (char ch : text.toCharArray()) {
+ panel.handleKeyEvent(KeyEvent.ofChar(ch));
+ }
+ }
+
+ private static void enter(AiPanel panel) {
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ }
+
+ private static boolean hasEntry(AiPanel panel, AiRole role, String fragment) {
+ return panel.conversationForTesting().stream()
+ .anyMatch(e -> e.role() == role && e.text().contains(fragment));
+ }
+
+ /** Text of the last conversation entry with that role. */
+ private static String lastEntry(AiPanel panel, AiRole role) {
+ return panel.conversationForTesting().stream()
+ .filter(e -> e.role() == role)
+ .reduce((first, last) -> last)
+ .map(AiPanel.ConversationEntry::text)
+ .orElseGet(() -> fail("no " + role + " entry in the conversation"));
+ }
+
+ /** Panel wired to a fresh fake agent, with the Claude preset selected. */
+ private AiPanel acpPanel() throws IOException {
+ agent = new FakeAcpAgent();
+ AiPanel panel = new AiPanel();
+ panel.setToolRegistryForTesting(new TuiToolRegistry(null));
+ panel.setAcpClientFactoryForTesting((preset, cwd) -> {
+ AcpAgentClient client = new AcpAgentClient(agent.clientInput(), agent.clientOutput(), s -> {
+ });
+ client.start();
+ return client;
+ });
+ panel.setMcpUrlSupplierForTestingOrRuntime(() -> "http://127.0.0.1:4242/mcp");
+ panel.open();
+ panel.selectProviderForTesting("acp:claude");
+ return panel;
+ }
+
+ private static void ask(AiPanel panel, String text) {
+ type(panel, text);
+ enter(panel);
+ }
+
+ private static void awaitIdle(AiPanel panel) {
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertFalse(panel.isThinkingForTesting()));
+ }
+
+ /** Wraps an update for the session the prompt request carried, like a real agent does. */
+ private static JsonObject update(JsonObject promptParams, JsonObject update) {
+ JsonObject params = new JsonObject();
+ params.put("sessionId", promptParams.getString("sessionId"));
+ params.put("update", update);
+ return params;
+ }
+
+ private static JsonObject chunk(String text) {
+ JsonObject content = new JsonObject();
+ content.put("type", "text");
+ content.put("text", text);
+ JsonObject u = new JsonObject();
+ u.put("sessionUpdate", "agent_message_chunk");
+ u.put("content", content);
+ return u;
+ }
+
+ private static JsonObject stop(String reason) {
+ JsonObject r = new JsonObject();
+ r.put("stopReason", reason);
+ return r;
+ }
+
+ private static String promptText(JsonObject promptRequest) {
+ JsonArray prompt = promptRequest.getJsonObject("params").getJsonArray("prompt");
+ return ((JsonObject) prompt.get(0)).getString("text");
+ }
+
+ private static JsonObject permissionParams(String name, String title) {
+ return permissionParams(name, title,
+ AcpAgentClientTest.option("opt-allow", "Allow once", "allow_once"),
+ AcpAgentClientTest.option("opt-always", "Always allow", "allow_always"),
+ AcpAgentClientTest.option("opt-reject", "Reject", "reject_once"));
+ }
+
+ private static JsonObject permissionParams(String name, String title, JsonObject... offered) {
+ JsonObject toolCall = new JsonObject();
+ toolCall.put("toolCallId", "t9");
+ if (name != null) {
+ toolCall.put("name", name);
+ }
+ toolCall.put("title", title);
+ toolCall.put("kind", "other");
+ JsonArray options = new JsonArray();
+ for (JsonObject option : offered) {
+ options.add(option);
+ }
+ JsonObject params = new JsonObject();
+ params.put("sessionId", "sess");
+ params.put("toolCall", toolCall);
+ params.put("options", options);
+ return params;
+ }
+
+ /** Makes the fake agent advertise a "review" command via available_commands_update from its session/new handler. */
+ private void advertiseReviewCommand() {
+ JsonObject review = new JsonObject();
+ review.put("name", "review");
+ review.put("description", "Review the current changes");
+ JsonObject input = new JsonObject();
+ input.put("hint", "focus area");
+ review.put("input", input);
+ JsonArray commands = new JsonArray();
+ commands.add(review);
+ advertise(commands);
+ }
+
+ /** Same, for commands that only carry a name and a description. */
+ private void advertiseCommands(String... names) {
+ JsonArray commands = new JsonArray();
+ for (String name : names) {
+ JsonObject command = new JsonObject();
+ command.put("name", name);
+ command.put("description", "The agent's own /" + name);
+ commands.add(command);
+ }
+ advertise(commands);
+ }
+
+ private void advertise(JsonArray commands) {
+ agent.onRequest("session/new", params -> {
+ JsonObject update = new JsonObject();
+ update.put("sessionUpdate", "available_commands_update");
+ update.put("availableCommands", commands);
+ JsonObject notifyParams = new JsonObject();
+ notifyParams.put("sessionId", "sess-cmd");
+ notifyParams.put("update", update);
+ agent.sendNotification("session/update", notifyParams);
+ JsonObject r = new JsonObject();
+ r.put("sessionId", "sess-cmd");
+ return r;
+ });
+ }
+
+ /** Prompt handler that asks permission once and reports the client's answer as the stop reason. */
+ private void askPermissionDuringPrompt(JsonObject permission) {
+ agent.onRequest("session/prompt", params -> {
+ JsonObject answer = agent.sendRequest("session/request_permission", permission);
+ JsonObject outcome = answer.getJsonObject("result").getJsonObject("outcome");
+ String chosen = outcome.getString("optionId");
+ return stop(chosen != null ? chosen : outcome.getString("outcome"));
+ });
+ }
+
+ @Test
+ void selectingAnAcpProviderNeedsNoLlmClient() {
+ AiPanel panel = new AiPanel();
+ panel.open();
+ panel.selectProviderForTesting("acp:claude");
+ assertTrue(panel.isAcpProviderForTesting());
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "Selected Claude Code (ACP)"));
+ }
+
+ @Test
+ void customProviderWithoutCommandIsRejected() {
+ AiPanel panel = new AiPanel();
+ panel.open();
+ panel.selectProviderForTesting("acp:custom");
+ assertFalse(panel.isAcpProviderForTesting());
+ assertTrue(hasEntry(panel, AiRole.ERROR, "camel.tui.ai.acp.command"));
+ }
+
+ @Test
+ void modelCommandExplainsThatTheAgentOwnsTheModel() {
+ AiPanel panel = new AiPanel();
+ panel.open();
+ panel.selectProviderForTesting("acp:codex");
+ type(panel, "/model gpt-5");
+ enter(panel);
+ assertTrue(hasEntry(panel, AiRole.ERROR, "configured in the agent"));
+ }
+
+ @Test
+ void titleShowsTheAgentLabel() {
+ AiPanel panel = new AiPanel();
+ panel.open();
+ panel.selectProviderForTesting("acp:opencode");
+ Rect area = new Rect(0, 0, 100, 20);
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+ assertTrue(TuiTestHelper.bufferToString(buffer).contains("OpenCode (ACP)"));
+ }
+
+ @Test
+ void firstPromptStartsTheAgentPassesTheMcpServerAndStreamsTheAnswer() throws Exception {
+ AiPanel panel = acpPanel();
+ agent.onRequest("session/prompt", params -> {
+ agent.sendNotification("session/update", update(params, chunk("Hel")));
+ agent.sendNotification("session/update", update(params, chunk("lo")));
+ return stop("end_turn");
+ });
+ ask(panel, "what is wrong?");
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "Starting Claude Code (ACP)"));
+ assertTrue(hasEntry(panel, AiRole.ASSISTANT, "Hello"));
+ assertEquals(1, agent.awaitReceived("initialize", T).getJsonObject("params").getInteger("protocolVersion"));
+ JsonObject session = agent.awaitReceived("session/new", T).getJsonObject("params");
+ JsonObject server = (JsonObject) session.getJsonArray("mcpServers").get(0);
+ assertEquals("http://127.0.0.1:4242/mcp", server.getString("url"));
+ String text = promptText(agent.awaitReceived("session/prompt", T));
+ assertTrue(text.contains("Apache Camel assistant"), "first prompt carries the TUI preamble");
+ assertTrue(text.endsWith("what is wrong?"));
+ Rect area = new Rect(0, 0, 120, 20);
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+ assertTrue(TuiTestHelper.bufferToString(buffer).contains("fake-agent 1.2.3"), "title shows the agent");
+ }
+
+ @Test
+ void laterPromptsSkipThePreamble() throws Exception {
+ AiPanel panel = acpPanel();
+ ask(panel, "first");
+ awaitIdle(panel);
+ ask(panel, "again");
+ awaitIdle(panel);
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertEquals(2, agent.receivedCount("session/prompt")));
+ assertEquals("again", promptText(agent.received("session/prompt").get(1)));
+ assertEquals(1, agent.receivedCount("session/new"), "one session for both prompts");
+ }
+
+ @Test
+ void toolCallsBecomeStatusLinesThatAreUpdatedInPlace() throws Exception {
+ AiPanel panel = acpPanel();
+ agent.onRequest("session/prompt", params -> {
+ JsonObject call = new JsonObject();
+ call.put("sessionUpdate", "tool_call");
+ call.put("toolCallId", "t1");
+ call.put("title", "Read file");
+ call.put("kind", "read");
+ agent.sendNotification("session/update", update(params, call));
+ JsonObject done = new JsonObject();
+ done.put("sessionUpdate", "tool_call_update");
+ done.put("toolCallId", "t1");
+ done.put("status", "completed");
+ agent.sendNotification("session/update", update(params, done));
+ agent.sendNotification("session/update", update(params, chunk("done")));
+ return stop("end_turn");
+ });
+ ask(panel, "read it");
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, TuiIcons.CHECK + " Read file"));
+ assertFalse(hasEntry(panel, AiRole.SYSTEM, TuiIcons.GEAR + " Read file"), "running marker replaced");
+ assertTrue(hasEntry(panel, AiRole.ASSISTANT, "done"));
+ }
+
+ @Test
+ void escapeCancelsTheTurn() throws Exception {
+ AiPanel panel = acpPanel();
+ agent.onRequest("session/prompt", params -> {
+ agent.awaitReceived("session/cancel", Duration.ofSeconds(30));
+ return stop("cancelled");
+ });
+ ask(panel, "slow one");
+ agent.awaitReceived("session/prompt", T);
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE));
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(cancelled)"));
+ assertTrue(agent.receivedCount("session/cancel") >= 1);
+ }
+
+ @Test
+ void clearOpensANewSessionOnTheNextPrompt() throws Exception {
+ AiPanel panel = acpPanel();
+ ask(panel, "one");
+ awaitIdle(panel);
+ String first = panel.acpSessionIdForTesting();
+ ask(panel, "/clear");
+ ask(panel, "two");
+ awaitIdle(panel);
+ assertEquals(2, agent.receivedCount("session/new"));
+ assertNotEquals(first, panel.acpSessionIdForTesting());
+ assertTrue(promptText(agent.awaitReceived("session/prompt", T)).contains("Apache Camel assistant"));
+ }
+
+ @Test
+ void aJsonRpcErrorKeepsTheSessionAlive() throws Exception {
+ AiPanel panel = acpPanel();
+ agent.failRequest("session/prompt", -32603, "boom");
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.ERROR, "boom"));
+ ask(panel, "again");
+ awaitIdle(panel);
+ assertEquals(2, agent.receivedCount("session/prompt"), "the second question reaches the same agent");
+ assertEquals(1, agent.receivedCount("session/new"), "the session survives a JSON-RPC error");
+ assertEquals(1, agent.receivedCount("initialize"), "the agent process survives a JSON-RPC error");
+ }
+
+ @Test
+ void authRequiredTriggersOneAuthenticateAndARetry() throws Exception {
+ AiPanel panel = acpPanel();
+ agent.onRequest("initialize", params -> {
+ JsonObject result = FakeAcpAgent.defaultInitializeResult();
+ JsonObject method = new JsonObject();
+ method.put("id", "agent-login");
+ method.put("name", "Agent login");
+ JsonArray methods = new JsonArray();
+ methods.add(method);
+ result.put("authMethods", methods);
+ return result;
+ });
+ agent.failRequest("session/new", AcpAgentClient.AUTH_REQUIRED, "Authentication required");
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertEquals(1, agent.receivedCount("authenticate"));
+ assertEquals(2, agent.receivedCount("session/new"));
+ assertEquals("agent-login", agent.awaitReceived("authenticate", T).getJsonObject("params").getString("methodId"));
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "Authenticating with fake-agent"));
+ assertFalse(hasEntry(panel, AiRole.ERROR, "Authentication"));
+ }
+
+ @Test
+ void authRequiredWithOnlyTerminalMethodsShowsTheLoginHint() throws Exception {
+ AiPanel panel = acpPanel();
+ agent.onRequest("initialize", params -> {
+ JsonObject result = FakeAcpAgent.defaultInitializeResult();
+ JsonObject meta = new JsonObject();
+ meta.put("type", "terminal");
+ JsonObject method = new JsonObject();
+ method.put("id", "claude-login");
+ method.put("name", "Log in with Claude");
+ method.put("_meta", meta);
+ JsonArray methods = new JsonArray();
+ methods.add(method);
+ result.put("authMethods", methods);
+ return result;
+ });
+ agent.failRequest("session/new", AcpAgentClient.AUTH_REQUIRED, "Authentication required");
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertEquals(0, agent.receivedCount("authenticate"));
+ assertTrue(hasEntry(panel, AiRole.ERROR, "ANTHROPIC_API_KEY"));
+ }
+
+ @Test
+ void protocolVersionMismatchIsReported() throws Exception {
+ AiPanel panel = acpPanel();
+ agent.onRequest("initialize", params -> {
+ JsonObject result = FakeAcpAgent.defaultInitializeResult();
+ result.put("protocolVersion", 2);
+ return result;
+ });
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.ERROR, "protocol version"));
+ assertEquals(0, agent.receivedCount("session/new"));
+ }
+
+ @Test
+ void missingHttpMcpCapabilityIsReported() throws Exception {
+ AiPanel panel = acpPanel();
+ agent.onRequest("initialize", params -> {
+ JsonObject result = FakeAcpAgent.defaultInitializeResult();
+ result.getJsonObject("agentCapabilities").getJsonObject("mcpCapabilities").put("http", false);
+ return result;
+ });
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.ERROR, "HTTP MCP"));
+ }
+
+ @Test
+ void refusalStopReasonIsAnError() throws Exception {
+ AiPanel panel = acpPanel();
+ agent.onRequest("session/prompt", params -> stop("refusal"));
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.ERROR, "refused"));
+ }
+
+ /** Makes the fake agent report a context usage of that size for the next turn. */
+ private void reportUsage(long used, long size) {
+ agent.onRequest("session/prompt", params -> {
+ JsonObject usage = new JsonObject();
+ usage.put("sessionUpdate", "usage_update");
+ usage.put("used", used);
+ usage.put("size", size);
+ agent.sendNotification("session/update", update(params, usage));
+ agent.sendNotification("session/update", update(params, chunk("ok")));
+ return stop("end_turn");
+ });
+ }
+
+ @Test
+ void usageUpdateFeedsTheTokenCounter() throws Exception {
+ AiPanel panel = acpPanel();
+ reportUsage(1234, 200000);
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertEquals(1234, panel.sessionTotalTokensForTesting());
+ assertArrayEquals(new long[] { 1234, 200000 }, panel.acpContextForTesting());
+
+ // the agent reports what is in its context now, so the second turn only adds its own 66 tokens
+ reportUsage(1300, 200000);
+ ask(panel, "and now?");
+ awaitIdle(panel);
+ assertEquals(1300, panel.sessionTotalTokensForTesting(), "1234 + the 66 this turn added");
+ assertArrayEquals(new long[] { 1300, 200000 }, panel.acpContextForTesting());
+ ask(panel, "/context");
+ String text = lastEntry(panel, AiRole.SYSTEM);
+ assertTrue(text.contains("Context: " + LlmClient.formatTokens(1300) + " of " + LlmClient.formatTokens(200000)),
+ text);
+
+ // the agent compacted: the context shrank, which is not a negative token spend
+ reportUsage(900, 200000);
+ ask(panel, "still there?");
+ awaitIdle(panel);
+ assertEquals(1300, panel.sessionTotalTokensForTesting(), "a smaller context spends nothing");
+ assertArrayEquals(new long[] { 900, 200000 }, panel.acpContextForTesting());
+ }
+
+ @Test
+ void missingMcpServerIsReportedWithoutSpawning() throws Exception {
+ AiPanel panel = acpPanel();
+ panel.setMcpUrlSupplierForTestingOrRuntime(() -> {
+ throw new IOException("bind failed");
+ });
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.ERROR, "MCP server"));
+ assertEquals(0, agent.receivedCount("initialize"));
+ }
+
+ @Test
+ void tuiToolCallsAreAutoApprovedByName() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state"));
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)"), "allow_always preferred");
+ }
+
+ @Test
+ void tuiToolCallsAreAutoApprovedByTitle() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams(null, "tui_get_state (camel-tui MCP Server)"));
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)"));
+ }
+
+ @Test
+ void mutatingTuiToolOpensThePopupEvenByName() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_control", "tui_control"));
+ ask(panel, "hi");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE));
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "tui_control is not read-only");
+ }
+
+ @Test
+ void executeKindIsNotAutoApprovedEvenForARegisteredTuiTool() throws Exception {
+ AiPanel panel = acpPanel();
+ JsonObject permission = permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state");
+ permission.getJsonObject("toolCall").put("kind", "execute");
+ askPermissionDuringPrompt(permission);
+ ask(panel, "hi");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE));
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "a shell call is never a TUI tool call");
+ }
+
+ @Test
+ void fileEditWithCamelTuiInThePathIsNotAutoApproved() throws Exception {
+ AiPanel panel = acpPanel();
+ JsonObject permission = permissionParams(null, "Edit /tmp/camel-tui/route.yaml");
+ permission.getJsonObject("toolCall").put("kind", "edit");
+ askPermissionDuringPrompt(permission);
+ ask(panel, "hi");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE));
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "a path is not a tool identity");
+ }
+
+ @Test
+ void unregisteredToolWithCamelTuiTitleIsNotAutoApproved() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams(null, "rm -rf / (camel-tui MCP Server)"));
+ ask(panel, "hi");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE));
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "the TUI registers no such tool");
+ }
+
+ @Test
+ void tuiToolWithoutAllowAlwaysFallsBackToAllowOnce() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state",
+ AcpAgentClientTest.option("opt-allow", "Allow once", "allow_once"),
+ AcpAgentClientTest.option("opt-reject", "Reject", "reject_once")));
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-allow)"));
+ }
+
+ @Test
+ void tuiToolWithNoAllowOptionsPicksTheFirst() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state",
+ AcpAgentClientTest.option("opt-reject", "Reject", "reject_once")));
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"));
+ }
+
+ @Test
+ void otherToolCallsOpenThePopupAndEnterAnswers() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build"));
+ ask(panel, "hi");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ assertTrue(panel.isThinkingForTesting(), "turn is still in progress while the popup waits");
+ enter(panel);
+ awaitIdle(panel);
+ assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-allow)"));
+ }
+
+ @Test
+ void escapeOnThePopupRejects() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build"));
+ ask(panel, "hi");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE));
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"));
+ }
+
+ @Test
+ void popupRendersWhileWaiting() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build"));
+ ask(panel, "hi");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ Rect area = new Rect(0, 0, 100, 30);
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+ assertTrue(TuiTestHelper.bufferToString(buffer).contains("rm -rf build"));
+ enter(panel);
+ awaitIdle(panel);
+ }
+
+ @Test
+ void providerSwitchClosesTheAgent() throws Exception {
+ AiPanel panel = acpPanel();
+ ask(panel, "hi");
+ awaitIdle(panel);
+ panel.selectProviderForTesting("anthropic");
+ assertFalse(panel.isAcpProviderForTesting());
+ assertNull(panel.acpSessionIdForTesting());
+ assertTrue(agent.awaitClientClosed(Duration.ofSeconds(5)), "the agent process is closed on a provider switch");
+ }
+
+ @Test
+ void missingExecutableShowsTheInstallHint() {
+ AiPanel panel = new AiPanel();
+ panel.setMcpUrlSupplierForTestingOrRuntime(() -> "http://127.0.0.1:4242/mcp");
+ TuiSettings settings = TuiSettings.load();
+ settings.setAiAcpCommand("definitely-not-a-real-binary-42 --acp");
+ settings.save();
+ panel.open();
+ panel.selectProviderForTesting("acp:custom");
+ ask(panel, "hi");
+ awaitIdle(panel);
+ assertTrue(hasEntry(panel, AiRole.ERROR, "definitely-not-a-real-binary-42 not found"));
+ }
+
+ @Test
+ void ctrlCWhileThePopupWaitsCancelsTheTurn() throws Exception {
+ AiPanel panel = acpPanel();
+ askPermissionDuringPrompt(permissionParams("Bash", "rm -rf build"));
+ ask(panel, "hi");
+ await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(panel.isPermissionPopupVisibleForTesting()));
+ panel.handleKeyEvent(KeyEvent.ofChar('c', KeyModifiers.CTRL));
+ awaitIdle(panel);
+ assertFalse(panel.isPermissionPopupVisibleForTesting());
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "(cancelled)"));
+ }
+
+ @Test
+ void unknownSlashCommandIsForwardedToTheAgentWithoutThePreamble() throws Exception {
+ AiPanel panel = acpPanel();
+ ask(panel, "/review src");
+ awaitIdle(panel);
+ assertEquals("/review src", promptText(agent.received("session/prompt").get(0)));
+ ask(panel, "hello");
+ awaitIdle(panel);
+ assertTrue(promptText(agent.received("session/prompt").get(1)).contains("Apache Camel assistant"),
+ "the first regular prompt still carries the preamble");
+ }
+
+ @Test
+ void panelCommandsStillWinOverTheAgent() throws Exception {
+ AiPanel panel = acpPanel();
+ ask(panel, "/help");
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "/provider"));
+ assertEquals(0, agent.receivedCount("session/prompt"));
+ }
+
+ @Test
+ void headerStripAppearsOnceTheSessionIsOpen() throws Exception {
+ AiPanel panel = acpPanel();
+ // wide enough that the meta line is never clipped by a deep checkout path
+ Rect area = new Rect(0, 0, 200, 20);
+ Buffer before = Buffer.empty(area);
+ panel.render(Frame.forTesting(before), area);
+ assertFalse(TuiTestHelper.bufferToString(before).contains("session "), "no header before the session opens");
+ advertiseReviewCommand();
+ ask(panel, "hi");
+ awaitIdle(panel);
+ Buffer after = Buffer.empty(area);
+ panel.render(Frame.forTesting(after), area);
+ String rendered = TuiTestHelper.bufferToString(after);
+ assertTrue(rendered.contains("Claude Code (ACP) · fake-agent 1.2.3"), rendered);
+ assertTrue(rendered.contains("session " + panel.acpSessionIdForTesting()), rendered);
+ assertTrue(rendered.contains(AcpHeaderStrip.homeRelative(Path.of("").toAbsolutePath())), rendered);
+ assertTrue(rendered.contains("1 command"), rendered);
+ assertFalse(hasEntry(panel, AiRole.SYSTEM, " connected"), "no banner entry in the conversation any more");
+ }
+
+ @Test
+ void headerStripIsHiddenOnAShortPanel() throws Exception {
+ AiPanel panel = acpPanel();
+ ask(panel, "hi");
+ awaitIdle(panel);
+ Rect area = new Rect(0, 0, 120, 6);
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+ assertFalse(TuiTestHelper.bufferToString(buffer).contains("session "));
+ }
+
+ @Test
+ void agentCommandsAppearInHintsAndTabCompletion() throws Exception {
+ AiPanel panel = acpPanel();
+ advertiseReviewCommand();
+ ask(panel, "hi");
+ awaitIdle(panel);
+ type(panel, "/rev");
+ Rect area = new Rect(0, 0, 100, 20);
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+ String rendered = TuiTestHelper.bufferToString(buffer);
+ assertTrue(rendered.contains("/agent:review focus area"));
+ assertTrue(rendered.contains("Review the current changes"));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ assertEquals("/agent:review ", panel.inputBufferForTesting());
+ }
+
+ @Test
+ void agentPrefixForwardsEvenAPanelCommandName() throws Exception {
+ AiPanel panel = acpPanel();
+ ask(panel, "/agent:clear");
+ awaitIdle(panel);
+ assertEquals("/clear", promptText(agent.received("session/prompt").get(0)));
+ assertTrue(hasEntry(panel, AiRole.USER, "/agent:clear"), "the panel conversation was not cleared");
+ }
+
+ @Test
+ void agentPrefixAloneListsTheAgentCommands() throws Exception {
+ AiPanel panel = acpPanel();
+ advertiseReviewCommand();
+ ask(panel, "hi");
+ awaitIdle(panel);
+ ask(panel, "/agent:");
+ Rect area = new Rect(0, 0, 120, 20);
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+ String listing = TuiTestHelper.bufferToString(buffer);
+ assertTrue(listing.lines().anyMatch(
+ line -> line.contains("/agent:review") && line.contains("Review the current changes")),
+ listing);
+ assertEquals(1, agent.receivedCount("session/prompt"), "nothing was sent to the agent");
+ }
+
+ @Test
+ void agentCommandsAreShownAndCompletedWithThePrefix() throws Exception {
+ AiPanel panel = acpPanel();
+ advertiseReviewCommand();
+ ask(panel, "hi");
+ awaitIdle(panel);
+ type(panel, "/agent:rev");
+ Rect area = new Rect(0, 0, 100, 20);
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+ assertTrue(TuiTestHelper.bufferToString(buffer).contains("/agent:review"));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+ assertEquals("/agent:review ", panel.inputBufferForTesting());
+ }
+
+ @Test
+ void retryResendsTheLastQuestionToTheAgent() throws Exception {
+ AiPanel panel = acpPanel();
+ ask(panel, "/retry");
+ assertTrue(hasEntry(panel, AiRole.ERROR, "No question to retry"));
+ ask(panel, "hello");
+ awaitIdle(panel);
+ ask(panel, "/retry");
+ awaitIdle(panel);
+ assertEquals(2, agent.receivedCount("session/prompt"));
+ assertEquals("hello", promptText(agent.received("session/prompt").get(1)), "the preamble is not repeated");
+ assertEquals(2, panel.conversationForTesting().stream().filter(e -> e.role() == AiRole.USER).count());
+ }
+
+ @Test
+ void retryReplaysAnAgentCommandWithoutThePrefix() throws Exception {
+ AiPanel panel = acpPanel();
+ ask(panel, "/agent:clear");
+ awaitIdle(panel);
+ ask(panel, "/retry");
+ awaitIdle(panel);
+ assertEquals("/clear", promptText(agent.received("session/prompt").get(1)));
+ }
+
+ @Test
+ void contextDescribesTheAgentSession() throws Exception {
+ AiPanel panel = acpPanel();
+ advertiseReviewCommand();
+ ask(panel, "/context");
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "not started yet"));
+ assertEquals(0, agent.receivedCount("session/prompt"));
+ ask(panel, "hello");
+ awaitIdle(panel);
+ ask(panel, "/context");
+ String text = lastEntry(panel, AiRole.SYSTEM);
+ assertTrue(text.contains("Session: sess-cmd"), text);
+ assertTrue(text.contains("MCP: http://127.0.0.1:4242/mcp"), text);
+ assertTrue(text.contains("Agent commands: 1"), text);
+ assertTrue(text.contains("Preamble: ~"), text);
+ assertTrue(text.contains("/compact and /tools do not apply"), text);
+ assertEquals(1, agent.receivedCount("session/prompt"), "/context is answered by the panel");
+ }
+
+ @Test
+ void compactAndToolsExplainThatTheAgentOwnsThem() throws Exception {
+ AiPanel panel = acpPanel();
+ advertiseCommands("compact");
+ ask(panel, "hello");
+ awaitIdle(panel);
+ ask(panel, "/compact");
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "manages its own conversation history"));
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "Use /agent:compact"));
+ ask(panel, "/tools");
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "through the MCP server directly"));
+ ask(panel, "/tools core");
+ assertTrue(hasEntry(panel, AiRole.ERROR, "only applies to LLM providers"));
+ assertNull(TuiSettings.load().getAiTools(), "the LLM tool set was not changed");
+ assertEquals(1, agent.receivedCount("session/prompt"), "nothing was forwarded to the agent");
+ }
+
+ @Test
+ void compactWithoutAnAgentCommandGivesNoHint() {
+ AiPanel panel = new AiPanel();
+ panel.open();
+ panel.selectProviderForTesting("acp:codex");
+ ask(panel, "/compact");
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "Codex (ACP) manages its own conversation history"));
+ assertFalse(hasEntry(panel, AiRole.SYSTEM, "/agent:compact"));
+ }
+
+ @Test
+ void promptShowsThePreambleSentToTheAgent() {
+ AiPanel panel = new AiPanel();
+ panel.open();
+ panel.selectProviderForTesting("acp:codex");
+ ask(panel, "/prompt");
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "Sent to Codex (ACP) ahead of the first prompt"));
+ assertTrue(hasEntry(panel, AiRole.SYSTEM, "Apache Camel assistant"));
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java
index b8f85286c6e5..c621d51f8650 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSelectorTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -23,12 +24,18 @@ import org.apache.camel.dsl.jbang.core.commands.LlmClient;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledOnOs;
+import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.parallel.Isolated;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -59,7 +66,8 @@ class AiProviderSelectorTest {
List<AiProviderSwitchPopup.ProviderChoice> choices = selector.buildChoices();
- assertEquals(List.of("auto", "anthropic", "openai", "gemini", "ollama", "watsonx"),
+ assertEquals(List.of("auto", "anthropic", "openai", "gemini", "ollama", "watsonx",
+ "acp:claude", "acp:codex", "acp:bob", "acp:qwen", "acp:opencode", "acp:dsh"),
choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList(),
"all known providers must be offered, even without a detected API key, so they remain selectable");
assertTrue(choices.get(0).persistedDefault());
@@ -74,7 +82,8 @@ class AiProviderSelectorTest {
List<AiProviderSwitchPopup.ProviderChoice> choices = selector.buildChoices();
- assertEquals(List.of("anthropic", "openai", "gemini", "ollama", "watsonx"),
+ assertEquals(List.of("anthropic", "openai", "gemini", "ollama", "watsonx",
+ "acp:claude", "acp:codex", "acp:bob", "acp:qwen", "acp:opencode", "acp:dsh"),
choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList(),
"anthropic must not be listed twice when it's already the default");
}
@@ -88,7 +97,8 @@ class AiProviderSelectorTest {
List<AiProviderSwitchPopup.ProviderChoice> choices = selector.buildChoices();
- assertEquals(List.of("ollama", "anthropic", "openai", "gemini", "watsonx"),
+ assertEquals(List.of("ollama", "anthropic", "openai", "gemini", "watsonx",
+ "acp:claude", "acp:codex", "acp:bob", "acp:qwen", "acp:opencode", "acp:dsh"),
choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList());
}
@@ -101,7 +111,8 @@ class AiProviderSelectorTest {
List<AiProviderSwitchPopup.ProviderChoice> choices = selector.buildChoices();
- assertEquals(List.of("watsonx", "anthropic", "openai", "gemini", "ollama"),
+ assertEquals(List.of("watsonx", "anthropic", "openai", "gemini", "ollama",
+ "acp:claude", "acp:codex", "acp:bob", "acp:qwen", "acp:opencode", "acp:dsh"),
choices.stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList());
}
@@ -150,4 +161,72 @@ class AiProviderSelectorTest {
.hasMessageContaining("anthropic")
.hasMessageContaining("watsonx");
}
+
+ @Test
+ void customAcpRowAppearsOnlyWhenCommandIsConfigured(@TempDir Path tempDir) {
+ useHome(tempDir);
+ assertFalse(selector.buildChoices().stream().anyMatch(c -> "acp:custom".equals(c.provider())));
+ TuiSettings settings = TuiSettings.load();
+ settings.setAiAcpCommand("npx -y pi-acp");
+ settings.save();
+ assertTrue(selector.buildChoices().stream().anyMatch(c -> "acp:custom".equals(c.provider())));
+ }
+
+ @Test
+ void acpDefaultIsListedFirstAndNotDuplicated(@TempDir Path tempDir) {
+ useHome(tempDir);
+ TuiSettings settings = TuiSettings.load();
+ settings.setAiProvider("acp:codex");
+ settings.save();
+ List<String> ids = selector.buildChoices().stream().map(AiProviderSwitchPopup.ProviderChoice::provider).toList();
+ assertEquals("acp:codex", ids.get(0));
+ assertEquals(1, ids.stream().filter("acp:codex"::equals).count());
+ }
+
+ @Test
+ void applyChoiceLeavesLlmClientUntouchedForAcpProviders() {
+ LlmClient client = LlmClient.create();
+ LlmClient.ApiType before = client.apiType();
+ selector.applyChoice(client, "acp:claude", "", "");
+ assertEquals(before, client.apiType());
+ }
+
+ @Test
+ void acpPresetResolvesCommandsAndCustomCommand(@TempDir Path tempDir) {
+ useHome(tempDir);
+ TuiSettings settings = TuiSettings.load();
+ AiProviderSelector.AcpPreset claude = selector.acpPreset("acp:claude", settings);
+ assertEquals(List.of("npx", "-y", "@agentclientprotocol/claude-agent-acp"), claude.command());
+ assertEquals("npx", claude.executable());
+ assertEquals("Claude Code (ACP)", AiProviderSelector.acpLabel("acp:claude"));
+ assertThrows(IllegalArgumentException.class, () -> selector.acpPreset("acp:custom", settings));
+ settings.setAiAcpCommand(" /opt/agent/bin/agent --acp ");
+ AiProviderSelector.AcpPreset custom = selector.acpPreset("acp:custom", settings);
+ assertEquals(List.of("/opt/agent/bin/agent", "--acp"), custom.command());
+ assertEquals("/opt/agent/bin/agent", custom.executable());
+ assertEquals("Custom (ACP)", custom.label());
+ assertEquals("✱", claude.glyph());
+ assertEquals("●", custom.glyph());
+ assertThrows(IllegalArgumentException.class, () -> selector.acpPreset("acp:nope", settings));
+ }
+
+ @Test
+ @EnabledOnOs({ OS.LINUX, OS.MAC })
+ void resolveExecutableFindsShellButNotNonsense() {
+ assertNotNull(AiProviderSelector.resolveExecutable("sh"));
+ assertNull(AiProviderSelector.resolveExecutable("definitely-not-a-real-binary-42"));
+ }
+
+ @Test
+ void resolveExecutableReturnsTheFileItFoundIncludingACmdShim(@TempDir Path tempDir) throws Exception {
+ Path bob = Files.createFile(tempDir.resolve("fakebob"));
+ assertTrue(bob.toFile().setExecutable(true));
+ Path npx = Files.createFile(tempDir.resolve("fakenpx.cmd"));
+ assertTrue(npx.toFile().setExecutable(true));
+ String path = tempDir.toString();
+ assertEquals(bob.toString(), AiProviderSelector.resolveExecutable("fakebob", path));
+ assertEquals(npx.toString(), AiProviderSelector.resolveExecutable("fakenpx", path));
+ assertEquals(npx.toString(), AiProviderSelector.resolveExecutable(tempDir.resolve("fakenpx").toString(), path));
+ assertNull(AiProviderSelector.resolveExecutable("definitely-not-a-real-binary-42", path));
+ }
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopupTest.java
index d558f73fb33d..fa0c2de113eb 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopupTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiProviderSwitchPopupTest.java
@@ -50,6 +50,14 @@ class AiProviderSwitchPopupTest {
assertFalse(popup.isVisible());
}
+ @Test
+ void acpChoiceIsLabelledWithTheAgentName() {
+ assertEquals("Claude Code (ACP) default",
+ new AiProviderSwitchPopup.ProviderChoice("acp:claude", "", "", true).label());
+ assertEquals("Claude Code (ACP)",
+ new AiProviderSwitchPopup.ProviderChoice("acp:claude", "", "", false).label());
+ }
+
@Test
void escapeCancelsWithoutSelection() {
AiProviderSwitchPopup popup = new AiProviderSwitchPopup();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java
index c386d8bb38a4..667c0fc1c22f 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java
@@ -94,6 +94,39 @@ class AiSlashCommandRegistryTest {
assertTrue(registry.completionsFor("/send ").isEmpty());
}
+ @Test
+ void completionsIncludeExtraDescriptorsButRegistryCommandsWin() {
+ AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults();
+ List<AiSlashCommandRegistry.Descriptor> extra = List.of(
+ new AiSlashCommandRegistry.Descriptor("review", List.of(), "Review changes", "focus", null),
+ new AiSlashCommandRegistry.Descriptor("help", List.of(), "Agent help", null, null));
+ List<String> all = registry.completionsFor("/", extra).stream().map(AiSlashCommandRegistry.Descriptor::name).toList();
+ assertTrue(all.contains("review"));
+ assertEquals(1, all.stream().filter("help"::equals).count(), "the panel's /help wins over the agent's");
+ assertEquals(List.of("review"),
+ registry.completionsFor("/rev", extra).stream().map(AiSlashCommandRegistry.Descriptor::name).toList());
+ assertTrue(registry.completionsFor("/review ", extra).isEmpty(),
+ "a complete command followed by a space hides the hints");
+ assertTrue(registry.completionsFor("/review focus", extra).isEmpty());
+ }
+
+ @Test
+ void prefixedAgentDescriptorsMatchByAliasAndHideWhenComplete() {
+ AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults();
+ List<AiSlashCommandRegistry.Descriptor> extra = List.of(
+ new AiSlashCommandRegistry.Descriptor("agent:review", List.of("review"), "Review changes", "focus", null),
+ new AiSlashCommandRegistry.Descriptor("agent:clear", List.of("clear"), "Clear the agent context", null, null));
+ assertEquals(List.of("agent:review"),
+ registry.completionsFor("/rev", extra).stream().map(AiSlashCommandRegistry.Descriptor::name).toList());
+ assertEquals(List.of("agent:review", "agent:clear"),
+ registry.completionsFor("/agent:", extra).stream().map(AiSlashCommandRegistry.Descriptor::name).toList());
+ assertTrue(registry.completionsFor("/agent:review ", extra).isEmpty());
+ assertTrue(registry.completionsFor("/review ", extra).isEmpty(),
+ "a complete alias followed by a space hides the hints too");
+ assertTrue(registry.completionsFor("/clear", extra).stream().anyMatch(d -> "agent:clear".equals(d.name())),
+ "the agent's clear is offered next to the panel's because its display name does not collide");
+ }
+
@Test
void placeholderUsesRegistryDescriptor() {
AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults();
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FakeAcpAgent.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FakeAcpAgent.java
new file mode 100644
index 000000000000..82286c937cbd
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FakeAcpAgent.java
@@ -0,0 +1,267 @@
+/*
+ * 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.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.io.Writer;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+import static org.awaitility.Awaitility.await;
+
+/**
+ * In-memory ACP agent for tests. Talks newline-delimited JSON-RPC over pipes. Request handlers run on their own thread
+ * so a handler may itself send notifications and agent-initiated requests (e.g. permission prompts) and wait for the
+ * client's answers.
+ */
+final class FakeAcpAgent implements AutoCloseable {
+
+ private final PipedOutputStream agentOut = new PipedOutputStream();
+ private final PipedOutputStream clientOut = new PipedOutputStream();
+ private final PipedInputStream clientIn;
+ private final PipedInputStream agentIn;
+ private final BufferedReader reader;
+ private final Writer writer;
+ private final Map<String, Function<JsonObject, JsonObject>> handlers = new ConcurrentHashMap<>();
+ private final Map<String, JsonObject> errors = new ConcurrentHashMap<>();
+ private final List<JsonObject> received = new CopyOnWriteArrayList<>();
+ private final Map<Long, CompletableFuture<JsonObject>> pendingAgentRequests = new ConcurrentHashMap<>();
+ private final AtomicLong ids = new AtomicLong(1000);
+ private final CountDownLatch clientClosed = new CountDownLatch(1);
+ private final ExecutorService handlerExecutor = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "fake-acp-handler");
+ t.setDaemon(true);
+ return t;
+ });
+
+ FakeAcpAgent() throws IOException {
+ clientIn = new PipedInputStream(agentOut, 1 << 16);
+ agentIn = new PipedInputStream(clientOut, 1 << 16);
+ reader = new BufferedReader(new InputStreamReader(agentIn, StandardCharsets.UTF_8));
+ writer = new BufferedWriter(new OutputStreamWriter(agentOut, StandardCharsets.UTF_8));
+ onRequest("initialize", params -> defaultInitializeResult());
+ onRequest("session/new", params -> {
+ JsonObject r = new JsonObject();
+ r.put("sessionId", "sess-" + ids.incrementAndGet());
+ return r;
+ });
+ onRequest("session/prompt", params -> {
+ JsonObject r = new JsonObject();
+ r.put("stopReason", "end_turn");
+ return r;
+ });
+ onRequest("authenticate", params -> new JsonObject());
+ Thread t = new Thread(this::loop, "fake-acp-reader");
+ t.setDaemon(true);
+ t.start();
+ }
+
+ static JsonObject defaultInitializeResult() {
+ JsonObject mcp = new JsonObject();
+ mcp.put("http", true);
+ mcp.put("sse", false);
+ JsonObject caps = new JsonObject();
+ caps.put("mcpCapabilities", mcp);
+ JsonObject info = new JsonObject();
+ info.put("name", "fake-agent");
+ info.put("version", "1.2.3");
+ JsonObject r = new JsonObject();
+ r.put("protocolVersion", 1);
+ r.put("agentCapabilities", caps);
+ r.put("authMethods", new JsonArray());
+ r.put("agentInfo", info);
+ return r;
+ }
+
+ InputStream clientInput() {
+ return clientIn;
+ }
+
+ OutputStream clientOutput() {
+ return clientOut;
+ }
+
+ void onRequest(String method, Function<JsonObject, JsonObject> resultBuilder) {
+ errors.remove(method);
+ handlers.put(method, resultBuilder);
+ }
+
+ /** The next request for {@code method} is answered with this JSON-RPC error once, then the handler applies. */
+ void failRequest(String method, int code, String message) {
+ JsonObject error = new JsonObject();
+ error.put("code", code);
+ error.put("message", message);
+ errors.put(method, error);
+ }
+
+ void sendNotification(String method, JsonObject params) {
+ JsonObject msg = new JsonObject();
+ msg.put("jsonrpc", "2.0");
+ msg.put("method", method);
+ msg.put("params", params);
+ write(msg);
+ }
+
+ /** Agent-initiated request; blocks up to 5 seconds for the client's response and returns it. */
+ JsonObject sendRequest(String method, JsonObject params) {
+ long id = ids.incrementAndGet();
+ CompletableFuture<JsonObject> future = new CompletableFuture<>();
+ pendingAgentRequests.put(id, future);
+ JsonObject msg = new JsonObject();
+ msg.put("jsonrpc", "2.0");
+ msg.put("id", id);
+ msg.put("method", method);
+ msg.put("params", params);
+ write(msg);
+ try {
+ return future.get(5, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ throw new IllegalStateException("No response to " + method, e);
+ }
+ }
+
+ void sendRaw(String line) {
+ synchronized (writer) {
+ try {
+ writer.write(line);
+ writer.write('\n');
+ writer.flush();
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+ }
+
+ JsonObject awaitReceived(String method, Duration timeout) {
+ await("client never sent " + method).atMost(timeout).until(() -> find(method) != null);
+ return find(method);
+ }
+
+ /** True when the client closed its end of the pipe within {@code timeout}. */
+ boolean awaitClientClosed(Duration timeout) throws InterruptedException {
+ return clientClosed.await(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ }
+
+ private JsonObject find(String method) {
+ return received.stream().filter(m -> method.equals(m.getString("method"))).findFirst().orElse(null);
+ }
+
+ long receivedCount(String method) {
+ return received.stream().filter(m -> method.equals(m.getString("method"))).count();
+ }
+
+ /** Every message the client sent with this method, in arrival order. */
+ List<JsonObject> received(String method) {
+ return received.stream().filter(m -> method.equals(m.getString("method"))).toList();
+ }
+
+ /** Closes the agent side of both pipes: the client sees EOF, like a crashed process. */
+ @Override
+ public void close() {
+ try {
+ agentOut.close();
+ } catch (IOException ignored) {
+ }
+ try {
+ agentIn.close();
+ } catch (IOException ignored) {
+ }
+ handlerExecutor.shutdownNow();
+ }
+
+ private void loop() {
+ try {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ JsonObject msg = Jsoner.deserialize(line, (JsonObject) null);
+ if (msg == null) {
+ continue;
+ }
+ received.add(msg);
+ String method = msg.getString("method");
+ Object id = msg.get("id");
+ if (method != null && id != null) {
+ if (handlerExecutor.isShutdown()) {
+ continue; // closed while a line was still buffered
+ }
+ handlerExecutor.execute(() -> respond(id, method, msg.getJsonObject("params")));
+ } else if (method == null && id != null) {
+ CompletableFuture<JsonObject> f = pendingAgentRequests.remove(((Number) id).longValue());
+ if (f != null) {
+ f.complete(msg);
+ }
+ }
+ }
+ } catch (IOException ignored) {
+ // pipe closed
+ } finally {
+ clientClosed.countDown();
+ }
+ }
+
+ private void respond(Object id, String method, JsonObject params) {
+ JsonObject response = new JsonObject();
+ response.put("jsonrpc", "2.0");
+ response.put("id", id);
+ JsonObject error = errors.remove(method);
+ if (error != null) {
+ response.put("error", error);
+ } else {
+ Function<JsonObject, JsonObject> handler = handlers.get(method);
+ if (handler == null) {
+ JsonObject notFound = new JsonObject();
+ notFound.put("code", -32601);
+ notFound.put("message", "Method not found: " + method);
+ response.put("error", notFound);
+ } else {
+ response.put("result", handler.apply(params != null ? params : new JsonObject()));
+ }
+ }
+ try {
+ write(response);
+ } catch (IllegalStateException e) {
+ // the client closed the pipe mid-flight; nothing to answer
+ }
+ }
+
+ private void write(JsonObject msg) {
+ sendRaw(Jsoner.serialize(msg));
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java
index 92730c0e98ab..9386952c9c36 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java
@@ -22,6 +22,9 @@ import java.util.List;
import dev.tamboui.buffer.Buffer;
import dev.tamboui.layout.Rect;
import dev.tamboui.terminal.Frame;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -29,6 +32,8 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.parallel.Isolated;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -67,7 +72,7 @@ class SettingsPopupRenderTest {
new TabRegistry.TabEntry("🩺", "Health", "health", "7", 6, -1)));
popup.open();
- Rect area = new Rect(0, 0, 80, 25);
+ Rect area = new Rect(0, 0, 80, 28);
Buffer buffer = Buffer.empty(area);
Frame frame = Frame.forTesting(buffer);
popup.render(frame, area);
@@ -84,5 +89,37 @@ class SettingsPopupRenderTest {
assertTrue(rendered.contains("AI Base URL"), "the AI Base URL row should be shown");
assertTrue(rendered.contains("Shell History"), "the Shell History row should be shown");
assertTrue(rendered.contains("AI History"), "the AI History row should be shown");
+ assertTrue(rendered.contains("ACP Command"), "the ACP Command row should be shown");
+ }
+
+ @Test
+ void lineOfAccountsForEveryRowAndDivider() {
+ assertEquals(SettingsPopup.ROW_COUNT + SettingsPopup.DIVIDERS,
+ SettingsPopup.lineOf(SettingsPopup.ROW_COUNT - 1) + 1);
+ }
+
+ @Test
+ void scrollsTheSelectedRowIntoViewOnAShortTerminal(@TempDir Path tempDir) {
+ useHome(tempDir);
+ SettingsPopup popup = new SettingsPopup();
+ popup.setTabEntries(List.of(
+ new TabRegistry.TabEntry("🐪", "Overview", "overview", "1", 0, -1),
+ new TabRegistry.TabEntry("🩺", "Health", "health", "7", 6, -1)));
+ popup.open();
+ Rect area = new Rect(0, 0, 80, 25);
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+ String rendered = TuiTestHelper.bufferToString(buffer);
+ assertTrue(rendered.contains("Theme:"), rendered);
+ assertFalse(rendered.contains("ACP Command"), "the last rows do not fit in 25 lines");
+ for (int i = 0; i < 18; i++) {
+ popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ }
+ buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area);
+ rendered = TuiTestHelper.bufferToString(buffer);
+ assertTrue(rendered.contains("ACP Command"), rendered);
+ assertTrue(rendered.contains("AI History"), rendered);
+ assertFalse(rendered.contains("Theme:"), "the first rows scrolled out");
}
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupTest.java
index 7dc071e4d1c4..c27c09d76d91 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupTest.java
@@ -394,6 +394,15 @@ class SettingsPopupTest {
assertTrue(persisted.isPanelOverlay());
}
+ @Test
+ void aiProviderDropdownOffersAcpAgents() {
+ SettingsPopup popup = new SettingsPopup();
+ popup.open();
+ assertTrue(popup.aiProviderOptionsForTesting().contains("acp:claude"));
+ assertTrue(popup.aiProviderOptionsForTesting().contains("acp:custom"));
+ assertEquals("auto", popup.aiProviderOptionsForTesting().get(popup.aiProviderOptionsForTesting().size() - 1));
+ }
+
@Test
void panelRowsDefaultValuesAreNotPersisted(@TempDir Path tempDir) {
useHome(tempDir);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerPortTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerPortTest.java
new file mode 100644
index 000000000000..00990ae8643a
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerPortTest.java
@@ -0,0 +1,35 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TuiMcpServerPortTest {
+
+ @Test
+ void ephemeralPortIsReportedAfterStart() throws Exception {
+ TuiMcpServer server = new TuiMcpServer(0, null);
+ try {
+ server.start();
+ assertTrue(server.getPort() > 0, "port 0 must be replaced by the bound ephemeral port");
+ } finally {
+ server.stop();
+ }
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java
index 00ba4ad323fe..55f3442046ed 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java
@@ -64,6 +64,7 @@ class TuiSettingsTest {
settings.setAiModel("gemini-3.5-flash");
settings.setAiUrl("https://generativelanguage.googleapis.com");
settings.setAiTools("core");
+ settings.setAiAcpCommand("npx -y pi-acp");
settings.setShellHistory("25");
settings.setAiPromptHistory("50");
settings.setPanelPosition("top");
@@ -78,6 +79,7 @@ class TuiSettingsTest {
assertThat(loaded.getAiModel()).isEqualTo("gemini-3.5-flash");
assertThat(loaded.getAiUrl()).isEqualTo("https://generativelanguage.googleapis.com");
assertThat(loaded.getAiTools()).isEqualTo("core");
+ assertThat(loaded.getAiAcpCommand()).isEqualTo("npx -y pi-acp");
assertThat(loaded.getShellHistory()).isEqualTo("25");
assertThat(loaded.getAiPromptHistory()).isEqualTo("50");
assertThat(loaded.getPanelPosition()).isEqualTo("top");
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java
index b38d749ce48f..ec79299aa682 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistryCoreToolsTest.java
@@ -58,4 +58,23 @@ class TuiToolRegistryCoreToolsTest {
assertFalse(TuiToolRegistry.CORE_TOOLS.stream().anyMatch(automation::contains));
}
+
+ @Test
+ void readOnlyToolsAreRegisteredAndCoverEveryGetter() {
+ TuiToolRegistry registry = new TuiToolRegistry(null);
+ Set<String> all = registry.getToolDefinitions().stream()
+ .map(TuiToolRegistry.ToolDef::name).collect(Collectors.toSet());
+
+ assertTrue(all.containsAll(TuiToolRegistry.READ_ONLY_TOOLS),
+ "read-only tools missing from registry: " + TuiToolRegistry.READ_ONLY_TOOLS.stream()
+ .filter(name -> !all.contains(name)).toList());
+ List<String> getters = all.stream().filter(name -> name.startsWith("tui_get_")).sorted().toList();
+ assertTrue(TuiToolRegistry.READ_ONLY_TOOLS.containsAll(getters),
+ "a new getter must be classified deliberately: " + getters.stream()
+ .filter(name -> !TuiToolRegistry.READ_ONLY_TOOLS.contains(name)).toList());
+ for (String mutating : List.of("tui_control", "tui_execute_sql", "tui_send_message", "tui_write_file",
+ "tui_infra")) {
+ assertFalse(TuiToolRegistry.READ_ONLY_TOOLS.contains(mutating), mutating + " changes something");
+ }
+ }
}