This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch quick-fix/ai-question-first
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 51d8e4990e3be1551de710435ec141be78d2f601
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 16:07:14 2026 +0200

    chore: camel-jbang - a question asked in the AI panel goes before the 
background project summary
    
    The automatic project summary starts once the panel is idle; a question 
that waits behind it on Ollama says so, and Esc stops the summary so the 
question goes first.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[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();

Reply via email to