This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new a796ff70d2f2 chore: camel-jbang - a question in the AI panel goes
before the background project summary (#27221)
a796ff70d2f2 is described below
commit a796ff70d2f2b246a01a2abfe36a3dcab7eae5c7
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 19:47:28 2026 +0200
chore: camel-jbang - a question in the AI panel goes before the background
project summary (#27221)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-tui-ai.adoc | 5 +-
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 28 ++++++++++-
.../core/commands/tui/ProjectOverviewAssist.java | 57 +++++++++++++++++++++-
.../dsl/jbang/core/commands/tui/AiPanelTest.java | 12 +++++
.../commands/tui/ProjectOverviewAssistTest.java | 57 +++++++++++++++++++++-
5 files changed, 155 insertions(+), 4 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
index a538bcb0a3ac..2a1ac2268d8b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
@@ -299,7 +299,10 @@ into the route sources (`description` and `note` in YAML
and XML, `.routeDescrip
`/write` mode. From then on they are part of the routes and no longer marked.
With *AI Overview* set to *auto* in *F2 -> Settings*, the panel explains the
project by itself when
-it opens and the summary is missing or out of date, once per project state.
External AI agents get the
+it opens and the summary is missing or out of date, once per project state. It
starts once the panel has
+been idle for a few seconds, so a question asked right away is answered first.
A local model answers one
+request at a time: a question asked while the summary is being written waits
for it, and the panel says
+so; *Esc* then stops the summary so the question goes first (`/overview`
writes it later). External AI agents get the
same through the `camel_project_overview` and `camel_save_project_summary`
tools; from the TUI's MCP
server the summary they save goes through the confirm dialog too.
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 05142a375076..16b96c78ba68 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
@@ -162,6 +162,7 @@ class AiPanel {
// Input state
private final StringBuilder inputBuffer = new StringBuilder();
+ private volatile long lastInputMs;
private int cursorPos;
private TuiPromptHistory promptHistory;
@@ -531,14 +532,24 @@ class AiPanel {
// the project is kept off the UI thread
LlmClient c = client;
Path dir = projectDirectory();
+ // it waits until the panel is idle, so a question asked right
away is answered first
Thread t = new Thread(
- () -> projectOverview.autoExplain(dir, c, mcpFacade,
this::addOverviewEntry),
+ () -> projectOverview.autoExplain(dir, c, mcpFacade,
this::addOverviewEntry,
+ () -> idleFor(OVERVIEW_IDLE_MS,
System.currentTimeMillis())),
"tui-ai-overview-check");
t.setDaemon(true);
t.start();
}
}
+ /** How long the panel is left alone (nothing typed, no question running)
before the summary starts. */
+ static final long OVERVIEW_IDLE_MS = 3000;
+
+ /** Whether the panel has been idle for a while: no question or command
running, nothing typed. */
+ boolean idleFor(long ms, long now) {
+ return !thinking.get() && activeCliCommand == null &&
inputBuffer.isEmpty() && now - lastInputMs >= ms;
+ }
+
/** The project the AI overview is about: the selected integration's
sources, else the folder the TUI runs in. */
private Path projectDirectory() {
Path dir = mcpFacade != null ? mcpFacade.getSelectedSourceDirectory()
: null;
@@ -811,6 +822,7 @@ class AiPanel {
}
boolean handleKeyEvent(KeyEvent ke) {
+ lastInputMs = System.currentTimeMillis();
if (permissionPopup.isVisible()) {
if (ke.isCtrlC()) {
interruptBusyOperation();
@@ -1440,6 +1452,13 @@ class AiPanel {
}
private void interruptBusyOperation() {
+ if (thinking.get() && projectOverview.stop()) {
+ // the question waits behind the project summary: the first Esc
stops the summary, so it goes first
+ conversation.add(new ConversationEntry(
+ AiRole.SYSTEM,
+ "(project summary stopped, your question goes first;
/overview writes it later)"));
+ return;
+ }
// A background CLI command and an LLM request can be in flight at the
same time, so cancel each one
// independently. Cancelling the CLI must not touch the LLM's thinking
state (that belongs to the agent
// thread, which clears it in its own finally block) and vice versa.
@@ -1504,6 +1523,13 @@ class AiPanel {
return;
}
conversation.add(new ConversationEntry(AiRole.USER, question));
+ if (projectOverview.isRunning() && client.apiType() ==
LlmClient.ApiType.ollama) {
+ // Ollama answers one request at a time: say why the answer takes
long, and how to go first
+ conversation.add(new ConversationEntry(
+ AiRole.SYSTEM,
+ "The project summary (/overview) is being written, and
Ollama answers one request at a time:"
+ + " this question waits for it. Esc stops
the summary."));
+ }
questionCounter++;
currentQuestion = question;
noteQuestionStarted(question);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java
index bc73042e93f2..6a9f5b2616cd 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssist.java
@@ -27,6 +27,7 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BooleanSupplier;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
@@ -62,6 +63,8 @@ final class ProjectOverviewAssist {
private static volatile CamelCatalog catalog;
private final AtomicBoolean running = new AtomicBoolean();
+ private volatile Thread worker;
+ private volatile boolean stopRequested;
/** The projects auto mode already explained in this session, by directory
and fingerprint. */
private final Set<String> autoDone = ConcurrentHashMap.newKeySet();
@@ -143,6 +146,15 @@ final class ProjectOverviewAssist {
* @return true when it started
*/
boolean autoExplain(Path dir, LlmClient client, McpFacade facade, Sink
sink) {
+ return autoExplain(dir, client, facade, sink, () -> true);
+ }
+
+ /**
+ * As {@link #autoExplain(Path, LlmClient, McpFacade, Sink)}, but starts
only once the panel is idle: a local model
+ * answers one request at a time, so a question asked right after the
panel opens would otherwise wait for the whole
+ * summary. Blocks the calling (background) thread until then.
+ */
+ boolean autoExplain(Path dir, LlmClient client, McpFacade facade, Sink
sink, BooleanSupplier idle) {
if (!MODE_AUTO.equals(mode()) || dir == null || client == null ||
running.get()) {
return false;
}
@@ -154,6 +166,9 @@ final class ProjectOverviewAssist {
if (summary != null && !summary.ai().isEmpty() &&
overview.fingerprint().equals(summary.fingerprint())) {
return false;
}
+ if (!awaitIdle(idle, IDLE_WAIT_MS) || running.get()) {
+ return false;
+ }
sink.add(AiRole.SYSTEM, "The project summary is " + (summary == null ?
"missing" : "out of date")
+ ": explaining " + overview.flows().size() +
" routes with " + client.model()
+ " in the background (AI Overview: auto).");
@@ -161,11 +176,47 @@ final class ProjectOverviewAssist {
return true;
}
+ /** How long the summary waits for the panel to become idle before it
gives up for this session. */
+ static final long IDLE_WAIT_MS = 10 * 60_000;
+
+ /** Waits until {@code idle} holds, checking a few times a second; false
when it never did in time. */
+ static boolean awaitIdle(BooleanSupplier idle, long maxWaitMs) {
+ long until = System.currentTimeMillis() + maxWaitMs;
+ while (!idle.getAsBoolean()) {
+ if (System.currentTimeMillis() >= until) {
+ return false;
+ }
+ try {
+ Thread.sleep(250);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Stops the summary being written, so a question waiting behind it on a
local model goes first.
+ *
+ * @return true when one was running
+ */
+ boolean stop() {
+ Thread w = worker;
+ if (!running.get() || w == null) {
+ return false;
+ }
+ stopRequested = true;
+ w.interrupt();
+ return true;
+ }
+
private void explain(
Path dir, ProjectOverview.Overview overview,
IntegrationSummary.Summary previous, LlmClient client, Sink sink) {
if (!running.compareAndSet(false, true)) {
return;
}
+ stopRequested = false;
Thread t = new Thread(() -> {
try {
String answer = ask(client, IntegrationSummary.systemPrompt(),
@@ -187,12 +238,16 @@ final class ProjectOverviewAssist {
overview.fingerprint(), client.model(), null,
merged.overview(),
merged.capabilities(),
merged.descriptions())));
} catch (Exception e) {
- sink.add(AiRole.ERROR, "Explaining the project failed: " +
e.getMessage());
+ if (!stopRequested) {
+ sink.add(AiRole.ERROR, "Explaining the project failed: " +
e.getMessage());
+ }
} finally {
+ worker = null;
running.set(false);
}
}, "tui-ai-overview");
t.setDaemon(true);
+ worker = t;
t.start();
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
index 4074e7a8b27d..8df01c361505 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
@@ -68,6 +68,18 @@ class AiPanelTest {
assertEquals("2.0s · 1 tool call · 900 tokens", hosted.byline());
}
+ @Test
+ void thePanelIsIdleOnlyWhenNothingIsTypedOrAsked() {
+ AiPanel panel = new AiPanel();
+ long now = System.currentTimeMillis();
+ assertTrue(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS, now), "nothing
typed yet");
+
+ type(panel, "why");
+ assertFalse(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS,
System.currentTimeMillis()), "typing a question");
+ // still typed, even long after the last key
+ assertFalse(panel.idleFor(AiPanel.OVERVIEW_IDLE_MS,
System.currentTimeMillis() + 60_000));
+ }
+
@Test
void normalTextStillGoesToLlm() throws Exception {
AiPanel panel = new AiPanel();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssistTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssistTest.java
index fecd2a523e5b..6b53aa29d340 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssistTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ProjectOverviewAssistTest.java
@@ -21,7 +21,9 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.camel.dsl.jbang.core.commands.LlmClient;
@@ -178,6 +180,59 @@ class ProjectOverviewAssistTest {
assertFalse(assist.autoExplain(project, client, null, sink));
}
+ @Test
+ void theSummaryWaitsUntilThePanelIsIdle() throws Exception {
+ TuiSettings settings = TuiSettings.load();
+ settings.setAiOverview("auto");
+ settings.save();
+ AnsweringClient client = new AnsweringClient(ANSWER);
+ ProjectOverviewAssist assist = new ProjectOverviewAssist();
+ AtomicBoolean idle = new AtomicBoolean();
+
+ // the user asks a question right after opening the panel: the summary
must not go first
+ Thread check = new Thread(() -> assist.autoExplain(project, client,
null, sink, idle::get));
+ check.start();
+ check.join(1000);
+ assertTrue(check.isAlive(), "waits while the panel is busy");
+ assertEquals(0, client.calls.get());
+ assertTrue(entries.isEmpty());
+
+ idle.set(true);
+ await().atMost(10, TimeUnit.SECONDS).until(() -> client.calls.get() ==
1 && !assist.isRunning());
+ assertTrue(entries.get(0).contains("(AI Overview: auto)"),
entries.get(0));
+ }
+
+ @Test
+ void aStoppedSummaryLeavesNoError() {
+ TuiSettings settings = TuiSettings.load();
+ settings.setAiOverview("auto");
+ settings.save();
+ CountDownLatch asked = new CountDownLatch(1);
+ LlmClient slow = new AnsweringClient(ANSWER) {
+ @Override
+ public ChatResponse chatWithTools(String systemPrompt,
List<Message> messages, List<ToolDef> tools) {
+ asked.countDown();
+ try {
+ // a local model writing a long summary
+ new CountDownLatch(1).await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("interrupted", e);
+ }
+ return null;
+ }
+ };
+ ProjectOverviewAssist assist = new ProjectOverviewAssist();
+ assertFalse(assist.stop(), "nothing to stop");
+
+ assertTrue(assist.autoExplain(project, slow, null, sink));
+ await().atMost(10, TimeUnit.SECONDS).until(() -> asked.getCount() ==
0);
+ assertTrue(assist.stop());
+ await().atMost(10, TimeUnit.SECONDS).until(() -> !assist.isRunning());
+ assertEquals(1, entries.size(), "only the start was said: " + entries);
+
assertFalse(Files.exists(project.resolve(IntegrationSummary.FILE_NAME)));
+ }
+
@Test
void applyNeedsTheSelectedIntegration() {
ProjectOverviewAssist assist = new ProjectOverviewAssist();
@@ -204,7 +259,7 @@ class ProjectOverviewAssistTest {
}
}
- private static final class AnsweringClient extends LlmClient {
+ private static class AnsweringClient extends LlmClient {
private final String answer;
private final AtomicInteger calls = new AtomicInteger();