You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/01 15:49:54 UTC

(camel) branch quick-fix/ai-question-first created (now 51d8e4990e3b)

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

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


      at 51d8e4990e3b chore: camel-jbang - a question asked in the AI panel goes before the background project summary

This branch includes the following new commits:

     new 51d8e4990e3b chore: camel-jbang - a question asked in the AI panel goes before the background project summary

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(camel) 01/01: chore: camel-jbang - a question asked in the AI panel goes before the background project summary

Posted by da...@apache.org.
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 <cl...@gmail.com>
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) <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../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();