From fd3f32ddd3c64218e70576f39b7b2648f680d861 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 12:51:05 +0200 Subject: [PATCH 01/17] CAMEL-24663: Camel TUI: ACP coding-agent provider for the AI panel (prototype) Adds an Agent Client Protocol backend to the F8 AI panel: a hand-rolled ACP v1 client over stdio, agent presets (Claude Code, Codex, IBM Bob, Qwen Code, OpenCode, DeepSeek Harness) plus a custom command, on-demand start of the embedded MCP server handed to the agent in session/new, streamed answers and tool lines, a permission popup (camel-tui tools auto-approved), agent slash commands as /agent:, a header strip with the agent logo (kitty) or a glyph, settings rows, tests and user-manual docs. Squashed from 30 commits kept on branch tui-acp-prototype-backup. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 55 ++ .../core/commands/tui/AcpAgentClient.java | 629 ++++++++++++++++ .../core/commands/tui/AcpHeaderStrip.java | 357 +++++++++ .../core/commands/tui/AcpPermissionPopup.java | 204 ++++++ .../dsl/jbang/core/commands/tui/AiPanel.java | 575 ++++++++++++++- .../core/commands/tui/AiProviderSelector.java | 148 +++- .../commands/tui/AiProviderSwitchPopup.java | 3 + .../commands/tui/AiSlashCommandRegistry.java | 32 + .../jbang/core/commands/tui/CamelMonitor.java | 35 +- .../core/commands/tui/SettingsPopup.java | 54 +- .../dsl/jbang/core/commands/tui/TuiIcons.java | 2 + .../jbang/core/commands/tui/TuiMcpServer.java | 9 +- .../jbang/core/commands/tui/TuiSettings.java | 24 + .../src/main/resources/tui/logos/bob.png | Bin 0 -> 10743 bytes .../src/main/resources/tui/logos/claude.png | Bin 0 -> 14702 bytes .../src/main/resources/tui/logos/codex.png | Bin 0 -> 9537 bytes .../src/main/resources/tui/logos/dsh.png | Bin 0 -> 8994 bytes .../src/main/resources/tui/logos/opencode.png | Bin 0 -> 918 bytes .../src/main/resources/tui/logos/qwen.png | Bin 0 -> 14480 bytes .../core/commands/tui/AcpAgentClientTest.java | 446 ++++++++++++ .../core/commands/tui/AcpHeaderStripTest.java | 178 +++++ .../commands/tui/AcpPermissionPopupTest.java | 146 ++++ .../core/commands/tui/AiPanelAcpTest.java | 687 ++++++++++++++++++ .../commands/tui/AiProviderSelectorTest.java | 74 +- .../tui/AiProviderSwitchPopupTest.java | 8 + .../tui/AiSlashCommandRegistryTest.java | 33 + .../jbang/core/commands/tui/FakeAcpAgent.java | 267 +++++++ .../commands/tui/SettingsPopupRenderTest.java | 4 +- .../core/commands/tui/SettingsPopupTest.java | 31 + .../commands/tui/TuiMcpServerPortTest.java | 35 + .../core/commands/tui/TuiSettingsTest.java | 4 + 31 files changed, 4010 insertions(+), 30 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClient.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStrip.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopup.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/bob.png create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/claude.png create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/codex.png create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/dsh.png create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/opencode.png create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/qwen.png create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpAgentClientTest.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpHeaderStripTest.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AcpPermissionPopupTest.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/FakeAcpAgent.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServerPortTest.java 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 7ef06c41ef641..19188832729e3 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,8 @@ 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`), and +`camel.tui.ai.acp.logos` (`auto`, `on`, `off`) controls the agent logos in the panel header. ==== Using Ollama (local, no API key) @@ -1018,6 +1020,59 @@ 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 18 or newer. 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 logo (in terminals with Kitty, iTerm2 or Sixel graphics, for example Kitty, Ghostty, WezTerm, iTerm2) or a +coloured glyph, the agent's name and version, the session id, the working directory and the number of commands it +advertises. `camel.tui.ai.acp.logos` (`auto`, `on`, `off`) forces or disables the logos; `auto` detects the terminal +from its environment variables. 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 own tools are approved without asking. 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:` in the `/` completion +hints once the session is open; `/agent:` alone lists them. `/agent: …` 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. + +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 0000000000000..fd1d9b02be032 --- /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,629 @@ +/* + * 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; + + /** 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 options); + } + + record AuthMethod(String id, String name, String description, String type) { + } + + record AgentInfo(int protocolVersion, String name, String version, boolean httpMcp, List 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) { + } + + 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 diagnostics; + private final Deque stderrTail = new ArrayDeque<>(); + private final Map> 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 availableCommands = List.of(); + private volatile String currentSession; + + AcpAgentClient(InputStream fromAgent, OutputStream toAgent, Consumer diagnostics) { + this(fromAgent, toAgent, null, diagnostics); + } + + private AcpAgentClient(InputStream fromAgent, OutputStream toAgent, Process process, Consumer 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 command, Path cwd, Consumer 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 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 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); + 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; + 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) { + this.listenerSession = sessionId; + this.listener = listener; + try { + 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); + JsonObject result = request("session/prompt", params, null); + return result.getStringOrDefault("stopReason", "end_turn"); + } catch (AcpException e) { + if (e.code() == INTERRUPTED) { + cancel(sessionId); + return "cancelled"; + } + throw e; + } finally { + this.listener = null; + this.listenerSession = 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) { + long id = nextId.getAndIncrement(); + CompletableFuture 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; + } + 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(id); + throw new AcpException(TIMEOUT, method + " timed out after " + timeout.toSeconds() + "s"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + pending.remove(id); + throw new AcpException(INTERRUPTED, method + " interrupted"); + } + } + + void notify(String method, JsonObject params) { + JsonObject msg = new JsonObject(); + msg.put("jsonrpc", "2.0"); + msg.put("method", method); + if (params != null) { + msg.put("params", params); + } + send(msg); + } + + private void send(JsonObject msg) { + String line = Jsoner.serialize(msg); + synchronized (writer) { + try { + writer.write(line); + writer.write('\n'); + writer.flush(); + } catch (IOException e) { + throw new AcpException(CONNECTION, "Cannot write to agent: " + e.getMessage()); + } + } + } + + private void readLoop() { + try { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + JsonObject msg = Jsoner.deserialize(line, (JsonObject) null); + if (msg == null || !msg.containsKey("jsonrpc")) { + diagnostics.accept("Ignoring malformed line from agent: " + abbreviate(line)); + continue; + } + try { + dispatch(msg); + } catch (RuntimeException e) { + diagnostics.accept("Cannot handle message from agent: " + e); + } + } + } catch (IOException e) { + if (!closed) { + diagnostics.accept("Agent stream error: " + e.getMessage()); + } + } finally { + onStreamClosed(); + } + } + + private void dispatch(JsonObject msg) { + Object id = msg.get("id"); + String method = msg.getString("method"); + if (method == null && id != null) { + if (!(id instanceof Number number)) { + return; + } + CompletableFuture 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 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; + } + List 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 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 0000000000000..5b4a2885a41fb --- /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,357 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.image.Image; +import dev.tamboui.image.ImageData; +import dev.tamboui.image.ImageScaling; +import dev.tamboui.image.capability.TerminalImageCapabilities; +import dev.tamboui.image.capability.TerminalImageProtocol; +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.terminal.FrameInternal; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.widget.RawOutputCapable; +import dev.tamboui.widget.Widget; +import dev.tamboui.widgets.paragraph.Paragraph; + +/** + * Two-row header shown under the AI panel title while an ACP session is open: the agent's logo (native terminal + * graphics only) or a coloured glyph, the preset and agent labels, and a dimmed line with session, working directory + * and command count. ACP agents run headless and never draw a start screen of their own. + */ +final class AcpHeaderStrip { + + static final int ROWS = 2; + private static final int LOGO_COLUMNS = 5; + private static final int LOGO_PIXELS = 64; + private static final long RESEND_INTERVAL_MS = 2_000; + private static final String APC = "\033_G"; + private static final String ST = "\033\\"; + private static final int KITTY_CHUNK = 4096; + private static final int KITTY_ID_BASE = 0x43_4D_00; + + enum LogoMode { + AUTO, + ON, + OFF; + + static LogoMode parse(String value) { + if (value == null) { + return AUTO; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return AUTO; + } + } + } + + record Model(String presetLabel, String glyph, Color color, String logo, String agentLabel, String sessionId, + Path cwd, int commandCount) { + } + + private final TerminalImageCapabilities capabilities; + private final Map logos = new HashMap<>(); + private final Map logoBytes = new HashMap<>(); + private final Set uploaded = new HashSet<>(); + private Rect lastLogoRect; + private long lastLogoSentAt; + private int placedImageId; + + AcpHeaderStrip() { + this(TerminalImageCapabilities.detect()); + } + + AcpHeaderStrip(TerminalImageCapabilities capabilities) { + this.capabilities = capabilities; + } + + boolean logosEnabled(LogoMode mode) { + return switch (mode) { + case ON -> true; + case OFF -> false; + default -> capabilities.supportsNativeImages(); + }; + } + + /** The preset's logo scaled to a small square, cached; null when there is no such resource or it cannot be read. */ + ImageData logoFor(String logo) { + if (logo == null) { + return null; + } + return logos.computeIfAbsent(logo, name -> { + try (InputStream in = AcpHeaderStrip.class.getResourceAsStream("/tui/logos/" + name + ".png")) { + if (in == null) { + return null; + } + return ImageData.fromBytes(in.readAllBytes()).resize(LOGO_PIXELS, LOGO_PIXELS); + } catch (IOException | RuntimeException e) { + return null; + } + }); + } + + /** Raw PNG bytes of the preset logo (cached; null when missing or unreadable). Kitty scales them itself. */ + byte[] logoBytes(String logo) { + if (logo == null) { + return null; + } + return logoBytes.computeIfAbsent(logo, name -> { + try (InputStream in = AcpHeaderStrip.class.getResourceAsStream("/tui/logos/" + name + ".png")) { + return in != null ? in.readAllBytes() : null; + } catch (IOException e) { + return null; + } + }); + } + + void render(Frame frame, Rect area, Model model, LogoMode mode) { + render(frame, area, model, mode, null); + } + + void renderForTesting(Rect area, Buffer buffer, OutputStream raw, Model model, LogoMode mode) { + render(Frame.forTesting(buffer), area, model, mode, raw); + } + + private void render(Frame frame, Rect area, Model model, LogoMode mode, OutputStream rawOverride) { + if (area.height() < ROWS || area.width() < 20) { + return; + } + boolean enabled = logosEnabled(mode); + boolean kitty = capabilities.supports(TerminalImageProtocol.KITTY); + byte[] png = enabled && kitty ? logoBytes(model.logo()) : null; + ImageData logo = enabled && !kitty ? logoFor(model.logo()) : null; + boolean hasLogo = png != null || logo != null; + Rect textArea = area; + if (hasLogo) { + List parts = Layout.horizontal() + .constraints(Constraint.length(LOGO_COLUMNS), Constraint.length(1), Constraint.fill()) + .split(area); + if (png != null) { + renderKittyLogo(frame, parts.get(0), kittyImageId(model.logo()), png, rawOverride); + } else { + renderLogo(frame, parts.get(0), logo); + } + textArea = parts.get(2); + } else { + lastLogoRect = null; + } + Style accent = Style.EMPTY.fg(model.color()).bold(); + String title = model.presetLabel() + " · " + model.agentLabel(); + Line first = hasLogo + ? Line.from(Span.styled(title, accent)) + : Line.from(Span.styled(model.glyph() + " ", accent), Span.styled(title, accent)); + Line second = Line.from(Span.styled((hasLogo ? "" : " ") + metaLine(model), Style.EMPTY.dim())); + frame.renderWidget(Paragraph.from(first), new Rect(textArea.x(), textArea.y(), textArea.width(), 1)); + frame.renderWidget(Paragraph.from(second), new Rect(textArea.x(), textArea.y() + 1, textArea.width(), 1)); + } + + /** + * Native protocols re-transmit the picture on every render, so the logo is sent only when its cell rectangle + * changed or every RESEND_INTERVAL_MS, which keeps it visible after the terminal repaints without flooding it. + */ + private void renderLogo(Frame frame, Rect logoArea, ImageData logo) { + long now = System.currentTimeMillis(); + if (logoArea.equals(lastLogoRect) && now - lastLogoSentAt < RESEND_INTERVAL_MS) { + return; + } + Image image = Image.builder() + .data(logo) + .scaling(ImageScaling.FIT) + .protocol(capabilities.bestProtocol()) + .build(); + frame.renderWidget(image, logoArea); + lastLogoRect = logoArea; + lastLogoSentAt = now; + } + + 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; + } + + /** + * Kitty keeps the uploaded picture, so a frame only costs a placement command: no re-upload, no flash, and q=2 + * stops the terminal from answering on the input stream the TUI reads keys from. + */ + private void renderKittyLogo(Frame frame, Rect logoArea, int imageId, byte[] png, OutputStream rawOverride) { + KittyLogoWidget widget = new KittyLogoWidget(imageId, png); + if (rawOverride != null) { + widget.render(logoArea, frame.buffer(), rawOverride); + } else { + frame.renderWidget(widget, logoArea); + } + lastLogoRect = logoArea; + } + + /** + * Drops the kitty placement when the header stops being drawn; a no-op for every other terminal. Written straight + * to the raw stream rather than through a widget: a widget would register its area with the frame, and TamboUI + * would then blank that area with a space on the next frame that renders no raw output. + */ + void hide(Frame frame) { + hide(FrameInternal.rawOutput(frame)); + } + + void hideForTesting(OutputStream raw) { + hide(raw); + } + + private void hide(OutputStream rawOutput) { + if (rawOutput == null || placedImageId == 0) { + return; + } + try { + rawOutput.write(kittyDelete(placedImageId).getBytes(StandardCharsets.US_ASCII)); + rawOutput.flush(); + } catch (IOException e) { + // the terminal is gone, there is nothing left to clean up + } + placedImageId = 0; + lastLogoRect = null; + } + + /** + * A stable, positive kitty image id per logo name, offset from a "CM" base so it does not clash with the ids of + * other programs sharing the terminal. The range is 16 bits wide: 8 would put claude and codex on the same id. + */ + static int kittyImageId(String logo) { + return KITTY_ID_BASE + Math.floorMod(logo.hashCode(), 0xFFFF) + 1; + } + + /** Transmits the PNG under an image id without displaying it, in chunks of at most KITTY_CHUNK base64 bytes. */ + static String kittyTransmit(int imageId, byte[] png) { + String data = Base64.getEncoder().encodeToString(png); + StringBuilder sb = new StringBuilder(); + for (int offset = 0, n = 0; offset < data.length() || n == 0; n++) { + int end = Math.min(offset + KITTY_CHUNK, data.length()); + boolean more = end < data.length(); + sb.append(APC); + if (n == 0) { + sb.append("a=t,f=100,t=d,i=").append(imageId).append(",q=2,m=").append(more ? 1 : 0).append(';'); + } else { + sb.append("m=").append(more ? 1 : 0).append(';'); + } + sb.append(data, offset, end).append(ST); + offset = end; + if (!more) { + break; + } + } + return sb.toString(); + } + + /** + * Places the already transmitted image in the given cell box; placement id 1 replaces the previous placement and + * C=1 keeps kitty from moving the cursor afterwards. + */ + static String kittyPlace(int imageId, Rect area) { + return "\033[" + (area.y() + 1) + ";" + (area.x() + 1) + "H" + + APC + "a=p,i=" + imageId + ",p=1,c=" + area.width() + ",r=" + area.height() + ",C=1,q=2" + ST; + } + + /** Deletes the placements of an image; the transmitted data stays, so the next frame only places it again. */ + static String kittyDelete(int imageId) { + return APC + "a=d,d=i,i=" + imageId + ",q=2" + ST; + } + + Rect lastLogoRectForTesting() { + return lastLogoRect; + } + + boolean hasPlacementForTesting() { + return placedImageId != 0; + } + + /** + * Uploads the logo once per image id and then only places it. A widget because that is how TamboUI hands out the + * terminal's raw stream; it draws nothing into the buffer. + */ + private final class KittyLogoWidget implements Widget, RawOutputCapable { + + private final int imageId; + private final byte[] png; + + KittyLogoWidget(int imageId, byte[] png) { + this.imageId = imageId; + this.png = png; + } + + @Override + public void render(Rect area, Buffer buffer) { + render(area, buffer, null); + } + + @Override + public void render(Rect area, Buffer buffer, OutputStream rawOutput) { + if (rawOutput == null) { + return; + } + boolean fresh = uploaded.add(imageId); + try { + if (fresh) { + rawOutput.write(kittyTransmit(imageId, png).getBytes(StandardCharsets.US_ASCII)); + } + rawOutput.write(kittyPlace(imageId, area).getBytes(StandardCharsets.US_ASCII)); + rawOutput.flush(); + placedImageId = imageId; + } catch (IOException e) { + uploaded.remove(imageId); + } + } + } + +} 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 0000000000000..bf7e93b4c23e2 --- /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,204 @@ +/* + * 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 inputLines = List.of(); + private List 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 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); + List parts = Layout.vertical() + .constraints(Constraint.length(Math.min(headerRows, inner.height())), Constraint.fill()) + .split(inner); + + List 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 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 spans) { + TuiHelper.hint(spans, "Enter", "select"); + TuiHelper.hint(spans, "Ctrl+C", "cancel turn"); + TuiHelper.hintLast(spans, "Esc", "reject"); + } + + private static List formatInput(Object rawInput) { + if (rawInput == null) { + return List.of(); + } + String[] lines = Jsoner.prettyPrint(Jsoner.serialize(rawInput)).split("\n"); + List 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 aa5087c567d11..0c03491516b23 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 @@ 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,10 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; +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; @@ -250,6 +254,32 @@ public void printf(String format, Object... args) { private List 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 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 volatile AcpHeaderStrip.LogoMode acpLogoMode = AcpHeaderStrip.LogoMode.AUTO; + private AcpHeaderStrip acpHeader = new AcpHeaderStrip(); + private String acpMcpUrl; + private Path acpCwd; + private AcpClientFactory acpClientFactory = this::spawnAcpAgent; + private Callable mcpUrlSupplier; + private final AcpPermissionPopup permissionPopup = new AcpPermissionPopup(); + private volatile CompletableFuture 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 +487,27 @@ void addPendingNote(String note) { 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); + acpLogoMode = AcpHeaderStrip.LogoMode.parse(settings.getAiAcpLogos()); + 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 +542,27 @@ private void initClient() { 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); + acpLogoMode = AcpHeaderStrip.LogoMode.parse(settings.getAiAcpLogos()); + } 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 +581,30 @@ private void applyProviderChoice(AiProviderSwitchPopup.ProviderChoice choice) { } } + private void closeAcpClient() { + AcpAgentClient agent = acpClient; + acpClient = null; + acpSessionId = null; + acpAgentInfo = null; + acpPreambleSent = false; + 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 +643,15 @@ boolean handleMouseEvent(MouseEvent me) { copyChoice(copyPopup.consumePendingChoice()); return handled; } + if (permissionPopup.isVisible()) { + boolean handled = permissionPopup.handleMouseEvent(me); + AcpPermissionPopup.Decision decision = permissionPopup.consumeDecision(); + CompletableFuture 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 +667,21 @@ boolean handleMouseEvent(MouseEvent me) { } 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 pending = pendingPermission; + if (pending != null) { + pending.complete(decision.optionId()); + } + } + return true; + } if (providerSwitchPopup.isVisible()) { providerSwitchPopup.handleKeyEvent(ke); AiProviderSwitchPopup.ProviderChoice choice = providerSwitchPopup.consumePendingChoice(); @@ -916,7 +1031,7 @@ private void handleTabCompletion(boolean backward) { 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 +1199,17 @@ private void replaceInputBuffer(String text) { } 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 parsed = slashCommands.parse(input); if (parsed.isPresent() && (thinking.get() || activeCliCommand != null)) { String name = parsed.get().descriptor().name(); @@ -1096,6 +1222,12 @@ private void executeSlashCommand(String input) { } } + 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 +1303,20 @@ private void interruptBusyOperation() { conversation.add(new ConversationEntry(AiRole.SYSTEM, "(command cancelled)")); } if (thinking.get()) { + CompletableFuture 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 +1347,10 @@ private void awaitAgentThreadStop(Thread agent) { private void submitQuestion(String question) { stopAgentThread(); + if (acpPreset != null) { + submitAcpQuestion(question); + return; + } if (client == null) { conversation.add(new ConversationEntry( AiRole.ERROR, @@ -1451,6 +1601,340 @@ private static String summarize(String text, int max) { return flat.length() <= max ? flat : flat.substring(0, max) + "..."; } + private void submitAcpQuestion(String question) { + conversation.add(new ConversationEntry(AiRole.USER, question)); + log(LogLevel.QUESTION, "Question", question); + thinkingVerb = THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size())); + thinkingStartTime = System.currentTimeMillis(); + thinking.set(true); + // The panel keeps showing exactly what was typed (including an explicit /agent: escape), but the agent + // itself only ever sees its own command name: /agent:clear reaches it as /clear. + String wireQuestion = question.startsWith(AGENT_COMMAND_PREFIX) + ? "/" + question.substring(AGENT_COMMAND_PREFIX.length()) + : question; + agentThread = new Thread(() -> { + try { + runAcpTurn(wireQuestion); + } catch (IOException | RuntimeException e) { + reportAcpTurnFailure(e); + } finally { + if (agentThread == Thread.currentThread()) { + thinking.set(false); + agentThread = null; + } + } + }, "tui-ai-agent"); + agentThread.setDaemon(true); + agentThread.start(); + } + + /** + * Reports a failed ACP turn. The agent process and its session are only thrown away when the failure means they are + * gone anyway, so a JSON-RPC error from a healthy agent does not cost the user the conversation context. An + * interrupted turn is already reported as "(cancelled)" by {@link #interruptBusyOperation()}. + */ + private void reportAcpTurnFailure(Exception e) { + AcpAgentClient.AcpException acp = e instanceof AcpAgentClient.AcpException a ? a : null; + if (acp != null && acp.code() == AcpAgentClient.INTERRUPTED) { + return; + } + String message = e.getMessage() != null ? e.getMessage() : e.toString(); + conversation.add(new ConversationEntry(AiRole.ERROR, message)); + log(LogLevel.ERROR, "ACP error", message); + AcpAgentClient agent = acpClient; + boolean dead = agent == null || !agent.isAlive() + || (acp != null && (acp.code() == AcpAgentClient.CONNECTION || acp.code() == AcpAgentClient.TIMEOUT)); + if (dead) { + closeAcpClient(); + } + } + + private void runAcpTurn(String question) throws IOException { + AcpAgentClient agent = ensureAcpSession(); + boolean agentCommand = question.startsWith("/"); + String text = acpPreambleSent || agentCommand ? question : buildSystemPrompt() + "\n\n" + question; + AcpTurnListener listener = new AcpTurnListener(); + String stopReason = agent.prompt(acpSessionId, text, listener); + if (!agentCommand) { + acpPreambleSent = true; + } + listener.finish(stopReason); + scrollOffset = 0; + } + + /** + * The ACP agent's advertised commands as display-only descriptors (null executor: they are forwarded, never run + * locally). Displayed and completed under the {@code agent:} name so they never collide with a panel command of the + * same name; the bare name is registered as an alias so typing it still suggests the prefixed form. + */ + private List agentCommandDescriptors() { + AcpAgentClient agent = acpClient; + if (acpPreset == null || agent == null) { + return List.of(); + } + List 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 commands = agentCommandDescriptors(); + if (commands.isEmpty()) { + return "The agent has not advertised any commands yet."; + } + List sorted = commands.stream() + .sorted(Comparator.comparing(AiSlashCommandRegistry.Descriptor::name)) + .toList(); + int width = AiSlashCommandRegistry.commandColumnWidth(sorted); + StringBuilder sb = new StringBuilder("Agent commands (").append(sorted.size()).append(")\n\n```\n"); + for (AiSlashCommandRegistry.Descriptor descriptor : sorted) { + sb.append(AiSlashCommandRegistry.formatAlignedLine(descriptor, width)).append('\n'); + } + return sb.append("```").toString(); + } + + /** + * Spawns the agent and negotiates the protocol on first use, then opens a session when none is active (first + * prompt, or after /clear). Runs on the agent thread; every failure is reported by the caller. + */ + private AcpAgentClient ensureAcpSession() throws IOException { + AiProviderSelector.AcpPreset preset = acpPreset; + AcpAgentClient agent = acpClient; + if (agent == null || !agent.isAlive()) { + closeAcpClient(); + String mcpUrl; + try { + mcpUrl = mcpUrlSupplier != null ? mcpUrlSupplier.call() : null; + } catch (Exception e) { + throw new IllegalStateException("Could not start the TUI MCP server: " + e.getMessage(), e); + } + if (mcpUrl == null) { + throw new IllegalStateException("The TUI MCP server is not available in this session."); + } + Path cwd = acpWorkingDir(); + conversation.add(new ConversationEntry(AiRole.SYSTEM, "Starting " + preset.label() + "…")); + agent = acpClientFactory.create(preset, cwd); + agent.setPermissionHandler(new AcpPanelPermissionHandler()); + AcpAgentClient.AgentInfo info; + try { + info = agent.initialize(ACP_INIT_TIMEOUT); + if (info.protocolVersion() != AcpAgentClient.PROTOCOL_VERSION) { + String mismatch = preset.label() + " negotiated ACP protocol version " + info.protocolVersion() + + "; the TUI supports version " + AcpAgentClient.PROTOCOL_VERSION + "."; + throw new IllegalStateException(mismatch); + } + if (!info.httpMcp()) { + throw new IllegalStateException( + preset.label() + " does not support HTTP MCP servers, so it cannot reach the TUI tools."); + } + } catch (RuntimeException e) { + agent.close(); + throw e; + } + acpAgentInfo = info; + // written before the volatile acpClient write below, which publishes them to the other threads + acpMcpUrl = mcpUrl; + acpCwd = cwd; + acpClient = agent; + acpSessionId = null; + log(LogLevel.RESULT, "ACP agent started", info.label() + " cwd=" + cwd); + } + if (acpSessionId == null) { + acpSessionId = openAcpSession(agent, acpAgentInfo, acpMcpUrl, acpCwd); + acpPreambleSent = false; + log(LogLevel.RESULT, "ACP session", acpSessionId); + } + return agent; + } + + private String openAcpSession(AcpAgentClient agent, AcpAgentClient.AgentInfo info, String mcpUrl, Path cwd) { + try { + return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT); + } catch (AcpAgentClient.AcpException e) { + if (e.code() != AcpAgentClient.AUTH_REQUIRED) { + throw e; + } + AcpAgentClient.AuthMethod method = info.authMethods().stream() + .filter(m -> m.id() != null && (m.type() == null || "agent".equals(m.type()))) + .findFirst() + .orElse(null); + if (method == null) { + throw new IllegalStateException("Authentication required. " + acpPreset.loginHint()); + } + conversation.add(new ConversationEntry( + AiRole.SYSTEM, "Authenticating with " + info.label() + " (" + method.name() + ")…")); + try { + agent.authenticate(method.id(), ACP_AUTH_TIMEOUT); + return agent.newSession(cwd, mcpUrl, ACP_SESSION_TIMEOUT); + } catch (AcpAgentClient.AcpException retry) { + throw new IllegalStateException( + "Authentication failed: " + retry.getMessage() + ". " + acpPreset.loginHint()); + } + } + } + + private AcpHeaderStrip.Model acpHeaderModel() { + AiProviderSelector.AcpPreset preset = acpPreset; + AcpAgentClient agent = acpClient; + int commands = agent != null ? agent.availableCommands().size() : 0; + return new AcpHeaderStrip.Model( + preset.label(), preset.glyph(), preset.color(), preset.logo(), acpLabel(), + acpSessionId, acpCwd, commands); + } + + private Path acpWorkingDir() { + IntegrationInfo info = ctx != null ? ctx.findSelectedIntegration() : null; + if (info != null && info.configProperties != null) { + Path dir = FilesBrowser.resolveSourceDirectory(info); + if (dir != null && Files.isDirectory(dir)) { + return dir.toAbsolutePath(); + } + } + return Path.of("").toAbsolutePath(); + } + + private int replaceOrAppend(int index, ConversationEntry entry) { + if (index >= 0 && index < conversation.size()) { + conversation.set(index, entry); + return index; + } + conversation.add(entry); + return conversation.size() - 1; + } + + /** + * Turns agent updates into conversation entries. The callbacks run on the ACP reader thread while + * {@link #finish(String)} runs on the agent thread, and cancelling a turn returns from + * {@link AcpAgentClient#prompt} without waiting for the reader, so every method is synchronized on the listener. + */ + private final class AcpTurnListener implements AcpAgentClient.Listener { + private final StringBuilder text = new StringBuilder(); + private int liveIndex = -1; + private final Map toolLines = new HashMap<>(); + private final Map toolTitles = new HashMap<>(); + private volatile long usedTokens; + + @Override + public synchronized void onTextChunk(String chunk) { + text.append(chunk); + liveIndex = replaceOrAppend(liveIndex, new ConversationEntry(AiRole.ASSISTANT, text.toString())); + scrollOffset = 0; + } + + @Override + public synchronized void onToolCall(String toolCallId, String title, String kind, JsonObject rawInput) { + conversation.add(new ConversationEntry(AiRole.SYSTEM, TuiIcons.GEAR + " " + title)); + if (toolCallId != null) { + toolLines.put(toolCallId, conversation.size() - 1); + toolTitles.put(toolCallId, title); + } + // text after a tool call starts a new assistant entry below the tool line + liveIndex = -1; + text.setLength(0); + log(LogLevel.TOOL, title, rawInput != null ? rawInput.toJson() : ""); + } + + @Override + public synchronized void onToolCallUpdate(String toolCallId, String status, String contentText) { + Integer index = toolCallId != null ? toolLines.get(toolCallId) : null; + if (index == null) { + return; + } + String title = toolTitles.getOrDefault(toolCallId, "tool"); + if ("completed".equals(status)) { + replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CHECK + " " + title)); + log(LogLevel.RESULT, title, contentText != null ? contentText : "completed"); + } else if ("failed".equals(status)) { + replaceOrAppend(index, new ConversationEntry(AiRole.SYSTEM, TuiIcons.CROSS + " " + title)); + log(LogLevel.ERROR, title, contentText != null ? contentText : "failed"); + } + } + + @Override + public synchronized void onUsage(long used, long size) { + usedTokens = used; + } + + synchronized void finish(String stopReason) { + long elapsed = System.currentTimeMillis() - thinkingStartTime; + int tokens = (int) Math.min(Integer.MAX_VALUE, usedTokens); + if (liveIndex >= 0) { + // the agent runs its own tools, so ACP reports no ai/tool split to fill in + replaceOrAppend(liveIndex, + new ConversationEntry(AiRole.ASSISTANT, text.toString(), elapsed, 0, 0, 0, tokens)); + } + if (tokens > 0) { + AiProviderSelector.AcpPreset preset = acpPreset; + sessionTotalTokens = tokens; + usageHistory.add(new AiUsageEntry( + acpLabel(), preset != null ? preset.id() : "acp", 0, 0, tokens, elapsed, + stopReason, Instant.now())); + } + switch (stopReason) { + case "end_turn", "cancelled" -> { + // cancelled is reported by interruptBusyOperation() + } + case "refusal" -> conversation.add(new ConversationEntry(AiRole.ERROR, "The agent refused to continue.")); + default -> conversation.add(new ConversationEntry(AiRole.SYSTEM, "(stopped: " + stopReason + ")")); + } + log(LogLevel.RESPONSE, "Response (" + formatSeconds(elapsed) + ", " + stopReason + ")", text.toString()); + } + } + + /** + * Policy A from the design: calls to the camel-tui MCP server are approved silently (allow-always preferred), + * everything else is put in front of the user. Runs on the ACP request thread and blocks until the user answers or + * the turn is cancelled. + */ + private final class AcpPanelPermissionHandler implements AcpAgentClient.PermissionHandler { + @Override + public String decide(JsonObject toolCall, List options) { + String name = String.valueOf(toolCall.getStringOrDefault("name", "")); + String title = String.valueOf(toolCall.getStringOrDefault("title", "")); + if (name.startsWith(TUI_TOOL_PREFIX) || title.contains("camel-tui")) { + String optionId = firstOptionOfKind(options, "allow_always"); + if (optionId == null) { + optionId = firstOptionOfKind(options, "allow_once"); + } + if (optionId == null && !options.isEmpty()) { + optionId = options.get(0).getString("optionId"); + } + log(LogLevel.TOOL, "Auto-approved TUI tool", title); + return optionId; + } + CompletableFuture 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(); + } + } + } + + private static String firstOptionOfKind(List 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 +1962,10 @@ void render(Frame frame, Rect area) { 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() + " ", Style.EMPTY.dim())); } else if (sessionTotalTokens > 0) { titleLine = Line.from( Span.styled(" AI ", Style.EMPTY.bold()), @@ -1495,10 +1983,12 @@ void render(Frame frame, Rect area) { frame.renderWidget(block, area); Rect inner = block.inner(area); if (inner.height() < 2) { + acpHeader.hide(frame); return; } if (statsView) { + acpHeader.hide(frame); renderStats(frame, inner); if (providerSwitchPopup.isVisible()) { providerSwitchPopup.render(frame, inner); @@ -1506,10 +1996,26 @@ void render(Frame frame, Rect area) { 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 top = Layout.vertical() + .constraints(Constraint.length(AcpHeaderStrip.ROWS), Constraint.length(1), Constraint.fill()) + .split(inner); + acpHeader.render(frame, top.get(0), acpHeaderModel(), acpLogoMode); + frame.renderWidget(Paragraph.from(Line.from(Span.styled("─".repeat(top.get(1).width()), Style.EMPTY.dim()))), + top.get(1)); + body = top.get(2); + } else { + acpHeader.hide(frame); + } + + // Split the body area: conversation (fill) + optional slash hints + separator (1 row) + input (1 row per line) List slashHints = slashCommandHints(); int hintRows = slashHints.isEmpty() ? 0 : slashHints.size(); int inputRows = historySearchActive ? 1 : inputRows(); @@ -1517,12 +2023,12 @@ void render(Frame frame, Rect area) { 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 +2049,16 @@ void render(Frame frame, Rect area) { if (copyPopup.isVisible()) { copyPopup.render(frame, inner); } + if (permissionPopup.isVisible()) { + permissionPopup.render(frame, inner); + } } private List 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 hints) { @@ -1800,6 +2309,10 @@ private void renderSearchInput(Frame frame, Rect area) { } void renderFooter(List spans) { + if (permissionPopup.isVisible()) { + permissionPopup.renderFooter(spans); + return; + } if (providerSwitchPopup.isVisible()) { providerSwitchPopup.renderFooter(spans); return; @@ -2737,6 +3250,10 @@ void executeSlashCommandForTesting(String input) { } 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 +3268,9 @@ void clearConversation() { if (messages != null) { messages.clear(); } + // the next prompt opens a fresh agent session (context reset), keeping the process + acpSessionId = null; + acpPreambleSent = false; } void setPromptHistoryForTesting(TuiPromptHistory history) { @@ -2768,6 +3288,41 @@ void setClientForTesting(LlmClient client) { this.testingClientInjected = true; } + void selectProviderForTesting(String providerId) { + applyProviderChoice(new AiProviderSwitchPopup.ProviderChoice(providerId, "", "", false)); + } + + boolean isAcpProviderForTesting() { + return acpPreset != null; + } + + void setAcpHeaderForTesting(AcpHeaderStrip strip) { + this.acpHeader = strip; + } + + void setAcpClientFactoryForTesting(AcpClientFactory factory) { + this.acpClientFactory = factory; + } + + void setMcpUrlSupplierForTestingOrRuntime(Callable supplier) { + this.mcpUrlSupplier = supplier; + } + + String acpSessionIdForTesting() { + return acpSessionId; + } + + boolean isPermissionPopupVisibleForTesting() { + return permissionPopup.isVisible(); + } + + private AcpAgentClient spawnAcpAgent(AiProviderSelector.AcpPreset preset, Path cwd) throws IOException { + if (!AiProviderSelector.isOnPath(preset.executable())) { + throw new IOException(preset.installHint()); + } + return AcpAgentClient.spawn(preset.command(), cwd, line -> log(LogLevel.ERROR, "ACP", line)); + } + void setSlashCommandContextForTesting(AiSlashCommandContext context) { this.slashCommandContext = context; } @@ -2907,16 +3462,26 @@ public void clearHistory() { @Override public String currentModel() { + if (acpPreset != null) { + return acpLabel(); + } return client != null && client.model() != null ? client.model() : "unknown"; } @Override public List 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; } 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 d691a583b3b70..d7744e21b4b70 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,124 @@ */ 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 18 or newer (https://nodejs.org) and try again."; + + /** + * One external ACP agent the panel knows how to launch. {@code executable} is the first token of the command, + * checked on the PATH before spawning so a missing tool yields {@code installHint} instead of an obscure error. + * {@code glyph}, {@code color} and {@code logo} identify the agent in the panel's header strip: the logo names a + * {@code /tui/logos/.png} resource drawn in terminals with native graphics, the glyph is the fallback + * everywhere else. + */ + record AcpPreset(String id, String label, List command, String executable, String loginHint, + String installHint, String glyph, Color color, String logo) { + } + + private static final List ACP_PRESETS = List.of( + new AcpPreset( + "acp:claude", "Claude Code (ACP)", + List.of("npx", "-y", "@agentclientprotocol/claude-agent-acp"), "npx", + "Log in with the claude CLI or set ANTHROPIC_API_KEY, then ask again.", NPX_HINT, + "✱", Color.rgb(0xD9, 0x77, 0x57), "claude"), + new AcpPreset( + "acp:codex", "Codex (ACP)", + List.of("npx", "-y", "@agentclientprotocol/codex-acp"), "npx", + "Run `codex login` or set OPENAI_API_KEY, then ask again.", NPX_HINT, + "⬢", Color.rgb(0x10, 0xA3, 0x7F), "codex"), + new AcpPreset( + "acp:bob", "IBM Bob (ACP)", + List.of("bob", "acp"), "bob", + "Set BOBSHELL_API_KEY or run `bob` once to sign in, then ask again.", + "bob not found: install Bob Shell (https://bob.ibm.com/docs/shell) and try again.", + "◆", Color.rgb(0x0F, 0x62, 0xFE), "bob"), + new AcpPreset( + "acp:qwen", "Qwen Code (ACP)", + List.of("qwen", "--acp"), "qwen", + "Set OPENAI_API_KEY and OPENAI_BASE_URL for Qwen Code, then ask again.", + "qwen not found: npm install -g @qwen-code/qwen-code and try again.", + "✦", Color.rgb(0x61, 0x5C, 0xED), "qwen"), + new AcpPreset( + "acp:opencode", "OpenCode (ACP)", + List.of("opencode", "acp"), "opencode", + "Run `opencode auth login`, then ask again.", + "opencode not found: install it from https://opencode.ai and try again.", + "▣", Color.rgb(0x9F, 0xD3, 0x5B), "opencode"), + new AcpPreset( + "acp:dsh", "DeepSeek Harness (ACP, preview)", + List.of("npx", "-y", "@deepseek-ai/dsh", "--profile", "acp"), "npx", + "Configure the model key in DeepSeek Harness, then ask again.", NPX_HINT, + "◉", Color.rgb(0x4D, 0x6B, 0xFE), "dsh")); + + static List 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 command = List.of(raw.trim().split("\\s+")); + return new AcpPreset( + ACP_CUSTOM, "Custom (ACP)", command, command.get(0), + "Check the agent's own login instructions, then ask again.", + command.get(0) + " not found: check camel.tui.ai.acp.command.", + "●", Theme.ACCENT, null); + } + throw new IllegalArgumentException("Unknown ACP provider '" + provider + "'."); + } + + /** True when {@code executable} is an absolute path to an executable file or is found on the PATH. */ + static boolean isOnPath(String executable) { + Path direct = Path.of(executable); + if (direct.isAbsolute()) { + return Files.isExecutable(direct); + } + String path = System.getenv("PATH"); + if (path == null) { + return false; + } + for (String dir : path.split(File.pathSeparator)) { + if (dir.isBlank()) { + continue; + } + Path candidate = Path.of(dir).resolve(executable); + if (Files.isExecutable(candidate) || Files.isExecutable(Path.of(candidate + ".cmd")) + || Files.isExecutable(Path.of(candidate + ".exe"))) { + return true; + } + } + return false; + } + /** * 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 +168,19 @@ List buildChoices() { settings.getAiModel() != null ? settings.getAiModel() : "", settings.getAiUrl() != null ? settings.getAiUrl() : "", true)); - if (!"anthropic".equals(defaultProvider)) { - choices.add(new AiProviderSwitchPopup.ProviderChoice("anthropic", "", "", false)); + for (String provider : List.of("anthropic", "openai", "gemini", "ollama", "watsonx")) { + if (!provider.equals(defaultProvider)) { + choices.add(new AiProviderSwitchPopup.ProviderChoice(provider, "", "", false)); + } } - if (!"openai".equals(defaultProvider)) { - choices.add(new AiProviderSwitchPopup.ProviderChoice("openai", "", "", false)); + for (AcpPreset preset : ACP_PRESETS) { + if (!preset.id().equals(defaultProvider)) { + choices.add(new AiProviderSwitchPopup.ProviderChoice(preset.id(), "", "", false)); + } } - if (!"gemini".equals(defaultProvider)) { - choices.add(new AiProviderSwitchPopup.ProviderChoice("gemini", "", "", 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 +192,9 @@ List buildChoices() { * @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 ac19042dedcdb..6233d61ae015e 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 @@ record ProviderChoice(String provider, String model, String url, boolean persist } 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 833629380725f..118482badba82 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 @@ List completionsFor(String input) { 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 completionsFor(String input, List extra) { + List 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 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 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 244a6c46cd898..7003642b7ac82 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 @@ private void startMcpServer() throws Exception { 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 @@ private void startMcpServer() throws Exception { } } aiPanel.setMcpInfo(mcp, mcpPort); + aiPanel.setMcpUrlSupplierForTestingOrRuntime(this::ensureMcpServer); } /** @@ -2962,6 +2962,31 @@ private String selectedName() { // ---- MCP .mcp.json lifecycle ---- + /** + * Returns the Streamable HTTP URL of the embedded MCP server, starting it on an ephemeral localhost port if neither + * --mcp nor an earlier call did. Used by the AI panel to hand the TUI tools to an ACP agent. No .mcp.json is + * written for an on-demand server. Called from the AI panel's agent thread. + */ + synchronized String ensureMcpServer() throws IOException { + 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 aea9a61533c33..eddd09b757c4f 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,9 @@ 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; + private static final int ROW_AI_ACP_LOGOS = 19; + private static final int ROW_COUNT = 20; private static final String[] LOG_PIN_OPTIONS = { "off", "25", "50", "75" }; private static final String[] RATE_PER_OPTIONS = { "seconds", "minutes" }; @@ -71,6 +73,7 @@ class SettingsPopup { private static final String[] PANEL_SPACE_OPTIONS = { "move", "overlay" }; private static final String[] AI_TOOLS_OPTIONS = { AiPanel.TOOL_MODE_AUTO, AiPanel.TOOL_MODE_CORE, AiPanel.TOOL_MODE_FULL }; + private static final String[] AI_ACP_LOGOS_OPTIONS = { "auto", "on", "off" }; private static final List AI_PROVIDERS = buildAiProviderList(); private static List buildAiProviderList() { @@ -78,6 +81,10 @@ private static List buildAiProviderList() { 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; } @@ -97,6 +104,7 @@ private static List buildAiProviderList() { private int validateOnSaveIndex; private int aiProviderIndex; private int aiToolsIndex; + private int aiAcpLogosIndex; private TextInputState folderInput; private TextInputState proxyHostInput; private TextInputState proxyPortInput; @@ -104,6 +112,7 @@ private static List buildAiProviderList() { private TextInputState aiModelInput; private TextInputState aiUrlInput; private TextInputState aiPromptHistoryInput; + private TextInputState aiAcpCommandInput; private List tabNames = new ArrayList<>(); private List tabEntries; @@ -190,6 +199,14 @@ void open() { aiToolsIndex = Math.max(0, toolsIdx); aiPromptHistoryInput = new TextInputState( settings.getAiPromptHistory() != null ? settings.getAiPromptHistory() : ""); + aiAcpCommandInput = new TextInputState(settings.getAiAcpCommand() != null ? settings.getAiAcpCommand() : ""); + aiAcpLogosIndex = 0; + for (int i = 0; i < AI_ACP_LOGOS_OPTIONS.length; i++) { + if (AI_ACP_LOGOS_OPTIONS[i].equals(settings.getAiAcpLogos())) { + aiAcpLogosIndex = i; + break; + } + } selectedRow = ROW_THEME; visible = true; } @@ -342,6 +359,18 @@ boolean handleKeyEvent(KeyEvent ke) { handleTextInput(ke, aiPromptHistoryInput); return true; } + if (selectedRow == ROW_AI_ACP_COMMAND) { + handleTextInput(ke, aiAcpCommandInput); + return true; + } + if (selectedRow == ROW_AI_ACP_LOGOS) { + if (ke.isChar(' ') || ke.isRight()) { + aiAcpLogosIndex = (aiAcpLogosIndex + 1) % AI_ACP_LOGOS_OPTIONS.length; + } else if (ke.isLeft()) { + aiAcpLogosIndex = (aiAcpLogosIndex - 1 + AI_ACP_LOGOS_OPTIONS.length) % AI_ACP_LOGOS_OPTIONS.length; + } + return true; + } return true; } @@ -386,6 +415,8 @@ private void save() { 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.setAiAcpLogos(AI_ACP_LOGOS_OPTIONS[aiAcpLogosIndex]); settings.save(); if (Theme.mode().equals(selectedThemeId)) { // Already active via live preview (or unchanged): just persist and clear the preview marker. @@ -519,6 +550,16 @@ void render(Frame frame, Rect area) { 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)"); + rowY++; + + renderLabel(frame, innerX, rowY, labelW, "ACP Logos:", selectedRow == ROW_AI_ACP_LOGOS); + renderValue(frame, innerX + labelW, rowY, fieldW, AI_ACP_LOGOS_OPTIONS[aiAcpLogosIndex], + selectedRow == ROW_AI_ACP_LOGOS); } void renderFooter(List spans) { @@ -526,7 +567,8 @@ void renderFooter(List spans) { || selectedRow == ROW_LOG_PIN || selectedRow == ROW_RATE_PER || selectedRow == ROW_PANEL_POSITION || selectedRow == ROW_PANEL_SPACE || selectedRow == ROW_CONFIRM_ACTIONS || selectedRow == ROW_VALIDATE_ON_SAVE - || selectedRow == ROW_AI_PROVIDER || selectedRow == ROW_AI_TOOLS) { + || selectedRow == ROW_AI_PROVIDER || selectedRow == ROW_AI_TOOLS + || selectedRow == ROW_AI_ACP_LOGOS) { hint(spans, "Space", "cycle"); } hint(spans, "Enter", "save"); @@ -670,6 +712,14 @@ private String aiToolsLabel() { }; } + String selectedAiAcpLogos() { + return AI_ACP_LOGOS_OPTIONS[aiAcpLogosIndex]; + } + + List 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 d82da6b064041..c222298b545c3 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 a49bb1aff14f0..2c39d7c187684 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 @@ void start() throws IOException { 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 @@ void stop() { } } + /** + * 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 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 c6ababc822175..83118b957c97d 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,8 @@ 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_AI_ACP_LOGOS = "camel.tui.ai.acp.logos"; 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 +63,8 @@ final class TuiSettings { private String aiModel; private String aiUrl; private String aiTools; + private String aiAcpCommand; + private String aiAcpLogos; private String shellHistory; private String aiPromptHistory; private String confirmActions; @@ -168,6 +172,22 @@ void setAiTools(String aiTools) { this.aiTools = aiTools; } + String getAiAcpCommand() { + return aiAcpCommand; + } + + void setAiAcpCommand(String aiAcpCommand) { + this.aiAcpCommand = aiAcpCommand; + } + + String getAiAcpLogos() { + return aiAcpLogos; + } + + void setAiAcpLogos(String aiAcpLogos) { + this.aiAcpLogos = aiAcpLogos; + } + String getShellHistory() { return shellHistory; } @@ -262,6 +282,8 @@ static TuiSettings load() { 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.aiAcpLogos = trimToNull(TuiUserConfig.read(PROP_AI_ACP_LOGOS)); 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 +315,8 @@ void save() { 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_AI_ACP_LOGOS, aiAcpLogos); 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/resources/tui/logos/bob.png b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/bob.png new file mode 100644 index 0000000000000000000000000000000000000000..7a14543193f857d6765c99c077b21fd9341a5241 GIT binary patch literal 10743 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 literal 0 HcmV?d00001 diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/codex.png b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/codex.png new file mode 100644 index 0000000000000000000000000000000000000000..6d29b0ac129c48af33aae633079a0b1bf8fe5113 GIT binary patch literal 9537 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 I!6NMc0lNSX=Kufz literal 0 HcmV?d00001 diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/opencode.png b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/opencode.png new file mode 100644 index 0000000000000000000000000000000000000000..1b0e057f487e5a096ee3c8c132437c82031b9a53 GIT binary patch literal 918 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJFdSWg$nkcwMx?>golaS(7l zXkpZ_anGDhe+na{IQV+(_Gq#E|8ix!-Mg*#z7)eCphh!P=EuL{_wTyp>H^Q08VneRD%$Wk x?)6+b!8ML;%oH;g3(QrB(nSq^B$=t>d+H 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 literal 0 HcmV?d00001 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 0000000000000..9c1499d6842ee --- /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,446 @@ +/* + * 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 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.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 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("this is not json")), diagnostics.toString()); + assertTrue(diagnostics.stream().anyMatch(d -> 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 + @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); + JsonObject review = new JsonObject(); + review.put("name", "review"); + JsonArray reviewCommands = new JsonArray(); + reviewCommands.add(review); + JsonObject staleUpdate = new JsonObject(); + staleUpdate.put("sessionUpdate", "available_commands_update"); + staleUpdate.put("availableCommands", reviewCommands); + agent.sendNotification("session/update", update("sess-OLD", staleUpdate)); + JsonObject commit = new JsonObject(); + commit.put("name", "commit"); + JsonArray commitCommands = new JsonArray(); + commitCommands.add(commit); + JsonObject currentUpdate = new JsonObject(); + currentUpdate.put("sessionUpdate", "available_commands_update"); + currentUpdate.put("availableCommands", commitCommands); + agent.sendNotification("session/update", update(session, currentUpdate)); + await().atMost(5, TimeUnit.SECONDS).until(() -> client.availableCommands().size() == 1); + assertEquals("commit", 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; + } + + 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 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 0000000000000..cac2401bf5e17 --- /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,178 @@ +/* + * 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.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.EnumSet; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.image.capability.TerminalImageCapabilities; +import dev.tamboui.image.capability.TerminalImageProtocol; +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.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 AcpHeaderStripTest { + + private static final TerminalImageCapabilities KITTY + = TerminalImageCapabilities.withSupport(EnumSet.of(TerminalImageProtocol.KITTY, TerminalImageProtocol.HALF_BLOCK)); + private static final TerminalImageCapabilities TEXT_ONLY + = TerminalImageCapabilities.withSupport(EnumSet.of(TerminalImageProtocol.HALF_BLOCK)); + + private static AcpHeaderStrip.Model model(int commands) { + return new AcpHeaderStrip.Model( + "IBM Bob (ACP)", "◆", Color.rgb(0x0F, 0x62, 0xFE), "bob", + "bob-shell 2.0.2", "58b65aea-1234", Path.of(System.getProperty("user.home"), "Work", "camel"), commands); + } + + @Test + void logosFollowTheModeAndTheTerminal() { + assertTrue(new AcpHeaderStrip(KITTY).logosEnabled(AcpHeaderStrip.LogoMode.AUTO)); + assertFalse(new AcpHeaderStrip(TEXT_ONLY).logosEnabled(AcpHeaderStrip.LogoMode.AUTO)); + assertTrue(new AcpHeaderStrip(TEXT_ONLY).logosEnabled(AcpHeaderStrip.LogoMode.ON)); + assertFalse(new AcpHeaderStrip(KITTY).logosEnabled(AcpHeaderStrip.LogoMode.OFF)); + assertEquals(AcpHeaderStrip.LogoMode.AUTO, AcpHeaderStrip.LogoMode.parse(null)); + assertEquals(AcpHeaderStrip.LogoMode.OFF, AcpHeaderStrip.LogoMode.parse("off")); + assertEquals(AcpHeaderStrip.LogoMode.AUTO, AcpHeaderStrip.LogoMode.parse("nonsense")); + } + + @Test + void everyPresetLogoLoadsAndUnknownOnesAreNull() { + AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); + for (String logo : new String[] { "claude", "codex", "bob", "qwen", "opencode", "dsh" }) { + assertNotNull(strip.logoFor(logo), logo); + } + assertNull(strip.logoFor("nope")); + assertNull(strip.logoFor(null)); + } + + @Test + void glyphModeRendersGlyphAndMetadata() { + AcpHeaderStrip strip = new AcpHeaderStrip(TEXT_ONLY); + Rect area = new Rect(0, 0, 100, AcpHeaderStrip.ROWS); + Buffer buffer = Buffer.empty(area); + strip.render(Frame.forTesting(buffer), area, model(24), AcpHeaderStrip.LogoMode.AUTO); + 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); + assertNull(strip.lastLogoRectForTesting()); + } + + @Test + void logoModeReservesTheLogoColumnsAndKeepsTheText() { + AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); + Rect area = new Rect(0, 0, 100, AcpHeaderStrip.ROWS); + Buffer buffer = Buffer.empty(area); + strip.render(Frame.forTesting(buffer), area, model(0), AcpHeaderStrip.LogoMode.AUTO); + String rendered = TuiTestHelper.bufferToString(buffer); + assertTrue(rendered.contains("IBM Bob (ACP) · bob-shell 2.0.2"), rendered); + assertFalse(rendered.contains("◆"), "no glyph when the logo is drawn"); + assertTrue(rendered.contains("no commands yet"), rendered); + assertNotNull(strip.lastLogoRectForTesting()); + assertEquals(0, strip.lastLogoRectForTesting().x()); + } + + @Test + void kittyCommandsAreQuietAndIdentified() { + String transmit = AcpHeaderStrip.kittyTransmit(4242, new byte[] { 1, 2, 3 }); + assertTrue(transmit.startsWith("\033_Ga=t,f=100,t=d,i=4242,q=2,m=0;"), transmit); + assertTrue(transmit.endsWith("\033\\"), transmit); + String big = AcpHeaderStrip.kittyTransmit(7, new byte[9000]); + assertTrue(big.startsWith("\033_Ga=t,f=100,t=d,i=7,q=2,m=1;"), big); + assertEquals(3, big.split("\033_G", -1).length - 1, "9000 bytes base64 split into three 4096-character chunks"); + assertTrue(big.contains(",m=1;") && big.lastIndexOf("m=0;") > big.lastIndexOf("m=1;")); + assertEquals("\033[3;2H\033_Ga=p,i=4242,p=1,c=5,r=2,C=1,q=2\033\\", + AcpHeaderStrip.kittyPlace(4242, new Rect(1, 2, 5, 2))); + assertEquals("\033_Ga=d,d=i,i=4242,q=2\033\\", AcpHeaderStrip.kittyDelete(4242)); + assertEquals(AcpHeaderStrip.kittyImageId("claude"), AcpHeaderStrip.kittyImageId("claude")); + assertTrue(AcpHeaderStrip.kittyImageId("claude") != AcpHeaderStrip.kittyImageId("codex")); + assertTrue(AcpHeaderStrip.kittyImageId("bob") > 0); + } + + @Test + void kittyLogoIsUploadedOnceAndPlacedEveryFrame() { + AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); + Rect area = new Rect(0, 0, 100, AcpHeaderStrip.ROWS); + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + strip.renderForTesting(area, Buffer.empty(area), raw, model(3), AcpHeaderStrip.LogoMode.AUTO); + String first = raw.toString(StandardCharsets.US_ASCII); + assertTrue(first.contains("a=t,f=100,t=d,i=" + AcpHeaderStrip.kittyImageId("bob")), first); + assertTrue(first.endsWith(AcpHeaderStrip.kittyPlace(AcpHeaderStrip.kittyImageId("bob"), new Rect(0, 0, 5, 2))), first); + raw.reset(); + strip.renderForTesting(area, Buffer.empty(area), raw, model(3), AcpHeaderStrip.LogoMode.AUTO); + String second = raw.toString(StandardCharsets.US_ASCII); + assertFalse(second.contains("a=t,"), "no second upload"); + assertEquals(AcpHeaderStrip.kittyPlace(AcpHeaderStrip.kittyImageId("bob"), new Rect(0, 0, 5, 2)), second); + assertTrue(strip.hasPlacementForTesting()); + raw.reset(); + strip.hideForTesting(raw); + assertEquals(AcpHeaderStrip.kittyDelete(AcpHeaderStrip.kittyImageId("bob")), raw.toString(StandardCharsets.US_ASCII)); + assertFalse(strip.hasPlacementForTesting()); + raw.reset(); + strip.hideForTesting(raw); + assertEquals("", raw.toString(StandardCharsets.US_ASCII), "hide is a no-op without a placement"); + } + + @Test + void uploadIsRetriedAfterAWriteFailure() { + AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); + Rect area = new Rect(0, 0, 100, AcpHeaderStrip.ROWS); + OutputStream broken = new OutputStream() { + @Override + public void write(int b) throws IOException { + throw new IOException("terminal gone"); + } + }; + strip.renderForTesting(area, Buffer.empty(area), broken, model(1), AcpHeaderStrip.LogoMode.AUTO); + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + strip.renderForTesting(area, Buffer.empty(area), raw, model(1), AcpHeaderStrip.LogoMode.AUTO); + String second = raw.toString(StandardCharsets.US_ASCII); + assertTrue(second.contains("a=t,f=100,t=d,i="), "the upload is retried after a failed write"); + assertTrue(second.endsWith(AcpHeaderStrip.kittyPlace(AcpHeaderStrip.kittyImageId("bob"), new Rect(0, 0, 5, 2))), + second); + } + + @Test + void logoBytesAreTheFullPng() throws Exception { + AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); + byte[] png = strip.logoBytes("bob"); + assertNotNull(png); + assertTrue(png.length > 1000); + assertEquals((byte) 0x89, png[0]); + assertNull(strip.logoBytes("nope")); + } + + @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 0000000000000..7470992d4f508 --- /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,146 @@ +/* + * 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 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 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 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 0000000000000..c69e489b90b4f --- /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,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.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.EnumSet; +import java.util.concurrent.TimeUnit; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.image.capability.TerminalImageCapabilities; +import dev.tamboui.image.capability.TerminalImageProtocol; +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.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.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; + +@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)); + } + + /** 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.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() { + 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); + JsonArray commands = new JsonArray(); + commands.add(review); + 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")); + } + + @Test + void usageUpdateFeedsTheTokenCounter() throws Exception { + AiPanel panel = acpPanel(); + agent.onRequest("session/prompt", params -> { + JsonObject usage = new JsonObject(); + usage.put("sessionUpdate", "usage_update"); + usage.put("used", 1234); + usage.put("size", 200000); + agent.sendNotification("session/update", update(params, usage)); + agent.sendNotification("session/update", update(params, chunk("ok"))); + return stop("end_turn"); + }); + ask(panel, "hi"); + awaitIdle(panel); + assertEquals(1234, panel.sessionTotalTokensForTesting()); + } + + @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 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(); + panel.setAcpHeaderForTesting(new AcpHeaderStrip( + TerminalImageCapabilities.withSupport(EnumSet.of(TerminalImageProtocol.HALF_BLOCK)))); + // 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()); + } +} 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 b8f85286c6e5a..7014a4c30097f 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 @@ -23,12 +23,17 @@ 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -59,7 +64,8 @@ void buildChoicesAlwaysListsAllProvidersForManualSelection(@TempDir Path tempDir List 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 +80,8 @@ void defaultProviderIsNotDuplicated(@TempDir Path tempDir) { List 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 +95,8 @@ void ollamaDefaultIsNotDuplicated(@TempDir Path tempDir) { List 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 +109,8 @@ void watsonxDefaultIsNotDuplicated(@TempDir Path tempDir) { List 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 +159,61 @@ void applyChoiceRejectsUnknownProviderWithActionableMessage() { .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 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", claude.logo()); + assertEquals("✱", claude.glyph()); + assertNull(custom.logo()); + assertEquals("●", custom.glyph()); + assertThrows(IllegalArgumentException.class, () -> selector.acpPreset("acp:nope", settings)); + } + + @Test + @EnabledOnOs({ OS.LINUX, OS.MAC }) + void isOnPathFindsShellButNotNonsense() { + assertTrue(AiProviderSelector.isOnPath("sh")); + assertFalse(AiProviderSelector.isOnPath("definitely-not-a-real-binary-42")); + } } 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 d558f73fb33d2..fa0c2de113eb8 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 @@ void enterSelectsHighlightedChoice() { 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 c386d8bb38a46..667c0fc1c22f7 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 @@ void completionsHideAfterKnownCommandWithTrailingSpace() { assertTrue(registry.completionsFor("/send ").isEmpty()); } + @Test + void completionsIncludeExtraDescriptorsButRegistryCommandsWin() { + AiSlashCommandRegistry registry = AiSlashCommandRegistry.defaults(); + List extra = List.of( + new AiSlashCommandRegistry.Descriptor("review", List.of(), "Review changes", "focus", null), + new AiSlashCommandRegistry.Descriptor("help", List.of(), "Agent help", null, null)); + List 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 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 0000000000000..82286c937cbdc --- /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> handlers = new ConcurrentHashMap<>(); + private final Map errors = new ConcurrentHashMap<>(); + private final List received = new CopyOnWriteArrayList<>(); + private final Map> 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 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 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 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 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 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 92730c0e98ab7..1ffd0f4722eac 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 @@ -67,7 +67,7 @@ void rendersTitleAndAllSettingRows(@TempDir Path tempDir) { 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 +84,7 @@ void rendersTitleAndAllSettingRows(@TempDir Path tempDir) { 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"); + assertTrue(rendered.contains("ACP Logos"), "the ACP Logos row should be shown"); } } 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 7dc071e4d1c45..582fdd843f86b 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 @@ -257,6 +257,28 @@ void aiToolsRowCyclesModesAndPersistsNonDefault(@TempDir Path tempDir) { assertNull(TuiSettings.load().getAiTools()); } + @Test + void acpLogosRowCyclesAndPersists(@TempDir Path tempDir) { + useHome(tempDir); + SettingsPopup popup = new SettingsPopup(); + popup.setTabEntries(tabs()); + popup.open(); + + // navigate to ACP Logos (row 19) + for (int i = 0; i < 19; i++) { + popup.handleKeyEvent(key(KeyCode.DOWN)); + } + assertEquals(19, popup.selectedRow()); + assertEquals("auto", popup.selectedAiAcpLogos()); + popup.handleKeyEvent(KeyEvent.ofChar(' ')); + assertEquals("on", popup.selectedAiAcpLogos()); + popup.handleKeyEvent(KeyEvent.ofChar(' ')); + assertEquals("off", popup.selectedAiAcpLogos()); + + popup.handleKeyEvent(key(KeyCode.ENTER)); + assertEquals("off", TuiSettings.load().getAiAcpLogos()); + } + @Test void historyFieldsPersistValues(@TempDir Path tempDir) { useHome(tempDir); @@ -394,6 +416,15 @@ void panelRowsPersistPositionAndSpace(@TempDir Path tempDir) { 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 0000000000000..00990ae8643ad --- /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 00ba4ad323fe4..0929794e1bdc0 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,8 @@ void roundTripPreservesAllFields(@TempDir Path tempDir) { settings.setAiModel("gemini-3.5-flash"); settings.setAiUrl("https://generativelanguage.googleapis.com"); settings.setAiTools("core"); + settings.setAiAcpCommand("npx -y pi-acp"); + settings.setAiAcpLogos("off"); settings.setShellHistory("25"); settings.setAiPromptHistory("50"); settings.setPanelPosition("top"); @@ -78,6 +80,8 @@ void roundTripPreservesAllFields(@TempDir Path tempDir) { 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.getAiAcpLogos()).isEqualTo("off"); assertThat(loaded.getShellHistory()).isEqualTo("25"); assertThat(loaded.getAiPromptHistory()).isEqualTo("50"); assertThat(loaded.getPanelPosition()).isEqualTo("top"); From 0022ed276a1d5fdcfd7a0883354eb66f949b7f13 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 14:11:10 +0200 Subject: [PATCH 02/17] CAMEL-24663: Camel TUI: Answer /retry, /context, /compact, /tools and /prompt for the ACP agent instead of the LLM path With an ACP agent selected, the panel's /retry, /context, /compact, /tools and /prompt still described the LLM path: no client to retry with, a model history that does not exist, a tool set the agent never sees. /retry now resends the last question or /agent: command through the agent; /context shows the agent, its session, working directory, MCP server, command count, preamble size and the tokens reported so far; /prompt shows the preamble under a heading naming the agent; /compact and /tools say the agent manages its own history and tools, pointing to /agent:compact when the agent offers it, and /tools is refused without touching the settings. The LLM path is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 6 + .../dsl/jbang/core/commands/tui/AiPanel.java | 65 +++++++++- .../core/commands/tui/AiPanelAcpTest.java | 122 ++++++++++++++++-- 3 files changed, 182 insertions(+), 11 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 19188832729e3..817285ce48418 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -1066,6 +1066,12 @@ hints once the session is open; `/agent:` alone lists them. `/agent: …` 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. 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 0c03491516b23..50483375531b0 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 @@ -2886,6 +2886,10 @@ private boolean useCoreTools() { } 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"; } @@ -3063,6 +3067,9 @@ private long toolSchemaChars() { } 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"); @@ -3092,7 +3099,50 @@ String describeContext() { return sb.toString(); } + /** + * 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(" (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'); + sb.append("Tokens reported by the agent so far: ").append(LlmClient.formatTokens(sessionTotalTokens)) + .append('\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"; } @@ -3106,10 +3156,11 @@ String compactHistoryNow() { /** * 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; @@ -3122,7 +3173,7 @@ boolean retryLastQuestion() { 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) { @@ -3502,6 +3553,11 @@ public String describeToolMode() { @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; @@ -3574,6 +3630,9 @@ public void exportConversation() { @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/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 index c69e489b90b4f..ac06691c45062 100644 --- 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 @@ -45,6 +45,7 @@ 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 { @@ -87,6 +88,15 @@ private static boolean hasEntry(AiPanel panel, AiRole role, String fragment) { .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(); @@ -169,15 +179,31 @@ private static JsonObject permissionParams(String name, String title, JsonObject /** 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 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); JsonObject update = new JsonObject(); update.put("sessionUpdate", "available_commands_update"); update.put("availableCommands", commands); @@ -684,4 +710,84 @@ void agentCommandsAreShownAndCompletedWithThePrefix() throws Exception { 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")); + } } From 170f863ee10abd790b7f365b0551030e48df3135 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 15:10:27 +0200 Subject: [PATCH 03/17] CAMEL-24663: Camel TUI: Auto-approve only registered camel-tui tools in the ACP permission handler, never file or shell calls The handler auto-approved any tool call whose title contained "camel-tui", so a file edit titled "Edit /tmp/camel-tui/route.yaml" was approved without asking. A title now only identifies a tool when it has the Gemini-derived shape "tui_get_state (camel-tui MCP Server)" and the tool is one the TUI registers, and kinds that touch files or run commands never qualify. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- .../dsl/jbang/core/commands/tui/AiPanel.java | 38 ++++++++++++++++++- .../core/commands/tui/AiPanelAcpTest.java | 24 ++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) 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 50483375531b0..8ef42272ae89d 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 @@ -34,6 +34,7 @@ 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; @@ -42,6 +43,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; +import java.util.regex.Pattern; import dev.tamboui.layout.Alignment; import dev.tamboui.layout.Constraint; @@ -260,6 +262,8 @@ public void printf(String format, Object... args) { 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 FILE_OR_SHELL_KINDS = Set.of("edit", "delete", "move", "execute", "fetch"); + private static final Pattern TUI_TOOL_NAME = Pattern.compile("tui_[a-z0-9_]+"); private static final String AGENT_COMMAND_PREFIX = "/agent:"; private volatile AiProviderSelector.AcpPreset acpPreset; private volatile AcpAgentClient acpClient; @@ -1897,7 +1901,8 @@ private final class AcpPanelPermissionHandler implements AcpAgentClient.Permissi public String decide(JsonObject toolCall, List options) { String name = String.valueOf(toolCall.getStringOrDefault("name", "")); String title = String.valueOf(toolCall.getStringOrDefault("title", "")); - if (name.startsWith(TUI_TOOL_PREFIX) || title.contains("camel-tui")) { + 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"); @@ -1926,6 +1931,37 @@ public String decide(JsonObject toolCall, List options) { } } + /** + * Only a call to a tool the TUI itself registers counts as a camel-tui tool: the name the Claude adapter sends + * ({@code mcp__camel-tui__tui_get_state}) or a title of the form {@code tui_get_state (camel-tui MCP Server)}. + * Kinds that touch files or run commands never qualify, whatever the title says: a path containing "camel-tui" is + * not a tool identity. + */ + private boolean isTuiTool(String name, String title, String kind) { + if (FILE_OR_SHELL_KINDS.contains(kind)) { + return false; + } + String tool = null; + if (name.startsWith(TUI_TOOL_PREFIX)) { + tool = name.substring(TUI_TOOL_PREFIX.length()); + } else { + int paren = title.indexOf("(camel-tui"); + if (paren > 0) { + tool = title.substring(0, paren).strip(); + } + } + return tool != null && isRegisteredTuiTool(tool); + } + + private boolean isRegisteredTuiTool(String tool) { + TuiToolRegistry registry = toolRegistry; + if (registry != null) { + return registry.getToolDefinitions().stream().anyMatch(td -> td.name().equals(tool)); + } + // no registry wired yet (tests, or before the MCP facade exists): accept the TUI's own naming scheme + return TUI_TOOL_NAME.matcher(tool).matches(); + } + private static String firstOptionOfKind(List options, String kind) { for (JsonObject option : options) { if (kind.equals(option.getString("kind"))) { 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 index ac06691c45062..b14afc578b9ef 100644 --- 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 @@ -501,6 +501,30 @@ void tuiToolCallsAreAutoApprovedByTitle() throws Exception { assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)")); } + @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(); From e3dd044848e21f1af15b3b11c175ca9383b05655 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 15:20:30 +0200 Subject: [PATCH 04/17] CAMEL-24663: Camel TUI: Wait for the cancelled ACP turn to finish before sending the next prompt ACP lets the agent answer a cancelled session/prompt and keep streaming until that response. The client dropped the pending future on interrupt, so the next prompt installed its listener straight away and collected the old turn's late updates. The interrupted request now keeps its future and the next prompt waits up to 10s for it before installing its own listener. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- .../core/commands/tui/AcpAgentClient.java | 56 +++++++++++++++++-- .../core/commands/tui/AcpAgentClientTest.java | 51 +++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) 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 index fd1d9b02be032..db2c904ebf4a8 100644 --- 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 @@ -64,6 +64,8 @@ final class AcpAgentClient implements AutoCloseable { 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 { @@ -97,6 +99,10 @@ String label() { 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 future) { + } + static final class AcpException extends RuntimeException { private final int code; @@ -127,6 +133,7 @@ int code() { private volatile AcpException exitFailure; private volatile List availableCommands = List.of(); private volatile String currentSession; + private volatile CompletableFuture cancelledTurn; AcpAgentClient(InputStream fromAgent, OutputStream toAgent, Consumer diagnostics) { this(fromAgent, toAgent, null, diagnostics); @@ -270,9 +277,11 @@ String newSession(Path cwd, String mcpUrl, Duration timeout) { * 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) { - this.listenerSession = sessionId; - this.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); @@ -281,10 +290,14 @@ String prompt(String sessionId, String text, Listener listener) { JsonObject params = new JsonObject(); params.put("sessionId", sessionId); params.put("prompt", prompt); - JsonObject result = request("session/prompt", params, null); + 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"; } @@ -295,6 +308,29 @@ String prompt(String sessionId, String text, Listener listener) { } } + /** + * 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 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); @@ -341,6 +377,10 @@ private void awaitStreamClosed() { } 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 future = new CompletableFuture<>(); pending.put(id, future); @@ -367,17 +407,23 @@ JsonObject request(String method, JsonObject params, Duration timeout) { } throw e; } + return new Sent(id, future); + } + + private JsonObject await(Sent sent, String method, Duration timeout) { + CompletableFuture 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(id); + 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(); - pending.remove(id); throw new AcpException(INTERRUPTED, method + " interrupted"); } } 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 index 9c1499d6842ee..0f583c2f92fd6 100644 --- 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 @@ -22,6 +22,7 @@ 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; @@ -285,6 +286,56 @@ void interruptedPromptSendsCancelAndReturnsCancelled() throws Exception { assertEquals(1, agent.receivedCount("session/cancel")); } + @Test + 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())); + assertTrue(Thread.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 @EnabledOnOs({ OS.LINUX, OS.MAC }) void spawnedProcessExitReportsCodeAndStderr() throws Exception { From 50ef22511bef652d9269c68d737d1c6f99e01b0e Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 15:22:24 +0200 Subject: [PATCH 05/17] CAMEL-24663: Camel TUI: Accept the agent's command list sent while the ACP session is being created The Claude adapter advertises available_commands_update before it answers session/new, so after /clear the second session's commands arrived while currentSession still pointed at the first one and were dropped as foreign. newSession now clears the list before it asks, and the client remembers which session the stored list came from. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- .../core/commands/tui/AcpAgentClient.java | 10 ++++ .../core/commands/tui/AcpAgentClientTest.java | 48 ++++++++++++------- 2 files changed, 42 insertions(+), 16 deletions(-) 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 index db2c904ebf4a8..5ffa3fc2c0fed 100644 --- 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 @@ -132,6 +132,8 @@ int code() { private volatile Thread stderrThread; private volatile AcpException exitFailure; private volatile List 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 cancelledTurn; @@ -261,12 +263,19 @@ String newSession(Path cwd, String mcpUrl, Duration timeout) { 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(); 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; } @@ -566,6 +575,7 @@ private void handleNotification(String method, JsonObject params) { // an update from a session abandoned (for example after /clear) must not reach the new session return; } + commandsSession = updateSession; List commands = new ArrayList<>(); if (update.get("availableCommands") instanceof JsonArray array) { for (Object o : array) { 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 index 0f583c2f92fd6..fafe69b329834 100644 --- 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 @@ -392,26 +392,30 @@ void availableCommandsUpdateIsStoredEvenWithoutAPromptInFlight() { void commandsFromAnotherSessionAreIgnored() { client.initialize(T); String session = client.newSession(Path.of("."), "http://127.0.0.1:1/mcp", T); - JsonObject review = new JsonObject(); - review.put("name", "review"); - JsonArray reviewCommands = new JsonArray(); - reviewCommands.add(review); - JsonObject staleUpdate = new JsonObject(); - staleUpdate.put("sessionUpdate", "available_commands_update"); - staleUpdate.put("availableCommands", reviewCommands); - agent.sendNotification("session/update", update("sess-OLD", staleUpdate)); - JsonObject commit = new JsonObject(); - commit.put("name", "commit"); - JsonArray commitCommands = new JsonArray(); - commitCommands.add(commit); - JsonObject currentUpdate = new JsonObject(); - currentUpdate.put("sessionUpdate", "available_commands_update"); - currentUpdate.put("availableCommands", commitCommands); - agent.sendNotification("session/update", update(session, currentUpdate)); + 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); @@ -419,6 +423,18 @@ private static JsonObject update(String sessionId, JsonObject 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"); From 2e72914aed78d1f1c02e66efa23c9a7c277f9db3 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 15:24:09 +0200 Subject: [PATCH 06/17] CAMEL-24663: Camel TUI: Keep the ACP permission choices visible on short screens render() gave the header as many rows as it wanted and the option list the rest, which was zero on a small terminal: the popup asked for a decision with no choices on screen. The option rows are now reserved first and the header is clipped instead, title first. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- .../core/commands/tui/AcpPermissionPopup.java | 5 ++++- .../core/commands/tui/AcpPermissionPopupTest.java | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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 index bf7e93b4c23e2..aeeb893f63bb6 100644 --- 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 @@ -154,8 +154,11 @@ void render(Frame frame, Rect area) { .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 parts = Layout.vertical() - .constraints(Constraint.length(Math.min(headerRows, inner.height())), Constraint.fill()) + .constraints(Constraint.length(headerHeight), Constraint.fill()) .split(inner); List header = new ArrayList<>(); 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 index 7470992d4f508..0e4b27e9a58c9 100644 --- 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 @@ -72,6 +72,20 @@ void rendersTitleKindInputAndOptions() { 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(); From 585fc4f2968aa60e71289eb0936549cbcf3c679a Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 15:26:41 +0200 Subject: [PATCH 07/17] CAMEL-24663: Camel TUI: Scroll the Settings popup so the selected row stays visible on short terminals The dialog drew its 24 content lines from the top of the popup whatever the popup's height, so on an 80x25 terminal AI History, ACP Command and ACP Logos were drawn over the border or off it, and selecting them changed nothing on screen. The content now scrolls to keep the selected row inside the popup, and the rows outside it are not drawn. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- .../core/commands/tui/SettingsPopup.java | 41 ++++++++++++++++++- .../commands/tui/SettingsPopupRenderTest.java | 29 +++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) 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 eddd09b757c4f..4fa1b16ea0b1e 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 @@ -91,6 +91,10 @@ private static List buildAiProviderList() { 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; @@ -208,6 +212,7 @@ void open() { } } selectedRow = ROW_THEME; + scrollTop = 0; visible = true; } @@ -445,11 +450,23 @@ void render(Frame frame, Rect area) { .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); @@ -639,15 +656,34 @@ private static String stripControlChars(String s) { 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)); } @@ -659,6 +695,9 @@ private void renderFolder(Frame frame, int x, int y, int w, boolean active) { 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); } 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 1ffd0f4722eac..8e5ecbd1e288e 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 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,7 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.parallel.Isolated; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -87,4 +91,29 @@ void rendersTitleAndAllSettingRows(@TempDir Path tempDir) { assertTrue(rendered.contains("ACP Command"), "the ACP Command row should be shown"); assertTrue(rendered.contains("ACP Logos"), "the ACP Logos row should be shown"); } + + @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 Logos"), "the last rows do not fit in 25 lines"); + for (int i = 0; i < 19; 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 Logos"), rendered); + assertTrue(rendered.contains("AI History"), rendered); + assertFalse(rendered.contains("Theme:"), "the first rows scrolled out"); + } } From 967d791bcff92e300030698137ff6242ba5aebf9 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 15:29:04 +0200 Subject: [PATCH 08/17] CAMEL-24663: Camel TUI: Launch ACP presets through the executable resolved on PATH so Windows .cmd shims work The precheck accepted npx.cmd/npx.exe but the preset's command was passed as is, and ProcessBuilder only tries .exe on Windows: the check passed and the launch failed. isOnPath is now a thin wrapper over resolveExecutable, and spawnAcpAgent launches the file that was actually found. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- .../dsl/jbang/core/commands/tui/AiPanel.java | 10 ++++-- .../core/commands/tui/AiProviderSelector.java | 31 +++++++++++++------ .../commands/tui/AiProviderSelectorTest.java | 13 ++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) 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 8ef42272ae89d..5f05c3bb4b13c 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 @@ -3404,10 +3404,16 @@ boolean isPermissionPopupVisibleForTesting() { } private AcpAgentClient spawnAcpAgent(AiProviderSelector.AcpPreset preset, Path cwd) throws IOException { - if (!AiProviderSelector.isOnPath(preset.executable())) { + // 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()); } - return AcpAgentClient.spawn(preset.command(), cwd, line -> log(LogLevel.ERROR, "ACP", line)); + List 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) { 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 d7744e21b4b70..d73ec6253eabd 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 @@ -131,27 +131,40 @@ AcpPreset acpPreset(String provider, TuiSettings settings) { throw new IllegalArgumentException("Unknown ACP provider '" + provider + "'."); } - /** True when {@code executable} is an absolute path to an executable file or is found on the PATH. */ - static boolean isOnPath(String executable) { + /** + * 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 Files.isExecutable(direct); + return Files.isExecutable(direct) ? direct.toString() : null; } - String path = System.getenv("PATH"); if (path == null) { - return false; + return null; } for (String dir : path.split(File.pathSeparator)) { if (dir.isBlank()) { continue; } Path candidate = Path.of(dir).resolve(executable); - if (Files.isExecutable(candidate) || Files.isExecutable(Path.of(candidate + ".cmd")) - || Files.isExecutable(Path.of(candidate + ".exe"))) { - return true; + for (Path variant : List.of(candidate, Path.of(candidate + ".cmd"), Path.of(candidate + ".exe"))) { + if (Files.isExecutable(variant)) { + return variant.toString(); + } } } - return false; + return null; + } + + /** Same, on the process PATH: what {@code ProcessBuilder} must be given so a Windows shim is actually launched. */ + static String resolveExecutable(String executable) { + return resolveExecutable(executable, System.getenv("PATH")); + } + + /** True when {@code executable} is an absolute path to an executable file or is found on the PATH. */ + static boolean isOnPath(String executable) { + return resolveExecutable(executable) != null; } /** 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 7014a4c30097f..4f4538fca54cd 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; @@ -216,4 +217,16 @@ void isOnPathFindsShellButNotNonsense() { assertTrue(AiProviderSelector.isOnPath("sh")); assertFalse(AiProviderSelector.isOnPath("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)); + assertNull(AiProviderSelector.resolveExecutable("definitely-not-a-real-binary-42", path)); + } } From b09a2bbb852563bb0c09018e776f93be69fcf67b Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 15:30:57 +0200 Subject: [PATCH 09/17] CAMEL-24663: Camel TUI: Require Node.js 22 for the npx-based ACP presets @agentclientprotocol/claude-agent-acp 0.75.1 declares engines.node >= 22, so the install hint and the documentation asking for Node.js 18 sent users to a runtime the adapter refuses. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc | 2 +- .../camel/dsl/jbang/core/commands/tui/AiProviderSelector.java | 2 +- 2 files changed, 2 insertions(+), 2 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 817285ce48418..c50a94d2fca8d 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -1042,7 +1042,7 @@ Press *Ctrl+P* in the AI panel and pick one of the agents: | `acp:custom` | the value of `camel.tui.ai.acp.command` | depends on the agent |=== -The `npx` entries need Node.js 18 or newer. The agent starts with your first question; the first start can take a +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 logo (in terminals with Kitty, iTerm2 or Sixel graphics, for example Kitty, Ghostty, WezTerm, iTerm2) or a coloured glyph, the agent's name and version, the session id, the working directory and the number of commands it 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 d73ec6253eabd..4b208230e1d2e 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 @@ -38,7 +38,7 @@ 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 18 or newer (https://nodejs.org) and try again."; + 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, From daa0a6f2f7360a308be3497226f58d419d04cd53 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Wed, 9 Sep 2026 15:50:12 +0200 Subject: [PATCH 10/17] CAMEL-24663: Camel TUI: Address the Task 18 review findings - the permission handler's file-or-shell guard is now pinned by a test, and an unwired tool registry fails closed instead of falling back to a name pattern (the panel's tests wire the real registry through setToolRegistryForTesting) - the cancelled-turn test gets a @Timeout so a missed interrupt fails the build instead of wedging it, and no longer resets the interrupt flag from inside an assertion - newSession() forgets an owed cancelled turn, so a new session's first question is not delayed by the old one - SettingsPopup's divider count is a constant shared by render() and lineOf() - resolveExecutable() probes .cmd/.exe for an absolute path too - the ACP paragraph of the TUI docs is re-wrapped to the file's width Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018ppswyHx8nZfCvTTzZtkov --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 14 +++---- .../core/commands/tui/AcpAgentClient.java | 2 + .../dsl/jbang/core/commands/tui/AiPanel.java | 9 +---- .../core/commands/tui/AiProviderSelector.java | 20 +++++++--- .../core/commands/tui/SettingsPopup.java | 9 +++-- .../core/commands/tui/AcpAgentClientTest.java | 40 ++++++++++++++++++- .../core/commands/tui/AiPanelAcpTest.java | 30 ++++++++++++++ .../commands/tui/AiProviderSelectorTest.java | 1 + .../commands/tui/SettingsPopupRenderTest.java | 7 ++++ 9 files changed, 107 insertions(+), 25 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 c50a94d2fca8d..2a1115e1fd4e9 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -1042,13 +1042,13 @@ Press *Ctrl+P* in the AI panel and pick one of the agents: | `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 logo (in terminals with Kitty, iTerm2 or Sixel graphics, for example Kitty, Ghostty, WezTerm, iTerm2) or a -coloured glyph, the agent's name and version, the session id, the working directory and the number of commands it -advertises. `camel.tui.ai.acp.logos` (`auto`, `on`, `off`) forces or disables the logos; `auto` detects the terminal -from its environment variables. 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 `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 logo (in terminals with Kitty, iTerm2 or Sixel graphics, for example +Kitty, Ghostty, WezTerm, iTerm2) or a coloured glyph, the agent's name and version, the session id, the working +directory and the number of commands it advertises. `camel.tui.ai.acp.logos` (`auto`, `on`, `off`) forces or +disables the logos; `auto` detects the terminal from its environment variables. 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 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 index 5ffa3fc2c0fed..199a34251b770 100644 --- 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 @@ -267,6 +267,8 @@ String newSession(Path cwd, String mcpUrl, Duration timeout) { 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) { 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 5f05c3bb4b13c..3bbef0d139edb 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 @@ -43,7 +43,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; -import java.util.regex.Pattern; import dev.tamboui.layout.Alignment; import dev.tamboui.layout.Constraint; @@ -263,7 +262,6 @@ public void printf(String format, Object... args) { private static final Duration ACP_AUTH_TIMEOUT = Duration.ofSeconds(300); private static final String TUI_TOOL_PREFIX = "mcp__camel-tui__"; private static final Set FILE_OR_SHELL_KINDS = Set.of("edit", "delete", "move", "execute", "fetch"); - private static final Pattern TUI_TOOL_NAME = Pattern.compile("tui_[a-z0-9_]+"); private static final String AGENT_COMMAND_PREFIX = "/agent:"; private volatile AiProviderSelector.AcpPreset acpPreset; private volatile AcpAgentClient acpClient; @@ -1955,11 +1953,8 @@ private boolean isTuiTool(String name, String title, String kind) { private boolean isRegisteredTuiTool(String tool) { TuiToolRegistry registry = toolRegistry; - if (registry != null) { - return registry.getToolDefinitions().stream().anyMatch(td -> td.name().equals(tool)); - } - // no registry wired yet (tests, or before the MCP facade exists): accept the TUI's own naming scheme - return TUI_TOOL_NAME.matcher(tool).matches(); + // no registry wired yet: nothing is a known TUI tool, so the user is asked rather than the call approved + return registry != null && registry.getToolDefinitions().stream().anyMatch(td -> td.name().equals(tool)); } private static String firstOptionOfKind(List options, String kind) { 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 4b208230e1d2e..1def5287ad99a 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 @@ -138,7 +138,7 @@ AcpPreset acpPreset(String provider, TuiSettings settings) { static String resolveExecutable(String executable, String path) { Path direct = Path.of(executable); if (direct.isAbsolute()) { - return Files.isExecutable(direct) ? direct.toString() : null; + return firstExecutable(direct); } if (path == null) { return null; @@ -147,11 +147,19 @@ static String resolveExecutable(String executable, String path) { if (dir.isBlank()) { continue; } - Path candidate = Path.of(dir).resolve(executable); - for (Path variant : List.of(candidate, Path.of(candidate + ".cmd"), Path.of(candidate + ".exe"))) { - if (Files.isExecutable(variant)) { - return variant.toString(); - } + 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; 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 4fa1b16ea0b1e..ff42a9c683ddf 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 @@ -65,7 +65,9 @@ class SettingsPopup { private static final int ROW_AI_PROMPT_HISTORY = 17; private static final int ROW_AI_ACP_COMMAND = 18; private static final int ROW_AI_ACP_LOGOS = 19; - private static final int ROW_COUNT = 20; + static final int ROW_COUNT = 20; + /** 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" }; @@ -435,9 +437,8 @@ private void save() { } 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)); @@ -451,7 +452,7 @@ void render(Frame frame, Rect area) { frame.renderWidget(block, popup); int visibleLines = popup.height() - 2; - int contentLines = ROW_COUNT + dividers; + int contentLines = ROW_COUNT + DIVIDERS; int selectedLine = lineOf(selectedRow); if (selectedLine < scrollTop) { scrollTop = selectedLine; 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 index fafe69b329834..b62bda3cb3603 100644 --- 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 @@ -29,6 +29,7 @@ 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; @@ -287,6 +288,7 @@ void interruptedPromptSendsCancelAndReturnsCancelled() throws Exception { } @Test + @Timeout(30) void nextPromptWaitsForTheCancelledTurnToFinish() throws Exception { CountDownLatch lateSent = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); @@ -322,7 +324,8 @@ void nextPromptWaitsForTheCancelledTurnToFinish() throws Exception { esc.setDaemon(true); esc.start(); assertEquals("cancelled", client.prompt(session, "one", new NoopListener())); - assertTrue(Thread.interrupted(), "prompt leaves the caller interrupted"); + 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)); @@ -336,6 +339,41 @@ void nextPromptWaitsForTheCancelledTurnToFinish() throws Exception { 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 { 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 index b14afc578b9ef..8c81e9dff6641 100644 --- 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 @@ -99,8 +99,14 @@ private static String lastEntry(AiPanel panel, AiRole role) { /** Panel wired to a fresh fake agent, with the Claude preset selected. */ private AiPanel acpPanel() throws IOException { + return acpPanel(new TuiToolRegistry(null)); + } + + /** Same, with the tool registry the panel should check tool names against ({@code null} = none wired yet). */ + private AiPanel acpPanel(TuiToolRegistry registry) throws IOException { agent = new FakeAcpAgent(); AiPanel panel = new AiPanel(); + panel.setToolRegistryForTesting(registry); panel.setAcpClientFactoryForTesting((preset, cwd) -> { AcpAgentClient client = new AcpAgentClient(agent.clientInput(), agent.clientOutput(), s -> { }); @@ -501,6 +507,30 @@ void tuiToolCallsAreAutoApprovedByTitle() throws Exception { assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-always)")); } + @Test + void withoutAToolRegistryNothingIsAutoApproved() throws Exception { + AiPanel panel = acpPanel(null); + askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state")); + 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)"), "no registry means no known TUI tool"); + } + + @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(); 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 4f4538fca54cd..d6a4d8975f3d2 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 @@ -227,6 +227,7 @@ void resolveExecutableReturnsTheFileItFoundIncludingACmdShim(@TempDir Path tempD 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/SettingsPopupRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopupRenderTest.java index 8e5ecbd1e288e..2ef18b91f58c3 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 @@ -32,6 +32,7 @@ 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; @@ -92,6 +93,12 @@ void rendersTitleAndAllSettingRows(@TempDir Path tempDir) { assertTrue(rendered.contains("ACP Logos"), "the ACP Logos 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); From ac66fb7af08eb12743f054b68139e1f391216757 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Thu, 10 Sep 2026 09:25:32 +0200 Subject: [PATCH 11/17] CAMEL-24663: Camel TUI: Approve only read-only TUI tools silently for an ACP agent, ask for the rest Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014wKixhKe8MuTz8LpsLV1pL --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 5 +++- .../dsl/jbang/core/commands/tui/AiPanel.java | 25 ++++++++----------- .../core/commands/tui/TuiToolRegistry.java | 12 +++++++++ .../core/commands/tui/AiPanelAcpTest.java | 15 ++++------- .../tui/TuiToolRegistryCoreToolsTest.java | 19 ++++++++++++++ 5 files changed, 50 insertions(+), 26 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 2a1115e1fd4e9..b6a29753be1c4 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -1055,7 +1055,10 @@ required. *F2* -> _MCP Info_ shows the port and the tool calls the agent makes. `--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 own tools are approved without asking. For anything else the agent wants to do, +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 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 3bbef0d139edb..3967683a3ea56 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 @@ -1890,9 +1890,9 @@ synchronized void finish(String stopReason) { } /** - * Policy A from the design: calls to the camel-tui MCP server are approved silently (allow-always preferred), - * everything else is put in front of the user. Runs on the ACP request thread and blocks until the user answers or - * the turn is cancelled. + * 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 @@ -1908,7 +1908,7 @@ public String decide(JsonObject toolCall, List options) { if (optionId == null && !options.isEmpty()) { optionId = options.get(0).getString("optionId"); } - log(LogLevel.TOOL, "Auto-approved TUI tool", title); + log(LogLevel.TOOL, "Auto-approved read-only TUI tool", title); return optionId; } CompletableFuture decision = new CompletableFuture<>(); @@ -1930,10 +1930,11 @@ public String decide(JsonObject toolCall, List options) { } /** - * Only a call to a tool the TUI itself registers counts as a camel-tui tool: the name the Claude adapter sends - * ({@code mcp__camel-tui__tui_get_state}) or a title of the form {@code tui_get_state (camel-tui MCP Server)}. - * Kinds that touch files or run commands never qualify, whatever the title says: a path containing "camel-tui" is - * not a tool identity. + * 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)) { @@ -1948,13 +1949,7 @@ private boolean isTuiTool(String name, String title, String kind) { tool = title.substring(0, paren).strip(); } } - return tool != null && isRegisteredTuiTool(tool); - } - - private boolean isRegisteredTuiTool(String tool) { - TuiToolRegistry registry = toolRegistry; - // no registry wired yet: nothing is a known TUI tool, so the user is asked rather than the call approved - return registry != null && registry.getToolDefinitions().stream().anyMatch(td -> td.name().equals(tool)); + return tool != null && TuiToolRegistry.READ_ONLY_TOOLS.contains(tool); } private static String firstOptionOfKind(List options, String kind) { 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 490fdfc383e2f..cb8b509d7bca4 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 @@ void setLaunchManager(LaunchManager launchManager) { "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 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/AiPanelAcpTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelAcpTest.java index 8c81e9dff6641..c6aaf69fa2dbc 100644 --- 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 @@ -99,14 +99,9 @@ private static String lastEntry(AiPanel panel, AiRole role) { /** Panel wired to a fresh fake agent, with the Claude preset selected. */ private AiPanel acpPanel() throws IOException { - return acpPanel(new TuiToolRegistry(null)); - } - - /** Same, with the tool registry the panel should check tool names against ({@code null} = none wired yet). */ - private AiPanel acpPanel(TuiToolRegistry registry) throws IOException { agent = new FakeAcpAgent(); AiPanel panel = new AiPanel(); - panel.setToolRegistryForTesting(registry); + panel.setToolRegistryForTesting(new TuiToolRegistry(null)); panel.setAcpClientFactoryForTesting((preset, cwd) -> { AcpAgentClient client = new AcpAgentClient(agent.clientInput(), agent.clientOutput(), s -> { }); @@ -508,14 +503,14 @@ void tuiToolCallsAreAutoApprovedByTitle() throws Exception { } @Test - void withoutAToolRegistryNothingIsAutoApproved() throws Exception { - AiPanel panel = acpPanel(null); - askPermissionDuringPrompt(permissionParams("mcp__camel-tui__tui_get_state", "tui_get_state")); + 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)"), "no registry means no known TUI tool"); + assertTrue(hasEntry(panel, AiRole.SYSTEM, "(stopped: opt-reject)"), "tui_control is not read-only"); } @Test 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 b38d749ce48fe..ec79299aa6820 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 @@ void coreSetLeavesOutScreenAutomationTools() { assertFalse(TuiToolRegistry.CORE_TOOLS.stream().anyMatch(automation::contains)); } + + @Test + void readOnlyToolsAreRegisteredAndCoverEveryGetter() { + TuiToolRegistry registry = new TuiToolRegistry(null); + Set 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 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"); + } + } } From 10fb63ec28fb926b8783192b7c888d33803fbfa2 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Thu, 10 Sep 2026 09:29:39 +0200 Subject: [PATCH 12/17] CAMEL-24663: Camel TUI: Show the ACP agent's context usage instead of mislabelling it as a session total Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014wKixhKe8MuTz8LpsLV1pL --- .../dsl/jbang/core/commands/tui/AiPanel.java | 53 ++++++++++++++++--- .../core/commands/tui/AiPanelAcpTest.java | 36 +++++++++++-- 2 files changed, 78 insertions(+), 11 deletions(-) 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 3967683a3ea56..1ff8d605c6cb3 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 @@ -195,6 +195,9 @@ record LogEntry(String timestamp, LogLevel level, String message, String detail) 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. */ @@ -589,6 +592,8 @@ private void closeAcpClient() { 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. @@ -1750,6 +1755,8 @@ private AcpAgentClient ensureAcpSession() throws IOException { if (acpSessionId == null) { acpSessionId = openAcpSession(agent, acpAgentInfo, acpMcpUrl, acpCwd); acpPreambleSent = false; + acpContextUsed = 0; + acpContextSize = 0; log(LogLevel.RESULT, "ACP session", acpSessionId); } return agent; @@ -1821,6 +1828,7 @@ private final class AcpTurnListener implements AcpAgentClient.Listener { private final Map toolLines = new HashMap<>(); private final Map toolTitles = new HashMap<>(); private volatile long usedTokens; + private volatile long contextSize; @Override public synchronized void onTextChunk(String chunk) { @@ -1861,19 +1869,25 @@ public synchronized void onToolCallUpdate(String toolCallId, String status, Stri @Override public synchronized void onUsage(long used, long size) { usedTokens = used; + contextSize = size; } synchronized void finish(String stopReason) { long elapsed = System.currentTimeMillis() - thinkingStartTime; - int tokens = (int) Math.min(Integer.MAX_VALUE, usedTokens); + // 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 (tokens > 0) { + if (usedTokens > 0) { AiProviderSelector.AcpPreset preset = acpPreset; - sessionTotalTokens = tokens; + sessionTotalTokens += tokens; usageHistory.add(new AiUsageEntry( acpLabel(), preset != null ? preset.id() : "acp", 0, 0, tokens, elapsed, stopReason, Instant.now())); @@ -1991,7 +2005,7 @@ void render(Frame frame, Rect area) { } else if (acpPreset != null) { titleLine = Line.from( Span.styled(" AI ", Style.EMPTY.bold()), - Span.styled("· " + acpLabel() + " ", Style.EMPTY.dim())); + Span.styled("· " + acpLabel() + describeAcpContextSuffix() + " ", Style.EMPTY.dim())); } else if (sessionTotalTokens > 0) { titleLine = Line.from( Span.styled(" AI ", Style.EMPTY.bold()), @@ -3125,6 +3139,20 @@ String describeContext() { 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 @@ -3147,8 +3175,15 @@ private String describeAcpContext() { 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'); - sb.append("Tokens reported by the agent so far: ").append(LlmClient.formatTokens(sessionTotalTokens)) - .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(); } @@ -3348,6 +3383,8 @@ void clearConversation() { // 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) { @@ -3422,6 +3459,10 @@ int sessionTotalTokensForTesting() { return sessionTotalTokens; } + long[] acpContextForTesting() { + return new long[] { acpContextUsed, acpContextSize }; + } + int messageCountForTesting() { return messages == null ? 0 : messages.size(); } 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 index c6aaf69fa2dbc..0ae483613cd29 100644 --- 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 @@ -30,6 +30,7 @@ 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; @@ -40,6 +41,7 @@ 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; @@ -454,21 +456,45 @@ void refusalStopReasonIsAnError() throws Exception { assertTrue(hasEntry(panel, AiRole.ERROR, "refused")); } - @Test - void usageUpdateFeedsTheTokenCounter() throws Exception { - AiPanel panel = acpPanel(); + /** 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", 1234); - usage.put("size", 200000); + 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 From ed1f0f3135137b30e811d0aca4769e29fb4e4f6f Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Thu, 10 Sep 2026 09:30:50 +0200 Subject: [PATCH 13/17] CAMEL-24663: Camel TUI: Remove the unused isOnPath helper superseded by resolveExecutable Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014wKixhKe8MuTz8LpsLV1pL --- .../dsl/jbang/core/commands/tui/AiProviderSelector.java | 5 ----- .../jbang/core/commands/tui/AiProviderSelectorTest.java | 7 ++++--- 2 files changed, 4 insertions(+), 8 deletions(-) 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 1def5287ad99a..e3f38cc7d1f4f 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 @@ -170,11 +170,6 @@ static String resolveExecutable(String executable) { return resolveExecutable(executable, System.getenv("PATH")); } - /** True when {@code executable} is an absolute path to an executable file or is found on the PATH. */ - static boolean isOnPath(String executable) { - return resolveExecutable(executable) != null; - } - /** * 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 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 d6a4d8975f3d2..b9bfe13f746eb 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 @@ -33,6 +33,7 @@ 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; @@ -213,9 +214,9 @@ void acpPresetResolvesCommandsAndCustomCommand(@TempDir Path tempDir) { @Test @EnabledOnOs({ OS.LINUX, OS.MAC }) - void isOnPathFindsShellButNotNonsense() { - assertTrue(AiProviderSelector.isOnPath("sh")); - assertFalse(AiProviderSelector.isOnPath("definitely-not-a-real-binary-42")); + void resolveExecutableFindsShellButNotNonsense() { + assertNotNull(AiProviderSelector.resolveExecutable("sh")); + assertNull(AiProviderSelector.resolveExecutable("definitely-not-a-real-binary-42")); } @Test From a79fe2cceef5951ec63c66e97c356f28d4ac0204 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Thu, 10 Sep 2026 09:32:26 +0200 Subject: [PATCH 14/17] CAMEL-24663: Camel TUI: Cache a missing ACP logo instead of re-reading the resource every frame Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014wKixhKe8MuTz8LpsLV1pL --- .../core/commands/tui/AcpHeaderStrip.java | 44 ++++++++++++------- .../core/commands/tui/AcpHeaderStripTest.java | 1 + 2 files changed, 30 insertions(+), 15 deletions(-) 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 index 5b4a2885a41fb..c1c70049188fb 100644 --- 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 @@ -114,16 +114,20 @@ ImageData logoFor(String logo) { if (logo == null) { return null; } - return logos.computeIfAbsent(logo, name -> { - try (InputStream in = AcpHeaderStrip.class.getResourceAsStream("/tui/logos/" + name + ".png")) { - if (in == null) { - return null; - } - return ImageData.fromBytes(in.readAllBytes()).resize(LOGO_PIXELS, LOGO_PIXELS); - } catch (IOException | RuntimeException e) { - return null; + // computeIfAbsent does not store a null, so a missing logo would be looked up again on every frame + if (logos.containsKey(logo)) { + return logos.get(logo); + } + ImageData image = null; + try (InputStream in = AcpHeaderStrip.class.getResourceAsStream("/tui/logos/" + logo + ".png")) { + if (in != null) { + image = ImageData.fromBytes(in.readAllBytes()).resize(LOGO_PIXELS, LOGO_PIXELS); } - }); + } catch (IOException | RuntimeException e) { + image = null; + } + logos.put(logo, image); + return image; } /** Raw PNG bytes of the preset logo (cached; null when missing or unreadable). Kitty scales them itself. */ @@ -131,13 +135,19 @@ byte[] logoBytes(String logo) { if (logo == null) { return null; } - return logoBytes.computeIfAbsent(logo, name -> { - try (InputStream in = AcpHeaderStrip.class.getResourceAsStream("/tui/logos/" + name + ".png")) { - return in != null ? in.readAllBytes() : null; - } catch (IOException e) { - return null; + if (logoBytes.containsKey(logo)) { + return logoBytes.get(logo); + } + byte[] png = null; + try (InputStream in = AcpHeaderStrip.class.getResourceAsStream("/tui/logos/" + logo + ".png")) { + if (in != null) { + png = in.readAllBytes(); } - }); + } catch (IOException e) { + png = null; + } + logoBytes.put(logo, png); + return png; } void render(Frame frame, Rect area, Model model, LogoMode mode) { @@ -308,6 +318,10 @@ static String kittyDelete(int imageId) { return APC + "a=d,d=i,i=" + imageId + ",q=2" + ST; } + boolean isLogoCachedForTesting(String logo) { + return logoBytes.containsKey(logo); + } + Rect lastLogoRectForTesting() { return lastLogoRect; } 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 index cac2401bf5e17..cf119b4bcd57e 100644 --- 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 @@ -167,6 +167,7 @@ void logoBytesAreTheFullPng() throws Exception { assertTrue(png.length > 1000); assertEquals((byte) 0x89, png[0]); assertNull(strip.logoBytes("nope")); + assertTrue(strip.isLogoCachedForTesting("nope"), "a missing logo is not looked up again on every frame"); } @Test From f13ea8bef72a42e0545cb7cc7a33f72d4c67bcea Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Thu, 10 Sep 2026 09:33:34 +0200 Subject: [PATCH 15/17] CAMEL-24663: Camel TUI: Say what the ACP reader ignores when a line is not a JSON-RPC object Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014wKixhKe8MuTz8LpsLV1pL --- .../camel/dsl/jbang/core/commands/tui/AcpAgentClient.java | 2 +- .../dsl/jbang/core/commands/tui/AcpAgentClientTest.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 index 199a34251b770..d3466bdc601f1 100644 --- 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 @@ -471,7 +471,7 @@ private void readLoop() { } JsonObject msg = Jsoner.deserialize(line, (JsonObject) null); if (msg == null || !msg.containsKey("jsonrpc")) { - diagnostics.accept("Ignoring malformed line from agent: " + abbreviate(line)); + diagnostics.accept("Ignoring line that is not a JSON-RPC object: " + abbreviate(line)); continue; } try { 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 index b62bda3cb3603..bc77f198f9258 100644 --- 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 @@ -94,8 +94,10 @@ void malformedLineIsSkippedAndReported() { agent.sendRaw("[1,2,3]"); AcpAgentClient.AgentInfo info = client.initialize(T); assertEquals(1, info.protocolVersion()); - assertTrue(diagnostics.stream().anyMatch(d -> d.contains("this is not json")), diagnostics.toString()); - assertTrue(diagnostics.stream().anyMatch(d -> d.contains("[1,2,3]")), diagnostics.toString()); + 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 From 3531fd1bcaeb5ffb7f32264a7f91cde3b4f56cbb Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Thu, 10 Sep 2026 09:37:18 +0200 Subject: [PATCH 16/17] CAMEL-24663: Camel TUI: Say in /context that only read-only camel-tui tools are approved automatically Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014wKixhKe8MuTz8LpsLV1pL --- .../org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1ff8d605c6cb3..fd1f39e53eb5c 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 @@ -3166,7 +3166,7 @@ private String describeAcpContext() { .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(" (camel-tui tools are approved automatically)\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"); } From b7a5896ac251bba1900d4ad7f6eecd61948cdb17 Mon Sep 17 00:00:00 2001 From: Luigi De Masi Date: Thu, 10 Sep 2026 10:21:56 +0200 Subject: [PATCH 17/17] CAMEL-24663: Camel TUI: Drop the vendor logos from the ACP header and keep the coloured glyph The PMC ruled on apache/camel#26244 that third-party vendor logos cannot be included or shown. The six agent PNGs are removed together with all the image and kitty graphics code that drew them; the header keeps the per-preset glyph and colour, which are plain Unicode shapes, and names the vendor in text. The camel.tui.ai.acp.logos setting and its Settings row go away with them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014wKixhKe8MuTz8LpsLV1pL --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 9 +- .../core/commands/tui/AcpHeaderStrip.java | 314 +----------------- .../dsl/jbang/core/commands/tui/AiPanel.java | 18 +- .../core/commands/tui/AiProviderSelector.java | 20 +- .../core/commands/tui/SettingsPopup.java | 33 +- .../jbang/core/commands/tui/TuiSettings.java | 12 - .../src/main/resources/tui/logos/bob.png | Bin 10743 -> 0 bytes .../src/main/resources/tui/logos/claude.png | Bin 14702 -> 0 bytes .../src/main/resources/tui/logos/codex.png | Bin 9537 -> 0 bytes .../src/main/resources/tui/logos/dsh.png | Bin 8994 -> 0 bytes .../src/main/resources/tui/logos/opencode.png | Bin 918 -> 0 bytes .../src/main/resources/tui/logos/qwen.png | Bin 14480 -> 0 bytes .../core/commands/tui/AcpHeaderStripTest.java | 129 +------ .../core/commands/tui/AiPanelAcpTest.java | 5 - .../commands/tui/AiProviderSelectorTest.java | 2 - .../commands/tui/SettingsPopupRenderTest.java | 7 +- .../core/commands/tui/SettingsPopupTest.java | 22 -- .../core/commands/tui/TuiSettingsTest.java | 2 - 18 files changed, 38 insertions(+), 535 deletions(-) delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/bob.png delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/claude.png delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/codex.png delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/dsh.png delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/opencode.png delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/qwen.png 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 b6a29753be1c4..ac2c78c22f4a7 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -792,8 +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`), and -`camel.tui.ai.acp.logos` (`auto`, `on`, `off`) controls the agent logos in the panel header. +`camel.tui.ai.acp.command` sets the command line of the custom ACP agent (`acp:custom`). ==== Using Ollama (local, no API key) @@ -1044,10 +1043,8 @@ Press *Ctrl+P* in the AI panel and pick one of the agents: 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 logo (in terminals with Kitty, iTerm2 or Sixel graphics, for example -Kitty, Ghostty, WezTerm, iTerm2) or a coloured glyph, the agent's name and version, the session id, the working -directory and the number of commands it advertises. `camel.tui.ai.acp.logos` (`auto`, `on`, `off`) forces or -disables the logos; `auto` detects the terminal from its environment variables. Use *F2* -> _Settings_ to make an +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 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 index c1c70049188fb..c015fe50709a3 100644 --- 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 @@ -16,198 +16,39 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; -import java.util.Base64; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import dev.tamboui.buffer.Buffer; -import dev.tamboui.image.Image; -import dev.tamboui.image.ImageData; -import dev.tamboui.image.ImageScaling; -import dev.tamboui.image.capability.TerminalImageCapabilities; -import dev.tamboui.image.capability.TerminalImageProtocol; -import dev.tamboui.layout.Constraint; -import dev.tamboui.layout.Layout; import dev.tamboui.layout.Rect; import dev.tamboui.style.Color; import dev.tamboui.style.Style; import dev.tamboui.terminal.Frame; -import dev.tamboui.terminal.FrameInternal; import dev.tamboui.text.Line; import dev.tamboui.text.Span; -import dev.tamboui.widget.RawOutputCapable; -import dev.tamboui.widget.Widget; import dev.tamboui.widgets.paragraph.Paragraph; /** - * Two-row header shown under the AI panel title while an ACP session is open: the agent's logo (native terminal - * graphics only) or a coloured glyph, the preset and agent labels, and a dimmed line with session, working directory - * and command count. ACP agents run headless and never draw a start screen of their own. + * 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; - private static final int LOGO_COLUMNS = 5; - private static final int LOGO_PIXELS = 64; - private static final long RESEND_INTERVAL_MS = 2_000; - private static final String APC = "\033_G"; - private static final String ST = "\033\\"; - private static final int KITTY_CHUNK = 4096; - private static final int KITTY_ID_BASE = 0x43_4D_00; - enum LogoMode { - AUTO, - ON, - OFF; - - static LogoMode parse(String value) { - if (value == null) { - return AUTO; - } - try { - return valueOf(value.trim().toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException e) { - return AUTO; - } - } - } - - record Model(String presetLabel, String glyph, Color color, String logo, String agentLabel, String sessionId, - Path cwd, int commandCount) { - } - - private final TerminalImageCapabilities capabilities; - private final Map logos = new HashMap<>(); - private final Map logoBytes = new HashMap<>(); - private final Set uploaded = new HashSet<>(); - private Rect lastLogoRect; - private long lastLogoSentAt; - private int placedImageId; - - AcpHeaderStrip() { - this(TerminalImageCapabilities.detect()); - } - - AcpHeaderStrip(TerminalImageCapabilities capabilities) { - this.capabilities = capabilities; + record Model(String presetLabel, String glyph, Color color, String agentLabel, String sessionId, Path cwd, + int commandCount) { } - boolean logosEnabled(LogoMode mode) { - return switch (mode) { - case ON -> true; - case OFF -> false; - default -> capabilities.supportsNativeImages(); - }; - } - - /** The preset's logo scaled to a small square, cached; null when there is no such resource or it cannot be read. */ - ImageData logoFor(String logo) { - if (logo == null) { - return null; - } - // computeIfAbsent does not store a null, so a missing logo would be looked up again on every frame - if (logos.containsKey(logo)) { - return logos.get(logo); - } - ImageData image = null; - try (InputStream in = AcpHeaderStrip.class.getResourceAsStream("/tui/logos/" + logo + ".png")) { - if (in != null) { - image = ImageData.fromBytes(in.readAllBytes()).resize(LOGO_PIXELS, LOGO_PIXELS); - } - } catch (IOException | RuntimeException e) { - image = null; - } - logos.put(logo, image); - return image; - } - - /** Raw PNG bytes of the preset logo (cached; null when missing or unreadable). Kitty scales them itself. */ - byte[] logoBytes(String logo) { - if (logo == null) { - return null; - } - if (logoBytes.containsKey(logo)) { - return logoBytes.get(logo); - } - byte[] png = null; - try (InputStream in = AcpHeaderStrip.class.getResourceAsStream("/tui/logos/" + logo + ".png")) { - if (in != null) { - png = in.readAllBytes(); - } - } catch (IOException e) { - png = null; - } - logoBytes.put(logo, png); - return png; - } - - void render(Frame frame, Rect area, Model model, LogoMode mode) { - render(frame, area, model, mode, null); - } - - void renderForTesting(Rect area, Buffer buffer, OutputStream raw, Model model, LogoMode mode) { - render(Frame.forTesting(buffer), area, model, mode, raw); - } - - private void render(Frame frame, Rect area, Model model, LogoMode mode, OutputStream rawOverride) { + void render(Frame frame, Rect area, Model model) { if (area.height() < ROWS || area.width() < 20) { return; } - boolean enabled = logosEnabled(mode); - boolean kitty = capabilities.supports(TerminalImageProtocol.KITTY); - byte[] png = enabled && kitty ? logoBytes(model.logo()) : null; - ImageData logo = enabled && !kitty ? logoFor(model.logo()) : null; - boolean hasLogo = png != null || logo != null; - Rect textArea = area; - if (hasLogo) { - List parts = Layout.horizontal() - .constraints(Constraint.length(LOGO_COLUMNS), Constraint.length(1), Constraint.fill()) - .split(area); - if (png != null) { - renderKittyLogo(frame, parts.get(0), kittyImageId(model.logo()), png, rawOverride); - } else { - renderLogo(frame, parts.get(0), logo); - } - textArea = parts.get(2); - } else { - lastLogoRect = null; - } Style accent = Style.EMPTY.fg(model.color()).bold(); String title = model.presetLabel() + " · " + model.agentLabel(); - Line first = hasLogo - ? Line.from(Span.styled(title, accent)) - : Line.from(Span.styled(model.glyph() + " ", accent), Span.styled(title, accent)); - Line second = Line.from(Span.styled((hasLogo ? "" : " ") + metaLine(model), Style.EMPTY.dim())); - frame.renderWidget(Paragraph.from(first), new Rect(textArea.x(), textArea.y(), textArea.width(), 1)); - frame.renderWidget(Paragraph.from(second), new Rect(textArea.x(), textArea.y() + 1, textArea.width(), 1)); - } - - /** - * Native protocols re-transmit the picture on every render, so the logo is sent only when its cell rectangle - * changed or every RESEND_INTERVAL_MS, which keeps it visible after the terminal repaints without flooding it. - */ - private void renderLogo(Frame frame, Rect logoArea, ImageData logo) { - long now = System.currentTimeMillis(); - if (logoArea.equals(lastLogoRect) && now - lastLogoSentAt < RESEND_INTERVAL_MS) { - return; - } - Image image = Image.builder() - .data(logo) - .scaling(ImageScaling.FIT) - .protocol(capabilities.bestProtocol()) - .build(); - frame.renderWidget(image, logoArea); - lastLogoRect = logoArea; - lastLogoSentAt = now; + 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) { @@ -233,139 +74,4 @@ static String homeRelative(Path path) { return value; } - /** - * Kitty keeps the uploaded picture, so a frame only costs a placement command: no re-upload, no flash, and q=2 - * stops the terminal from answering on the input stream the TUI reads keys from. - */ - private void renderKittyLogo(Frame frame, Rect logoArea, int imageId, byte[] png, OutputStream rawOverride) { - KittyLogoWidget widget = new KittyLogoWidget(imageId, png); - if (rawOverride != null) { - widget.render(logoArea, frame.buffer(), rawOverride); - } else { - frame.renderWidget(widget, logoArea); - } - lastLogoRect = logoArea; - } - - /** - * Drops the kitty placement when the header stops being drawn; a no-op for every other terminal. Written straight - * to the raw stream rather than through a widget: a widget would register its area with the frame, and TamboUI - * would then blank that area with a space on the next frame that renders no raw output. - */ - void hide(Frame frame) { - hide(FrameInternal.rawOutput(frame)); - } - - void hideForTesting(OutputStream raw) { - hide(raw); - } - - private void hide(OutputStream rawOutput) { - if (rawOutput == null || placedImageId == 0) { - return; - } - try { - rawOutput.write(kittyDelete(placedImageId).getBytes(StandardCharsets.US_ASCII)); - rawOutput.flush(); - } catch (IOException e) { - // the terminal is gone, there is nothing left to clean up - } - placedImageId = 0; - lastLogoRect = null; - } - - /** - * A stable, positive kitty image id per logo name, offset from a "CM" base so it does not clash with the ids of - * other programs sharing the terminal. The range is 16 bits wide: 8 would put claude and codex on the same id. - */ - static int kittyImageId(String logo) { - return KITTY_ID_BASE + Math.floorMod(logo.hashCode(), 0xFFFF) + 1; - } - - /** Transmits the PNG under an image id without displaying it, in chunks of at most KITTY_CHUNK base64 bytes. */ - static String kittyTransmit(int imageId, byte[] png) { - String data = Base64.getEncoder().encodeToString(png); - StringBuilder sb = new StringBuilder(); - for (int offset = 0, n = 0; offset < data.length() || n == 0; n++) { - int end = Math.min(offset + KITTY_CHUNK, data.length()); - boolean more = end < data.length(); - sb.append(APC); - if (n == 0) { - sb.append("a=t,f=100,t=d,i=").append(imageId).append(",q=2,m=").append(more ? 1 : 0).append(';'); - } else { - sb.append("m=").append(more ? 1 : 0).append(';'); - } - sb.append(data, offset, end).append(ST); - offset = end; - if (!more) { - break; - } - } - return sb.toString(); - } - - /** - * Places the already transmitted image in the given cell box; placement id 1 replaces the previous placement and - * C=1 keeps kitty from moving the cursor afterwards. - */ - static String kittyPlace(int imageId, Rect area) { - return "\033[" + (area.y() + 1) + ";" + (area.x() + 1) + "H" - + APC + "a=p,i=" + imageId + ",p=1,c=" + area.width() + ",r=" + area.height() + ",C=1,q=2" + ST; - } - - /** Deletes the placements of an image; the transmitted data stays, so the next frame only places it again. */ - static String kittyDelete(int imageId) { - return APC + "a=d,d=i,i=" + imageId + ",q=2" + ST; - } - - boolean isLogoCachedForTesting(String logo) { - return logoBytes.containsKey(logo); - } - - Rect lastLogoRectForTesting() { - return lastLogoRect; - } - - boolean hasPlacementForTesting() { - return placedImageId != 0; - } - - /** - * Uploads the logo once per image id and then only places it. A widget because that is how TamboUI hands out the - * terminal's raw stream; it draws nothing into the buffer. - */ - private final class KittyLogoWidget implements Widget, RawOutputCapable { - - private final int imageId; - private final byte[] png; - - KittyLogoWidget(int imageId, byte[] png) { - this.imageId = imageId; - this.png = png; - } - - @Override - public void render(Rect area, Buffer buffer) { - render(area, buffer, null); - } - - @Override - public void render(Rect area, Buffer buffer, OutputStream rawOutput) { - if (rawOutput == null) { - return; - } - boolean fresh = uploaded.add(imageId); - try { - if (fresh) { - rawOutput.write(kittyTransmit(imageId, png).getBytes(StandardCharsets.US_ASCII)); - } - rawOutput.write(kittyPlace(imageId, area).getBytes(StandardCharsets.US_ASCII)); - rawOutput.flush(); - placedImageId = imageId; - } catch (IOException e) { - uploaded.remove(imageId); - } - } - } - } 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 fd1f39e53eb5c..02eee700d536a 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 @@ -271,8 +271,7 @@ public void printf(String format, Object... args) { private volatile AcpAgentClient.AgentInfo acpAgentInfo; private volatile String acpSessionId; private volatile boolean acpPreambleSent; - private volatile AcpHeaderStrip.LogoMode acpLogoMode = AcpHeaderStrip.LogoMode.AUTO; - private AcpHeaderStrip acpHeader = new AcpHeaderStrip(); + private final AcpHeaderStrip acpHeader = new AcpHeaderStrip(); private String acpMcpUrl; private Path acpCwd; private AcpClientFactory acpClientFactory = this::spawnAcpAgent; @@ -503,7 +502,6 @@ private void initClient() { try { TuiSettings settings = TuiSettings.load(); acpPreset = providerSelector.acpPreset(provider, settings); - acpLogoMode = AcpHeaderStrip.LogoMode.parse(settings.getAiAcpLogos()); initError = null; } catch (IllegalArgumentException e) { acpPreset = null; @@ -555,7 +553,6 @@ private void applyProviderChoice(AiProviderSwitchPopup.ProviderChoice choice) { try { TuiSettings settings = TuiSettings.load(); acpPreset = providerSelector.acpPreset(choice.provider(), settings); - acpLogoMode = AcpHeaderStrip.LogoMode.parse(settings.getAiAcpLogos()); } catch (IllegalArgumentException e) { acpPreset = null; sessionProviderChoice = null; @@ -1793,8 +1790,7 @@ private AcpHeaderStrip.Model acpHeaderModel() { AcpAgentClient agent = acpClient; int commands = agent != null ? agent.availableCommands().size() : 0; return new AcpHeaderStrip.Model( - preset.label(), preset.glyph(), preset.color(), preset.logo(), acpLabel(), - acpSessionId, acpCwd, commands); + preset.label(), preset.glyph(), preset.color(), acpLabel(), acpSessionId, acpCwd, commands); } private Path acpWorkingDir() { @@ -2023,12 +2019,10 @@ void render(Frame frame, Rect area) { frame.renderWidget(block, area); Rect inner = block.inner(area); if (inner.height() < 2) { - acpHeader.hide(frame); return; } if (statsView) { - acpHeader.hide(frame); renderStats(frame, inner); if (providerSwitchPopup.isVisible()) { providerSwitchPopup.render(frame, inner); @@ -2047,12 +2041,10 @@ void render(Frame frame, Rect area) { List top = Layout.vertical() .constraints(Constraint.length(AcpHeaderStrip.ROWS), Constraint.length(1), Constraint.fill()) .split(inner); - acpHeader.render(frame, top.get(0), acpHeaderModel(), acpLogoMode); + 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); - } else { - acpHeader.hide(frame); } // Split the body area: conversation (fill) + optional slash hints + separator (1 row) + input (1 row per line) @@ -3410,10 +3402,6 @@ boolean isAcpProviderForTesting() { return acpPreset != null; } - void setAcpHeaderForTesting(AcpHeaderStrip strip) { - this.acpHeader = strip; - } - void setAcpClientFactoryForTesting(AcpClientFactory factory) { this.acpClientFactory = factory; } 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 e3f38cc7d1f4f..e65678a61ea8d 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 @@ -43,12 +43,10 @@ final class AiProviderSelector { /** * One external ACP agent the panel knows how to launch. {@code executable} is the first token of the command, * checked on the PATH before spawning so a missing tool yields {@code installHint} instead of an obscure error. - * {@code glyph}, {@code color} and {@code logo} identify the agent in the panel's header strip: the logo names a - * {@code /tui/logos/.png} resource drawn in terminals with native graphics, the glyph is the fallback - * everywhere else. + * {@code glyph} and {@code color} identify the agent in the panel's header strip. */ record AcpPreset(String id, String label, List command, String executable, String loginHint, - String installHint, String glyph, Color color, String logo) { + String installHint, String glyph, Color color) { } private static final List ACP_PRESETS = List.of( @@ -56,35 +54,35 @@ record AcpPreset(String id, String label, List command, String executabl "acp:claude", "Claude Code (ACP)", List.of("npx", "-y", "@agentclientprotocol/claude-agent-acp"), "npx", "Log in with the claude CLI or set ANTHROPIC_API_KEY, then ask again.", NPX_HINT, - "✱", Color.rgb(0xD9, 0x77, 0x57), "claude"), + "✱", 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), "codex"), + "⬢", 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), "bob"), + "◆", 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), "qwen"), + "✦", 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), "opencode"), + "▣", 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), "dsh")); + "◉", Color.rgb(0x4D, 0x6B, 0xFE))); static List acpPresets() { return ACP_PRESETS; @@ -126,7 +124,7 @@ AcpPreset acpPreset(String provider, TuiSettings settings) { ACP_CUSTOM, "Custom (ACP)", command, command.get(0), "Check the agent's own login instructions, then ask again.", command.get(0) + " not found: check camel.tui.ai.acp.command.", - "●", Theme.ACCENT, null); + "●", Theme.ACCENT); } throw new IllegalArgumentException("Unknown ACP provider '" + provider + "'."); } 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 ff42a9c683ddf..bdd8130afcd02 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 @@ -64,8 +64,7 @@ class SettingsPopup { private static final int ROW_AI_TOOLS = 16; private static final int ROW_AI_PROMPT_HISTORY = 17; private static final int ROW_AI_ACP_COMMAND = 18; - private static final int ROW_AI_ACP_LOGOS = 19; - static final int ROW_COUNT = 20; + 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; @@ -75,7 +74,6 @@ class SettingsPopup { private static final String[] PANEL_SPACE_OPTIONS = { "move", "overlay" }; private static final String[] AI_TOOLS_OPTIONS = { AiPanel.TOOL_MODE_AUTO, AiPanel.TOOL_MODE_CORE, AiPanel.TOOL_MODE_FULL }; - private static final String[] AI_ACP_LOGOS_OPTIONS = { "auto", "on", "off" }; private static final List AI_PROVIDERS = buildAiProviderList(); private static List buildAiProviderList() { @@ -110,7 +108,6 @@ private static List buildAiProviderList() { private int validateOnSaveIndex; private int aiProviderIndex; private int aiToolsIndex; - private int aiAcpLogosIndex; private TextInputState folderInput; private TextInputState proxyHostInput; private TextInputState proxyPortInput; @@ -206,13 +203,6 @@ void open() { aiPromptHistoryInput = new TextInputState( settings.getAiPromptHistory() != null ? settings.getAiPromptHistory() : ""); aiAcpCommandInput = new TextInputState(settings.getAiAcpCommand() != null ? settings.getAiAcpCommand() : ""); - aiAcpLogosIndex = 0; - for (int i = 0; i < AI_ACP_LOGOS_OPTIONS.length; i++) { - if (AI_ACP_LOGOS_OPTIONS[i].equals(settings.getAiAcpLogos())) { - aiAcpLogosIndex = i; - break; - } - } selectedRow = ROW_THEME; scrollTop = 0; visible = true; @@ -370,14 +360,6 @@ boolean handleKeyEvent(KeyEvent ke) { handleTextInput(ke, aiAcpCommandInput); return true; } - if (selectedRow == ROW_AI_ACP_LOGOS) { - if (ke.isChar(' ') || ke.isRight()) { - aiAcpLogosIndex = (aiAcpLogosIndex + 1) % AI_ACP_LOGOS_OPTIONS.length; - } else if (ke.isLeft()) { - aiAcpLogosIndex = (aiAcpLogosIndex - 1 + AI_ACP_LOGOS_OPTIONS.length) % AI_ACP_LOGOS_OPTIONS.length; - } - return true; - } return true; } @@ -423,7 +405,6 @@ private void save() { settings.setAiTools(AiPanel.TOOL_MODE_AUTO.equals(aiToolsValue) ? null : aiToolsValue); settings.setAiPromptHistory(stripControlChars(aiPromptHistoryInput.text().trim())); settings.setAiAcpCommand(stripControlChars(aiAcpCommandInput.text().trim())); - settings.setAiAcpLogos(AI_ACP_LOGOS_OPTIONS[aiAcpLogosIndex]); settings.save(); if (Theme.mode().equals(selectedThemeId)) { // Already active via live preview (or unchanged): just persist and clear the preview marker. @@ -573,11 +554,6 @@ void render(Frame frame, Rect area) { 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)"); - rowY++; - - renderLabel(frame, innerX, rowY, labelW, "ACP Logos:", selectedRow == ROW_AI_ACP_LOGOS); - renderValue(frame, innerX + labelW, rowY, fieldW, AI_ACP_LOGOS_OPTIONS[aiAcpLogosIndex], - selectedRow == ROW_AI_ACP_LOGOS); } void renderFooter(List spans) { @@ -585,8 +561,7 @@ void renderFooter(List spans) { || selectedRow == ROW_LOG_PIN || selectedRow == ROW_RATE_PER || selectedRow == ROW_PANEL_POSITION || selectedRow == ROW_PANEL_SPACE || selectedRow == ROW_CONFIRM_ACTIONS || selectedRow == ROW_VALIDATE_ON_SAVE - || selectedRow == ROW_AI_PROVIDER || selectedRow == ROW_AI_TOOLS - || selectedRow == ROW_AI_ACP_LOGOS) { + || selectedRow == ROW_AI_PROVIDER || selectedRow == ROW_AI_TOOLS) { hint(spans, "Space", "cycle"); } hint(spans, "Enter", "save"); @@ -752,10 +727,6 @@ private String aiToolsLabel() { }; } - String selectedAiAcpLogos() { - return AI_ACP_LOGOS_OPTIONS[aiAcpLogosIndex]; - } - List aiProviderOptionsForTesting() { return AI_PROVIDERS; } 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 83118b957c97d..a790019dfdff1 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 @@ -41,7 +41,6 @@ final class TuiSettings { 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_AI_ACP_LOGOS = "camel.tui.ai.acp.logos"; 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"; @@ -64,7 +63,6 @@ final class TuiSettings { private String aiUrl; private String aiTools; private String aiAcpCommand; - private String aiAcpLogos; private String shellHistory; private String aiPromptHistory; private String confirmActions; @@ -180,14 +178,6 @@ void setAiAcpCommand(String aiAcpCommand) { this.aiAcpCommand = aiAcpCommand; } - String getAiAcpLogos() { - return aiAcpLogos; - } - - void setAiAcpLogos(String aiAcpLogos) { - this.aiAcpLogos = aiAcpLogos; - } - String getShellHistory() { return shellHistory; } @@ -283,7 +273,6 @@ static TuiSettings load() { 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.aiAcpLogos = trimToNull(TuiUserConfig.read(PROP_AI_ACP_LOGOS)); 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)); @@ -316,7 +305,6 @@ void save() { TuiUserConfig.write(PROP_AI_URL, aiUrl); TuiUserConfig.write(PROP_AI_TOOLS, aiTools); TuiUserConfig.write(PROP_AI_ACP_COMMAND, aiAcpCommand); - TuiUserConfig.write(PROP_AI_ACP_LOGOS, aiAcpLogos); 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/resources/tui/logos/bob.png b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/bob.png deleted file mode 100644 index 7a14543193f857d6765c99c077b21fd9341a5241..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10743 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 diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/codex.png b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/codex.png deleted file mode 100644 index 6d29b0ac129c48af33aae633079a0b1bf8fe5113..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9537 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 I!6NMc0lNSX=Kufz diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/opencode.png b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/logos/opencode.png deleted file mode 100644 index 1b0e057f487e5a096ee3c8c132437c82031b9a53..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 918 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJFdSWg$nkcwMx?>golaS(7l zXkpZ_anGDhe+na{IQV+(_Gq#E|8ix!-Mg*#z7)eCphh!P=EuL{_wTyp>H^Q08VneRD%$Wk x?)6+b!8ML;%oH;g3(QrB(nSq^B$=t>d+H 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 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 index cf119b4bcd57e..3c8798815194d 100644 --- 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 @@ -16,158 +16,45 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; -import java.util.EnumSet; import dev.tamboui.buffer.Buffer; -import dev.tamboui.image.capability.TerminalImageCapabilities; -import dev.tamboui.image.capability.TerminalImageProtocol; 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.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 AcpHeaderStripTest { - private static final TerminalImageCapabilities KITTY - = TerminalImageCapabilities.withSupport(EnumSet.of(TerminalImageProtocol.KITTY, TerminalImageProtocol.HALF_BLOCK)); - private static final TerminalImageCapabilities TEXT_ONLY - = TerminalImageCapabilities.withSupport(EnumSet.of(TerminalImageProtocol.HALF_BLOCK)); - private static AcpHeaderStrip.Model model(int commands) { return new AcpHeaderStrip.Model( - "IBM Bob (ACP)", "◆", Color.rgb(0x0F, 0x62, 0xFE), "bob", + "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 logosFollowTheModeAndTheTerminal() { - assertTrue(new AcpHeaderStrip(KITTY).logosEnabled(AcpHeaderStrip.LogoMode.AUTO)); - assertFalse(new AcpHeaderStrip(TEXT_ONLY).logosEnabled(AcpHeaderStrip.LogoMode.AUTO)); - assertTrue(new AcpHeaderStrip(TEXT_ONLY).logosEnabled(AcpHeaderStrip.LogoMode.ON)); - assertFalse(new AcpHeaderStrip(KITTY).logosEnabled(AcpHeaderStrip.LogoMode.OFF)); - assertEquals(AcpHeaderStrip.LogoMode.AUTO, AcpHeaderStrip.LogoMode.parse(null)); - assertEquals(AcpHeaderStrip.LogoMode.OFF, AcpHeaderStrip.LogoMode.parse("off")); - assertEquals(AcpHeaderStrip.LogoMode.AUTO, AcpHeaderStrip.LogoMode.parse("nonsense")); - } - - @Test - void everyPresetLogoLoadsAndUnknownOnesAreNull() { - AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); - for (String logo : new String[] { "claude", "codex", "bob", "qwen", "opencode", "dsh" }) { - assertNotNull(strip.logoFor(logo), logo); - } - assertNull(strip.logoFor("nope")); - assertNull(strip.logoFor(null)); - } - @Test void glyphModeRendersGlyphAndMetadata() { - AcpHeaderStrip strip = new AcpHeaderStrip(TEXT_ONLY); + 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), AcpHeaderStrip.LogoMode.AUTO); + 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); - assertNull(strip.lastLogoRectForTesting()); } @Test - void logoModeReservesTheLogoColumnsAndKeepsTheText() { - AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); - Rect area = new Rect(0, 0, 100, AcpHeaderStrip.ROWS); + 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(0), AcpHeaderStrip.LogoMode.AUTO); - String rendered = TuiTestHelper.bufferToString(buffer); - assertTrue(rendered.contains("IBM Bob (ACP) · bob-shell 2.0.2"), rendered); - assertFalse(rendered.contains("◆"), "no glyph when the logo is drawn"); - assertTrue(rendered.contains("no commands yet"), rendered); - assertNotNull(strip.lastLogoRectForTesting()); - assertEquals(0, strip.lastLogoRectForTesting().x()); - } - - @Test - void kittyCommandsAreQuietAndIdentified() { - String transmit = AcpHeaderStrip.kittyTransmit(4242, new byte[] { 1, 2, 3 }); - assertTrue(transmit.startsWith("\033_Ga=t,f=100,t=d,i=4242,q=2,m=0;"), transmit); - assertTrue(transmit.endsWith("\033\\"), transmit); - String big = AcpHeaderStrip.kittyTransmit(7, new byte[9000]); - assertTrue(big.startsWith("\033_Ga=t,f=100,t=d,i=7,q=2,m=1;"), big); - assertEquals(3, big.split("\033_G", -1).length - 1, "9000 bytes base64 split into three 4096-character chunks"); - assertTrue(big.contains(",m=1;") && big.lastIndexOf("m=0;") > big.lastIndexOf("m=1;")); - assertEquals("\033[3;2H\033_Ga=p,i=4242,p=1,c=5,r=2,C=1,q=2\033\\", - AcpHeaderStrip.kittyPlace(4242, new Rect(1, 2, 5, 2))); - assertEquals("\033_Ga=d,d=i,i=4242,q=2\033\\", AcpHeaderStrip.kittyDelete(4242)); - assertEquals(AcpHeaderStrip.kittyImageId("claude"), AcpHeaderStrip.kittyImageId("claude")); - assertTrue(AcpHeaderStrip.kittyImageId("claude") != AcpHeaderStrip.kittyImageId("codex")); - assertTrue(AcpHeaderStrip.kittyImageId("bob") > 0); - } - - @Test - void kittyLogoIsUploadedOnceAndPlacedEveryFrame() { - AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); - Rect area = new Rect(0, 0, 100, AcpHeaderStrip.ROWS); - ByteArrayOutputStream raw = new ByteArrayOutputStream(); - strip.renderForTesting(area, Buffer.empty(area), raw, model(3), AcpHeaderStrip.LogoMode.AUTO); - String first = raw.toString(StandardCharsets.US_ASCII); - assertTrue(first.contains("a=t,f=100,t=d,i=" + AcpHeaderStrip.kittyImageId("bob")), first); - assertTrue(first.endsWith(AcpHeaderStrip.kittyPlace(AcpHeaderStrip.kittyImageId("bob"), new Rect(0, 0, 5, 2))), first); - raw.reset(); - strip.renderForTesting(area, Buffer.empty(area), raw, model(3), AcpHeaderStrip.LogoMode.AUTO); - String second = raw.toString(StandardCharsets.US_ASCII); - assertFalse(second.contains("a=t,"), "no second upload"); - assertEquals(AcpHeaderStrip.kittyPlace(AcpHeaderStrip.kittyImageId("bob"), new Rect(0, 0, 5, 2)), second); - assertTrue(strip.hasPlacementForTesting()); - raw.reset(); - strip.hideForTesting(raw); - assertEquals(AcpHeaderStrip.kittyDelete(AcpHeaderStrip.kittyImageId("bob")), raw.toString(StandardCharsets.US_ASCII)); - assertFalse(strip.hasPlacementForTesting()); - raw.reset(); - strip.hideForTesting(raw); - assertEquals("", raw.toString(StandardCharsets.US_ASCII), "hide is a no-op without a placement"); - } - - @Test - void uploadIsRetriedAfterAWriteFailure() { - AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); - Rect area = new Rect(0, 0, 100, AcpHeaderStrip.ROWS); - OutputStream broken = new OutputStream() { - @Override - public void write(int b) throws IOException { - throw new IOException("terminal gone"); - } - }; - strip.renderForTesting(area, Buffer.empty(area), broken, model(1), AcpHeaderStrip.LogoMode.AUTO); - ByteArrayOutputStream raw = new ByteArrayOutputStream(); - strip.renderForTesting(area, Buffer.empty(area), raw, model(1), AcpHeaderStrip.LogoMode.AUTO); - String second = raw.toString(StandardCharsets.US_ASCII); - assertTrue(second.contains("a=t,f=100,t=d,i="), "the upload is retried after a failed write"); - assertTrue(second.endsWith(AcpHeaderStrip.kittyPlace(AcpHeaderStrip.kittyImageId("bob"), new Rect(0, 0, 5, 2))), - second); - } - - @Test - void logoBytesAreTheFullPng() throws Exception { - AcpHeaderStrip strip = new AcpHeaderStrip(KITTY); - byte[] png = strip.logoBytes("bob"); - assertNotNull(png); - assertTrue(png.length > 1000); - assertEquals((byte) 0x89, png[0]); - assertNull(strip.logoBytes("nope")); - assertTrue(strip.isLogoCachedForTesting("nope"), "a missing logo is not looked up again on every frame"); + strip.render(Frame.forTesting(buffer), area, model(3)); + assertTrue(TuiTestHelper.bufferToString(buffer).isBlank(), "nothing fits below 20 columns"); } @Test 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 index 0ae483613cd29..9601c22d08841 100644 --- 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 @@ -19,12 +19,9 @@ import java.io.IOException; import java.nio.file.Path; import java.time.Duration; -import java.util.EnumSet; import java.util.concurrent.TimeUnit; import dev.tamboui.buffer.Buffer; -import dev.tamboui.image.capability.TerminalImageCapabilities; -import dev.tamboui.image.capability.TerminalImageProtocol; import dev.tamboui.layout.Rect; import dev.tamboui.terminal.Frame; import dev.tamboui.tui.event.KeyCode; @@ -697,8 +694,6 @@ void panelCommandsStillWinOverTheAgent() throws Exception { @Test void headerStripAppearsOnceTheSessionIsOpen() throws Exception { AiPanel panel = acpPanel(); - panel.setAcpHeaderForTesting(new AcpHeaderStrip( - TerminalImageCapabilities.withSupport(EnumSet.of(TerminalImageProtocol.HALF_BLOCK)))); // 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); 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 b9bfe13f746eb..c621d51f8650b 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 @@ -205,9 +205,7 @@ void acpPresetResolvesCommandsAndCustomCommand(@TempDir Path tempDir) { assertEquals(List.of("/opt/agent/bin/agent", "--acp"), custom.command()); assertEquals("/opt/agent/bin/agent", custom.executable()); assertEquals("Custom (ACP)", custom.label()); - assertEquals("claude", claude.logo()); assertEquals("✱", claude.glyph()); - assertNull(custom.logo()); assertEquals("●", custom.glyph()); assertThrows(IllegalArgumentException.class, () -> selector.acpPreset("acp:nope", settings)); } 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 2ef18b91f58c3..9386952c9c36d 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 @@ -90,7 +90,6 @@ void rendersTitleAndAllSettingRows(@TempDir Path tempDir) { 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"); - assertTrue(rendered.contains("ACP Logos"), "the ACP Logos row should be shown"); } @Test @@ -112,14 +111,14 @@ void scrollsTheSelectedRowIntoViewOnAShortTerminal(@TempDir Path tempDir) { popup.render(Frame.forTesting(buffer), area); String rendered = TuiTestHelper.bufferToString(buffer); assertTrue(rendered.contains("Theme:"), rendered); - assertFalse(rendered.contains("ACP Logos"), "the last rows do not fit in 25 lines"); - for (int i = 0; i < 19; i++) { + 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 Logos"), rendered); + 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 582fdd843f86b..c27c09d76d91d 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 @@ -257,28 +257,6 @@ void aiToolsRowCyclesModesAndPersistsNonDefault(@TempDir Path tempDir) { assertNull(TuiSettings.load().getAiTools()); } - @Test - void acpLogosRowCyclesAndPersists(@TempDir Path tempDir) { - useHome(tempDir); - SettingsPopup popup = new SettingsPopup(); - popup.setTabEntries(tabs()); - popup.open(); - - // navigate to ACP Logos (row 19) - for (int i = 0; i < 19; i++) { - popup.handleKeyEvent(key(KeyCode.DOWN)); - } - assertEquals(19, popup.selectedRow()); - assertEquals("auto", popup.selectedAiAcpLogos()); - popup.handleKeyEvent(KeyEvent.ofChar(' ')); - assertEquals("on", popup.selectedAiAcpLogos()); - popup.handleKeyEvent(KeyEvent.ofChar(' ')); - assertEquals("off", popup.selectedAiAcpLogos()); - - popup.handleKeyEvent(key(KeyCode.ENTER)); - assertEquals("off", TuiSettings.load().getAiAcpLogos()); - } - @Test void historyFieldsPersistValues(@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/TuiSettingsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiSettingsTest.java index 0929794e1bdc0..55f3442046edb 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 @@ -65,7 +65,6 @@ void roundTripPreservesAllFields(@TempDir Path tempDir) { settings.setAiUrl("https://generativelanguage.googleapis.com"); settings.setAiTools("core"); settings.setAiAcpCommand("npx -y pi-acp"); - settings.setAiAcpLogos("off"); settings.setShellHistory("25"); settings.setAiPromptHistory("50"); settings.setPanelPosition("top"); @@ -81,7 +80,6 @@ void roundTripPreservesAllFields(@TempDir Path tempDir) { assertThat(loaded.getAiUrl()).isEqualTo("https://generativelanguage.googleapis.com"); assertThat(loaded.getAiTools()).isEqualTo("core"); assertThat(loaded.getAiAcpCommand()).isEqualTo("npx -y pi-acp"); - assertThat(loaded.getAiAcpLogos()).isEqualTo("off"); assertThat(loaded.getShellHistory()).isEqualTo("25"); assertThat(loaded.getAiPromptHistory()).isEqualTo("50"); assertThat(loaded.getPanelPosition()).isEqualTo("top");