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

commit 08bff85d5dc99ec875cb00b9a5f6842ef7b58e95
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Sep 8 16:21:17 2026 +0200

    Camel TUI: TAB completes the model name after /model and the mode after 
/tools in the AI panel
    
    The model list comes from the provider (the same call the bare /model 
listing
    uses), fetched once in the background on the first TAB and cached until the
    provider or client changes. Completion follows the existing rules: longest
    common prefix first, then cycling, Shift+TAB backward.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-tui.adoc        |   6 ++
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 107 +++++++++++++++++++--
 .../dsl/jbang/core/commands/tui/AiPanelTest.java   |  61 ++++++++++++
 3 files changed, 168 insertions(+), 6 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 96cef22d9c49..49380e035949 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -875,6 +875,12 @@ Use *F2* -> _MCP Info_ to see server details and _MCP Log_ 
to view the tool call
 
 When the AI panel is open, input that starts with `/` runs a local panel 
command instead of sending a question to the configured AI provider. 
`/provider` and `/model` are unavailable while a response or command is already 
in progress; the panel shows a message asking you to wait.
 
+Press *Tab* to complete a command name. After `/model ` *Tab* completes the 
model name against the
+models the current provider reports (the list is fetched in the background on 
the first press, so
+press *Tab* again once it has arrived), and after `/tools ` it completes 
`auto`, `core` or `full`.
+With several matches *Tab* fills in the common prefix and then cycles through 
them; *Shift+Tab*
+cycles backward.
+
 [cols="1,3",options="header"]
 |===
 | Command | Description
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 5e0107cafffc..1aa3722125bf 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
@@ -116,6 +116,13 @@ class AiPanel {
     private List<String> completionMatches;
     private int completionCycleIndex = -1;
     private String completionSnapshot;
+    // Buffer offset of the token being completed: 1 for a command name (after 
the slash), or the start of the
+    // first argument for commands that complete arguments (/model, /tools).
+    private int completionStart = 1;
+    // Models offered by TAB after "/model ". Fetched once in the background 
(a provider round trip) on the first TAB
+    // and reset whenever the client or provider changes.
+    private volatile List<String> modelCompletionCache;
+    private final AtomicBoolean modelCompletionFetch = new AtomicBoolean();
 
     // Conversation display. CopyOnWriteArrayList because entries are appended 
from the agent thread and the
     // CLI-command-completion callback while the render thread iterates the 
list concurrently.
@@ -300,6 +307,7 @@ class AiPanel {
     }
 
     private void initClient() {
+        modelCompletionCache = null;
         try {
             LlmClient created = LlmClient.create()
                     .withTemperature(0.3)
@@ -641,6 +649,13 @@ class AiPanel {
      * reachable with TAB alone). A single match is completed fully and a 
trailing space is appended. Shift+TAB cycles
      * backward. TAB is a no-op unless the buffer is a partial command name 
(starts with {@code /}, no arguments yet).
      */
+    /**
+     * Completes the slash command name at the cursor, or the first argument 
of {@code /model} (against the models the
+     * provider reports) and {@code /tools} (auto, core, full). With multiple 
matches, TAB first fills in the longest
+     * common prefix; once no further prefix can be added it cycles forward 
through the matches (wrapping so every match
+     * is reachable with TAB alone). A single match is completed fully and a 
trailing space is appended. Shift+TAB
+     * cycles backward.
+     */
     private void handleTabCompletion(boolean backward) {
         String text = inputBuffer.toString();
         boolean continuing = text.equals(completionSnapshot) && 
completionMatches != null && completionMatches.size() > 1;
@@ -657,9 +672,20 @@ class AiPanel {
             return;
         }
 
-        List<String> names = slashCommands.completionsFor(text).stream()
-                .map(AiSlashCommandRegistry.Descriptor::name)
-                .toList();
+        List<String> names;
+        String currentToken;
+        ArgumentCompletion argument = argumentCompletion(text);
+        if (argument != null) {
+            names = argument.candidates();
+            currentToken = argument.token();
+            completionStart = argument.start();
+        } else {
+            names = slashCommands.completionsFor(text).stream()
+                    .map(AiSlashCommandRegistry.Descriptor::name)
+                    .toList();
+            currentToken = text.length() > 1 ? text.substring(1) : "";
+            completionStart = 1;
+        }
         if (names.isEmpty()) {
             completionMatches = null;
             completionCycleIndex = -1;
@@ -674,7 +700,6 @@ class AiPanel {
             completionSnapshot = null;
             return;
         }
-        String currentToken = text.substring(1);
         String prefix = longestCommonPrefix(names);
         completionMatches = names;
         if (prefix.length() > currentToken.length()) {
@@ -686,9 +711,78 @@ class AiPanel {
         }
     }
 
+    private record ArgumentCompletion(int start, String token, List<String> 
candidates) {
+    }
+
+    /**
+     * Returns the argument completion for {@code /model <prefix>} or {@code 
/tools <prefix>} (aliases included), or
+     * {@code null} when the buffer is not at the first argument of one of 
those commands. The model list comes from the
+     * provider, so the first TAB kicks off a background fetch and returns 
nothing; TAB again once it is loaded.
+     */
+    private ArgumentCompletion argumentCompletion(String text) {
+        if (!text.startsWith("/")) {
+            return null;
+        }
+        int separator = -1;
+        for (int i = 1; i < text.length(); i++) {
+            if (Character.isWhitespace(text.charAt(i))) {
+                separator = i;
+                break;
+            }
+        }
+        if (separator < 0) {
+            return null;
+        }
+        Optional<AiSlashCommandRegistry.Descriptor> descriptor = 
slashCommands.lookup(text.substring(1, separator));
+        if (descriptor.isEmpty()) {
+            return null;
+        }
+        int start = separator;
+        while (start < text.length() && 
Character.isWhitespace(text.charAt(start))) {
+            start++;
+        }
+        String token = text.substring(start);
+        if (token.chars().anyMatch(Character::isWhitespace)) {
+            return null;
+        }
+        List<String> candidates = switch (descriptor.get().name()) {
+            case "model" -> modelCompletionCandidates();
+            case "tools" -> List.of(TOOL_MODE_AUTO, TOOL_MODE_CORE, 
TOOL_MODE_FULL);
+            default -> null;
+        };
+        if (candidates == null) {
+            return null;
+        }
+        String lower = token.toLowerCase();
+        List<String> matches = candidates.stream()
+                .filter(candidate -> candidate.toLowerCase().startsWith(lower))
+                .toList();
+        return new ArgumentCompletion(start, token, matches);
+    }
+
+    private List<String> modelCompletionCandidates() {
+        List<String> cached = modelCompletionCache;
+        if (cached != null) {
+            return cached;
+        }
+        if (client != null && modelCompletionFetch.compareAndSet(false, true)) 
{
+            conversation.add(new ConversationEntry(AiRole.SYSTEM, "Fetching 
available models, press TAB again..."));
+            Thread worker = new Thread(() -> {
+                try {
+                    modelCompletionCache = 
slashCommandContext.availableModels();
+                } finally {
+                    modelCompletionFetch.set(false);
+                }
+            }, "tui-ai-model-completion");
+            worker.setDaemon(true);
+            worker.start();
+        }
+        return List.of();
+    }
+
     private void applyCompletionToken(String token, boolean trailingSpace) {
-        inputBuffer.setLength(0);
-        inputBuffer.append('/').append(token);
+        inputBuffer.setLength(completionStart);
+        inputBuffer.append(token);
         if (trailingSpace) {
             inputBuffer.append(' ');
         }
@@ -781,6 +875,7 @@ class AiPanel {
         conversation.add(new ConversationEntry(AiRole.SYSTEM, "Fetching 
available models..."));
         Thread worker = new Thread(() -> {
             List<String> models = slashCommandContext.availableModels();
+            modelCompletionCache = models;
             conversation.add(new ConversationEntry(
                     AiRole.SYSTEM,
                     
AiSlashCommandRegistry.formatModelListing(slashCommandContext.currentModel(), 
models)));
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 9da0a15db472..51b912a2564f 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
@@ -728,6 +728,67 @@ class AiPanelTest {
         assertEquals("/clear-history", panel.inputBufferForTesting());
     }
 
+    // ---- argument completion tests ----
+
+    @Test
+    void tabCompletesModelNameFromProviderList() {
+        AiPanel panel = new AiPanel();
+        panel.setClientForTesting(new 
ModelListingLlmClient(List.of("qwen3.6:35b-a3b", "llama3.3:70b")));
+        panel.open();
+        type(panel, "/model qw");
+
+        // The first TAB only starts the background fetch of the model list; 
once it has arrived TAB completes.
+        tab(panel);
+        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> {
+            tab(panel);
+            assertEquals("/model qwen3.6:35b-a3b ", 
panel.inputBufferForTesting());
+        });
+    }
+
+    @Test
+    void tabCyclesModelsSharingAPrefixAndHonoursAliases() {
+        AiPanel panel = new AiPanel();
+        panel.setClientForTesting(new 
ModelListingLlmClient(List.of("qwen2.5:14b", "qwen2.5:32b", "hermes3:8b")));
+        panel.open();
+        type(panel, "/m q");
+
+        tab(panel);
+        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> {
+            tab(panel);
+            assertEquals("/m qwen2.5:", panel.inputBufferForTesting());
+        });
+
+        // No further common prefix, so TAB cycles through the matches and 
wraps around.
+        tab(panel);
+        assertEquals("/m qwen2.5:14b", panel.inputBufferForTesting());
+        tab(panel);
+        assertEquals("/m qwen2.5:32b", panel.inputBufferForTesting());
+        tab(panel);
+        assertEquals("/m qwen2.5:14b", panel.inputBufferForTesting());
+    }
+
+    @Test
+    void tabCompletesToolModeArgument() {
+        AiPanel panel = new AiPanel();
+        panel.open();
+        type(panel, "/tools c");
+
+        tab(panel);
+
+        assertEquals("/tools core ", panel.inputBufferForTesting());
+    }
+
+    @Test
+    void tabDoesNotCompleteArgumentsOfOtherCommands() {
+        AiPanel panel = new AiPanel();
+        panel.open();
+        type(panel, "/run --exam");
+
+        tab(panel);
+
+        assertEquals("/run --exam", panel.inputBufferForTesting());
+    }
+
     // ---- tool set and system prompt tests ----
 
     @Test

Reply via email to