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 694bb16ca8ba CAMEL-24254: Add configurable TUI shell and AI prompt
history
694bb16ca8ba is described below
commit 694bb16ca8ba722a35f101dfeb9182c98ced744c
Author: Omar Atie <[email protected]>
AuthorDate: Sat Jul 25 02:05:21 2026 -0700
CAMEL-24254: Add configurable TUI shell and AI prompt history
Introduce camel.tui.shell.history and camel.tui.ai.promptHistory settings
(default 100, 0 disables) with persisted recall for the embedded shell (F6)
and AI prompt line (F8). History files are stored under ~/.camel/. Prompt
history recall is disabled while the AI usage stats view is shown.
Closes #25101
Co-authored-by: Cursor <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-tui.adoc | 14 +-
dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml | 5 +
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 46 ++++++
.../dsl/jbang/core/commands/tui/ShellPanel.java | 33 +++--
.../jbang/core/commands/tui/TuiHistoryFiles.java | 42 ++++++
.../jbang/core/commands/tui/TuiHistoryLimits.java | 41 ++++++
.../jbang/core/commands/tui/TuiPromptHistory.java | 159 +++++++++++++++++++++
.../dsl/jbang/core/commands/tui/TuiSettings.java | 32 +++++
.../core/commands/tui/TuiShellHistorySupport.java | 48 +++++++
.../commands/tui/AiPanelPromptHistoryTest.java | 108 ++++++++++++++
.../core/commands/tui/TuiHistoryLimitsTest.java | 51 +++++++
.../core/commands/tui/TuiPromptHistoryTest.java | 92 ++++++++++++
.../jbang/core/commands/tui/TuiSettingsTest.java | 25 ++--
.../commands/tui/TuiShellHistorySupportTest.java | 86 +++++++++++
14 files changed, 758 insertions(+), 24 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 b9914b4111b5..0319c6c5c85d 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -353,7 +353,8 @@ Use *↑*/*↓* to move between rows, *Space* (or *←*/*→*) to
cycle the them
edit the default folder, *Enter* to save, and *Esc* to cancel.
Settings are stored under `camel.tui.*` keys (`camel.tui.theme`,
`camel.tui.startTab`,
-`camel.tui.selectTab`, `camel.tui.defaultFolder`) in the Camel CLI
configuration file. Each key is read from and
+`camel.tui.selectTab`, `camel.tui.defaultFolder`, `camel.tui.shell.history`,
+`camel.tui.ai.promptHistory`) in the Camel CLI configuration file. Each key is
read from and
written back to the file where it currently lives: a key present in the local
`./camel-cli.properties` is treated as a project-level override and stays
local, while every
other key defaults to the global `~/.camel-cli.properties`. This means a
project can
@@ -361,6 +362,14 @@ deliberately pin a starting tab in its local config
without redirecting your per
into the project file. See xref:camel-jbang-configuration.adoc[Configuration]
for details
on the global and local files.
+=== Input history
+
+The embedded shell (*F6*) and AI prompt (*F8*) keep a recall list for the
command line and prompt
+respectively. Use *↑*/*↓* on the input line to walk previous entries. Limits
are configured with
+`camel.tui.shell.history` and `camel.tui.ai.promptHistory` in
`.camel-cli.properties` (default
+`100` each; set to `0` to disable recall and persistence). Shell history is
stored in
+`~/.camel/tui-shell.history`; AI prompt history in
`~/.camel/tui-ai-prompt.history`.
+
== Keyboard Shortcuts
=== Global (All Tabs)
@@ -484,6 +493,9 @@ When the AI panel is open, input that starts with `/` runs
a local panel command
| Send a message through `camel cmd send`. A body that is exactly one `@file`
token is sent as `file:<path>`; inline `@file` text is sent literally.
|===
+Submitted prompts (including slash commands) participate in AI prompt history
when
+`camel.tui.ai.promptHistory` is not `0`. Use *↑*/*↓* on the prompt line to
recall them.
+
=== 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/pom.xml
b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
index cd82849d3c63..2e37992344f9 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
@@ -94,6 +94,11 @@
<version>${awaitility-version}</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
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 2cbf27d80bbe..9d2e93b10451 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
@@ -98,6 +98,7 @@ class AiPanel {
// Input state
private final StringBuilder inputBuffer = new StringBuilder();
private int cursorPos;
+ private TuiPromptHistory promptHistory;
// TAB completion cycle state. completionMatches holds the candidate
command names for the active cycle;
// completionSnapshot is the buffer text a TAB press last produced. The
cycle continues only while the buffer still
@@ -252,6 +253,7 @@ class AiPanel {
void open() {
visible = true;
+ reloadPromptHistory();
if (client == null) {
initClient();
}
@@ -412,6 +414,14 @@ class AiPanel {
}
return true;
}
+ if (!statsView && ke.isKey(KeyCode.UP) && promptHistory != null &&
promptHistory.isEnabled()) {
+
promptHistory.previous(inputBuffer.toString()).ifPresent(this::replaceInputBuffer);
+ return true;
+ }
+ if (!statsView && ke.isKey(KeyCode.DOWN) && promptHistory != null &&
promptHistory.isEnabled()) {
+
promptHistory.next(inputBuffer.toString()).ifPresent(this::replaceInputBuffer);
+ return true;
+ }
if (thinking.get()) {
if (ke.isCtrlC() || ke.isKey(KeyCode.ESCAPE)) {
interruptBusyOperation();
@@ -433,6 +443,9 @@ class AiPanel {
}
if (ke.isKey(KeyCode.BACKSPACE)) {
if (cursorPos > 0) {
+ if (promptHistory != null) {
+ promptHistory.resetNavigation();
+ }
inputBuffer.deleteCharAt(cursorPos - 1);
cursorPos--;
}
@@ -440,6 +453,9 @@ class AiPanel {
}
if (ke.isKey(KeyCode.DELETE)) {
if (cursorPos < inputBuffer.length()) {
+ if (promptHistory != null) {
+ promptHistory.resetNavigation();
+ }
inputBuffer.deleteCharAt(cursorPos);
}
return true;
@@ -469,6 +485,9 @@ class AiPanel {
return true;
}
if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
+ if (promptHistory != null) {
+ promptHistory.resetNavigation();
+ }
inputBuffer.insert(cursorPos, ke.character());
cursorPos++;
return true;
@@ -553,6 +572,9 @@ class AiPanel {
private void submitInput() {
String input = inputBuffer.toString().trim();
+ if (promptHistory != null) {
+ promptHistory.remember(input);
+ }
inputBuffer.setLength(0);
cursorPos = 0;
scrollOffset = 0;
@@ -563,6 +585,22 @@ class AiPanel {
}
}
+ private void reloadPromptHistory() {
+ TuiSettings settings = TuiSettings.load();
+ promptHistory =
TuiPromptHistory.load(settings.getAiPromptHistoryLimit(),
TuiHistoryFiles.aiPromptHistoryFile());
+ }
+
+ private void replaceInputBuffer(String text) {
+ inputBuffer.setLength(0);
+ if (text != null) {
+ inputBuffer.append(text);
+ }
+ cursorPos = inputBuffer.length();
+ completionMatches = null;
+ completionCycleIndex = -1;
+ completionSnapshot = null;
+ }
+
private void executeSlashCommand(String input) {
Optional<AiSlashCommandRegistry.ParsedCommand> parsed =
slashCommands.parse(input);
if (parsed.isPresent() && (thinking.get() || activeCliCommand !=
null)) {
@@ -1468,6 +1506,14 @@ class AiPanel {
}
}
+ void setPromptHistoryForTesting(TuiPromptHistory history) {
+ this.promptHistory = history;
+ }
+
+ List<String> promptHistoryEntriesForTesting() {
+ return promptHistory == null ? List.of() :
promptHistory.entriesForTesting();
+ }
+
void setClientForTesting(LlmClient client) {
this.client = client;
this.initError = null;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
index e59edf3bab18..6af96449f142 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
@@ -55,6 +55,7 @@ import org.jline.builtins.PosixCommandGroup;
import org.jline.picocli.PicocliCommandRegistry;
import org.jline.reader.LineReader;
import org.jline.shell.Shell;
+import org.jline.shell.ShellBuilder;
import org.jline.shell.impl.DefaultCommandDispatcher;
import org.jline.terminal.Size;
import org.jline.terminal.impl.LineDisciplineTerminal;
@@ -496,20 +497,7 @@ class ShellPanel {
EnvironmentHelper.setSelectedProcess(ctx.selectedName());
}
- try (Shell shell = Shell.builder()
- .terminal(terminal)
- .prompt(ShellPanel::buildPrompt)
- .groups(registry, new PosixCommandGroup(), new
InteractiveCommandGroup())
- .historyCommands(true)
- .helpCommands(true)
- .commandHighlighter(false)
- .variable(LineReader.LIST_MAX, 50)
- .onReaderReady((reader, dispatcher) -> {
- if (dispatcher instanceof DefaultCommandDispatcher
dcd) {
-
dcd.session().setWorkingDirectory(Path.of("").toAbsolutePath());
- }
- })
- .build()) {
+ try (Shell shell = buildShell(terminal, registry)) {
EnvironmentHelper.setActiveTerminal(terminal);
shell.run();
shellExited = true;
@@ -523,6 +511,23 @@ class ShellPanel {
}
}
+ private static Shell buildShell(LineDisciplineTerminal terminal,
PicocliCommandRegistry registry) throws IOException {
+ ShellBuilder builder = Shell.builder()
+ .terminal(terminal)
+ .prompt(ShellPanel::buildPrompt)
+ .groups(registry, new PosixCommandGroup(), new
InteractiveCommandGroup())
+ .helpCommands(true)
+ .commandHighlighter(false)
+ .variable(LineReader.LIST_MAX, 50)
+ .onReaderReady((reader, dispatcher) -> {
+ if (dispatcher instanceof DefaultCommandDispatcher dcd) {
+
dcd.session().setWorkingDirectory(Path.of("").toAbsolutePath());
+ }
+ });
+ TuiShellHistorySupport.configure(builder, TuiSettings.load());
+ return builder.build();
+ }
+
private static String buildPrompt() {
AttributedStringBuilder sb = new AttributedStringBuilder();
sb.append("camel",
AttributedStyle.DEFAULT.bold().foregroundRgb(0xF69123));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHistoryFiles.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHistoryFiles.java
new file mode 100644
index 000000000000..38ab4ff96af7
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHistoryFiles.java
@@ -0,0 +1,42 @@
+/*
+ * 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 org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+
+/**
+ * On-disk history files for the embedded TUI shell and AI prompt, stored
under the Camel CLI home directory.
+ */
+final class TuiHistoryFiles {
+
+ private TuiHistoryFiles() {
+ }
+
+ static Path shellHistoryFile() {
+ return historyFile("tui-shell.history");
+ }
+
+ static Path aiPromptHistoryFile() {
+ return historyFile("tui-ai-prompt.history");
+ }
+
+ private static Path historyFile(String name) {
+ return
CommandLineHelper.getHomeDir().resolve(CommandLineHelper.CAMEL_DIR).resolve(name);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHistoryLimits.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHistoryLimits.java
new file mode 100644
index 000000000000..a8eac38f1e64
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHistoryLimits.java
@@ -0,0 +1,41 @@
+/*
+ * 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;
+
+/**
+ * Parses {@code camel.tui.*} history limit settings. A limit of {@code 0}
disables recall and persistence; when unset,
+ * {@link #DEFAULT_LIMIT} applies.
+ */
+final class TuiHistoryLimits {
+
+ static final int DEFAULT_LIMIT = 100;
+
+ private TuiHistoryLimits() {
+ }
+
+ static int resolveLimit(String configured) {
+ if (configured == null || configured.isBlank()) {
+ return DEFAULT_LIMIT;
+ }
+ try {
+ int value = Integer.parseInt(configured.trim());
+ return Math.max(0, value);
+ } catch (NumberFormatException e) {
+ return DEFAULT_LIMIT;
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPromptHistory.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPromptHistory.java
new file mode 100644
index 000000000000..8be1dd760283
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPromptHistory.java
@@ -0,0 +1,159 @@
+/*
+ * 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.lang.System.Logger;
+import java.lang.System.Logger.Level;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Bounded, persisted recall list for the AI prompt input line. Entries are
stored oldest-to-newest; Up/Down walks from
+ * the newest backward, restoring a draft when returning past the newest entry.
+ */
+final class TuiPromptHistory {
+
+ private static final Logger LOG =
System.getLogger(TuiPromptHistory.class.getName());
+
+ private final int maxSize;
+ private final Path file;
+ private final List<String> entries = new ArrayList<>();
+ private int browseIndex = -1;
+ private String draft = "";
+
+ private TuiPromptHistory(int maxSize, Path file, List<String> entries) {
+ this.maxSize = maxSize;
+ this.file = file;
+ this.entries.addAll(entries);
+ }
+
+ static TuiPromptHistory load(int maxSize, Path file) {
+ if (maxSize <= 0) {
+ return new TuiPromptHistory(0, file, List.of());
+ }
+ List<String> loaded = readEntries(file, maxSize);
+ return new TuiPromptHistory(maxSize, file, loaded);
+ }
+
+ boolean isEnabled() {
+ return maxSize > 0;
+ }
+
+ List<String> entriesForTesting() {
+ return Collections.unmodifiableList(entries);
+ }
+
+ void remember(String line) {
+ if (!isEnabled() || line == null) {
+ return;
+ }
+ String trimmed = line.trim();
+ if (trimmed.isEmpty()) {
+ return;
+ }
+ if (!entries.isEmpty() && entries.get(entries.size() -
1).equals(trimmed)) {
+ resetNavigation();
+ return;
+ }
+ entries.add(trimmed);
+ trimToMax();
+ resetNavigation();
+ persist();
+ }
+
+ Optional<String> previous(String currentDraft) {
+ if (!isEnabled() || entries.isEmpty()) {
+ return Optional.empty();
+ }
+ if (browseIndex < 0) {
+ draft = currentDraft;
+ browseIndex = entries.size();
+ }
+ if (browseIndex == 0) {
+ return Optional.empty();
+ }
+ browseIndex--;
+ return Optional.of(entries.get(browseIndex));
+ }
+
+ Optional<String> next(String currentDraft) {
+ if (!isEnabled() || browseIndex < 0) {
+ return Optional.empty();
+ }
+ if (entries.isEmpty()) {
+ browseIndex = -1;
+ return Optional.of(draft == null ? "" : draft);
+ }
+ if (browseIndex >= entries.size() - 1) {
+ browseIndex = -1;
+ return Optional.of(draft == null ? "" : draft);
+ }
+ browseIndex++;
+ return Optional.of(entries.get(browseIndex));
+ }
+
+ void resetNavigation() {
+ browseIndex = -1;
+ draft = "";
+ }
+
+ private void trimToMax() {
+ while (entries.size() > maxSize) {
+ entries.remove(0);
+ }
+ }
+
+ private static List<String> readEntries(Path file, int maxSize) {
+ if (!Files.isRegularFile(file)) {
+ return List.of();
+ }
+ try {
+ List<String> lines = Files.readAllLines(file,
StandardCharsets.UTF_8);
+ List<String> result = new ArrayList<>(lines.size());
+ for (String line : lines) {
+ if (line != null && !line.isBlank()) {
+ result.add(line.trim());
+ }
+ }
+ if (result.size() > maxSize) {
+ return new ArrayList<>(result.subList(result.size() - maxSize,
result.size()));
+ }
+ return result;
+ } catch (IOException e) {
+ LOG.log(Level.DEBUG, "Failed to read AI prompt history from {0}:
{1}", file, e.getMessage());
+ return List.of();
+ }
+ }
+
+ private void persist() {
+ if (!isEnabled()) {
+ return;
+ }
+ try {
+ Files.createDirectories(file.getParent());
+ Files.write(file, entries, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ LOG.log(Level.DEBUG, "Failed to write AI prompt history to {0}:
{1}", file, e.getMessage());
+ }
+ }
+}
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 005ad20022eb..ad5d07ceeca3 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
@@ -36,6 +36,8 @@ final class TuiSettings {
static final String PROP_AI_PROVIDER = "camel.tui.ai.provider";
static final String PROP_AI_MODEL = "camel.tui.ai.model";
static final String PROP_AI_URL = "camel.tui.ai.url";
+ static final String PROP_SHELL_HISTORY = "camel.tui.shell.history";
+ static final String PROP_AI_PROMPT_HISTORY = "camel.tui.ai.promptHistory";
private String themeId;
private String startTab;
@@ -46,6 +48,8 @@ final class TuiSettings {
private String aiProvider;
private String aiModel;
private String aiUrl;
+ private String shellHistory;
+ private String aiPromptHistory;
String getThemeId() {
return themeId;
@@ -119,6 +123,30 @@ final class TuiSettings {
this.aiUrl = aiUrl;
}
+ String getShellHistory() {
+ return shellHistory;
+ }
+
+ void setShellHistory(String shellHistory) {
+ this.shellHistory = shellHistory;
+ }
+
+ String getAiPromptHistory() {
+ return aiPromptHistory;
+ }
+
+ void setAiPromptHistory(String aiPromptHistory) {
+ this.aiPromptHistory = aiPromptHistory;
+ }
+
+ int getShellHistoryLimit() {
+ return TuiHistoryLimits.resolveLimit(shellHistory);
+ }
+
+ int getAiPromptHistoryLimit() {
+ return TuiHistoryLimits.resolveLimit(aiPromptHistory);
+ }
+
/**
* Loads the current settings, resolving each key with per-key
local/global precedence via {@link TuiUserConfig}.
* Unset keys yield {@code null} fields; a read failure yields an object
with {@code null} fields rather than
@@ -136,6 +164,8 @@ final class TuiSettings {
settings.aiProvider =
trimToNull(TuiUserConfig.read(PROP_AI_PROVIDER));
settings.aiModel = trimToNull(TuiUserConfig.read(PROP_AI_MODEL));
settings.aiUrl = trimToNull(TuiUserConfig.read(PROP_AI_URL));
+ settings.shellHistory =
trimToNull(TuiUserConfig.read(PROP_SHELL_HISTORY));
+ settings.aiPromptHistory =
trimToNull(TuiUserConfig.read(PROP_AI_PROMPT_HISTORY));
} catch (RuntimeException e) {
// best-effort: return an object with null fields on read failure
}
@@ -158,6 +188,8 @@ final class TuiSettings {
TuiUserConfig.write(PROP_AI_PROVIDER, aiProvider);
TuiUserConfig.write(PROP_AI_MODEL, aiModel);
TuiUserConfig.write(PROP_AI_URL, aiUrl);
+ TuiUserConfig.write(PROP_SHELL_HISTORY, shellHistory);
+ TuiUserConfig.write(PROP_AI_PROMPT_HISTORY, aiPromptHistory);
} catch (RuntimeException e) {
// best-effort: a save failure must not disrupt the TUI
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiShellHistorySupport.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiShellHistorySupport.java
new file mode 100644
index 000000000000..3bb6d229218e
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiShellHistorySupport.java
@@ -0,0 +1,48 @@
+/*
+ * 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.Files;
+import java.nio.file.Path;
+
+import org.jline.reader.LineReader;
+import org.jline.shell.ShellBuilder;
+
+/**
+ * Applies {@link TuiSettings} shell command-line history limits to a JLine
{@link ShellBuilder}.
+ */
+final class TuiShellHistorySupport {
+
+ private TuiShellHistorySupport() {
+ }
+
+ static void configure(ShellBuilder builder, TuiSettings settings) throws
IOException {
+ int limit = settings.getShellHistoryLimit();
+ if (limit == 0) {
+ builder.historyCommands(false);
+ builder.variable(LineReader.DISABLE_HISTORY, true);
+ return;
+ }
+ builder.historyCommands(true);
+ Path historyFile = TuiHistoryFiles.shellHistoryFile();
+ Files.createDirectories(historyFile.getParent());
+ builder.historyFile(historyFile);
+ builder.variable(LineReader.HISTORY_SIZE, limit);
+ builder.variable(LineReader.HISTORY_FILE_SIZE, limit);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptHistoryTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptHistoryTest.java
new file mode 100644
index 000000000000..beaba54760c4
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptHistoryTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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 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.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@Isolated
+class AiPanelPromptHistoryTest {
+
+ @Test
+ void promptHistoryRecallWithUpDown(@TempDir Path tempDir) {
+ AiPanel panel = new AiPanel();
+ panel.setClientForTesting(LlmClient.create());
+ panel.open();
+ TuiPromptHistory history = TuiPromptHistory.load(10,
tempDir.resolve("history.txt"));
+ history.remember("first question");
+ history.remember("second question");
+ panel.setPromptHistoryForTesting(history);
+
+ type(panel, "draft");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.UP, KeyModifiers.NONE));
+ assertThat(panel.inputBufferForTesting()).isEqualTo("second question");
+
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.UP, KeyModifiers.NONE));
+ assertThat(panel.inputBufferForTesting()).isEqualTo("first question");
+
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ assertThat(panel.inputBufferForTesting()).isEqualTo("second question");
+
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ assertThat(panel.inputBufferForTesting()).isEqualTo("draft");
+ }
+
+ @Test
+ void submitInputRemembersPrompt(@TempDir Path tempDir) {
+ AiPanel panel = new AiPanel();
+ panel.setClientForTesting(LlmClient.create());
+ panel.open();
+ panel.setPromptHistoryForTesting(TuiPromptHistory.load(10,
tempDir.resolve("history.txt")));
+
+ type(panel, "/help");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+
assertThat(panel.promptHistoryEntriesForTesting()).containsExactly("/help");
+ }
+
+ @Test
+ void promptHistoryDisabledWhenLimitZero(@TempDir Path tempDir) {
+ AiPanel panel = new AiPanel();
+ panel.setClientForTesting(LlmClient.create());
+ panel.open();
+ panel.setPromptHistoryForTesting(TuiPromptHistory.load(0,
tempDir.resolve("history.txt")));
+
+ type(panel, "/help");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ type(panel, "draft");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.UP, KeyModifiers.NONE));
+
+ assertThat(panel.inputBufferForTesting()).isEqualTo("draft");
+ assertThat(panel.promptHistoryEntriesForTesting()).isEmpty();
+ }
+
+ @Test
+ void promptHistoryIgnoredInStatsView(@TempDir Path tempDir) {
+ AiPanel panel = new AiPanel();
+ panel.setClientForTesting(LlmClient.create());
+ panel.open();
+ TuiPromptHistory history = TuiPromptHistory.load(10,
tempDir.resolve("history.txt"));
+ history.remember("previous prompt");
+ panel.setPromptHistoryForTesting(history);
+
+ type(panel, "draft");
+ panel.handleKeyEvent(McpFacade.parseKey("Ctrl+u"));
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.UP, KeyModifiers.NONE));
+
+ assertThat(panel.inputBufferForTesting()).isEqualTo("draft");
+ }
+
+ private static void type(AiPanel panel, String text) {
+ for (char ch : text.toCharArray()) {
+ panel.handleKeyEvent(KeyEvent.ofChar(ch));
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHistoryLimitsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHistoryLimitsTest.java
new file mode 100644
index 000000000000..617503cb71ba
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHistoryLimitsTest.java
@@ -0,0 +1,51 @@
+/*
+ * 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.assertj.core.api.Assertions.assertThat;
+
+class TuiHistoryLimitsTest {
+
+ @Test
+ void unsetUsesDefaultLimit() {
+
assertThat(TuiHistoryLimits.resolveLimit(null)).isEqualTo(TuiHistoryLimits.DEFAULT_LIMIT);
+ assertThat(TuiHistoryLimits.resolveLimit("
")).isEqualTo(TuiHistoryLimits.DEFAULT_LIMIT);
+ }
+
+ @Test
+ void zeroDisablesHistory() {
+ assertThat(TuiHistoryLimits.resolveLimit("0")).isZero();
+ }
+
+ @Test
+ void parsesPositiveLimit() {
+ assertThat(TuiHistoryLimits.resolveLimit("100")).isEqualTo(100);
+ assertThat(TuiHistoryLimits.resolveLimit(" 25 ")).isEqualTo(25);
+ }
+
+ @Test
+ void negativeValuesClampToZero() {
+ assertThat(TuiHistoryLimits.resolveLimit("-3")).isZero();
+ }
+
+ @Test
+ void invalidValueFallsBackToDefault() {
+
assertThat(TuiHistoryLimits.resolveLimit("many")).isEqualTo(TuiHistoryLimits.DEFAULT_LIMIT);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPromptHistoryTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPromptHistoryTest.java
new file mode 100644
index 000000000000..8783cd1ac31a
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPromptHistoryTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TuiPromptHistoryTest {
+
+ @Test
+ void disabledHistoryDoesNotStoreEntries(@TempDir Path tempDir) {
+ Path file = tempDir.resolve("prompt.history");
+ TuiPromptHistory history = TuiPromptHistory.load(0, file);
+
+ history.remember("hello");
+
+ assertThat(history.entriesForTesting()).isEmpty();
+ assertThat(Files.exists(file)).isFalse();
+ assertThat(history.previous("draft")).isEmpty();
+ }
+
+ @Test
+ void rememberTrimPersistAndTrimToMax(@TempDir Path tempDir) throws
Exception {
+ Path file = tempDir.resolve("prompt.history");
+ TuiPromptHistory history = TuiPromptHistory.load(2, file);
+
+ history.remember(" first ");
+ history.remember("second");
+ history.remember("third");
+
+ assertThat(history.entriesForTesting()).containsExactly("second",
"third");
+
assertThat(Files.readString(file)).contains("second").contains("third").doesNotContain("first");
+ }
+
+ @Test
+ void upDownRecallAndRestoreDraft(@TempDir Path tempDir) {
+ Path file = tempDir.resolve("prompt.history");
+ TuiPromptHistory history = TuiPromptHistory.load(10, file);
+ history.remember("alpha");
+ history.remember("beta");
+
+ assertThat(history.previous("draft")).contains("beta");
+ assertThat(history.previous("ignored")).contains("alpha");
+ assertThat(history.previous("ignored")).isEmpty();
+
+ assertThat(history.next("ignored")).contains("beta");
+ assertThat(history.next("ignored")).contains("draft");
+ assertThat(history.next("ignored")).isEmpty();
+ }
+
+ @Test
+ void consecutiveDuplicateIsNotStoredTwice(@TempDir Path tempDir) throws
Exception {
+ Path file = tempDir.resolve("prompt.history");
+ TuiPromptHistory history = TuiPromptHistory.load(10, file);
+
+ history.remember("same");
+ history.remember("same");
+
+ assertThat(history.entriesForTesting()).containsExactly("same");
+ assertThat(Files.readString(file).lines().count()).isEqualTo(1);
+ }
+
+ @Test
+ void reloadsExistingFile(@TempDir Path tempDir) throws Exception {
+ Path file = tempDir.resolve("prompt.history");
+ Files.writeString(file, "one\ntwo\n");
+
+ TuiPromptHistory history = TuiPromptHistory.load(10, file);
+
+ assertThat(history.previous("")).contains("two");
+ assertThat(history.previous("")).contains("one");
+ }
+}
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 9035554dc3b3..b41d4c3856ed 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
@@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test;
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.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -62,15 +63,19 @@ class TuiSettingsTest {
settings.setAiProvider("gemini");
settings.setAiModel("gemini-3.5-flash");
settings.setAiUrl("https://generativelanguage.googleapis.com");
+ settings.setShellHistory("25");
+ settings.setAiPromptHistory("50");
settings.save();
TuiSettings loaded = TuiSettings.load();
- assertEquals("light", loaded.getThemeId());
- assertEquals("Health", loaded.getStartTab());
- assertEquals("/tmp/projects", loaded.getDefaultFolder());
- assertEquals("gemini", loaded.getAiProvider());
- assertEquals("gemini-3.5-flash", loaded.getAiModel());
- assertEquals("https://generativelanguage.googleapis.com",
loaded.getAiUrl());
+ assertThat(loaded.getThemeId()).isEqualTo("light");
+ assertThat(loaded.getStartTab()).isEqualTo("Health");
+ assertThat(loaded.getDefaultFolder()).isEqualTo("/tmp/projects");
+ assertThat(loaded.getAiProvider()).isEqualTo("gemini");
+ assertThat(loaded.getAiModel()).isEqualTo("gemini-3.5-flash");
+
assertThat(loaded.getAiUrl()).isEqualTo("https://generativelanguage.googleapis.com");
+ assertThat(loaded.getShellHistory()).isEqualTo("25");
+ assertThat(loaded.getAiPromptHistory()).isEqualTo("50");
}
@Test
@@ -106,9 +111,11 @@ class TuiSettingsTest {
useHome(tempDir);
TuiSettings settings = TuiSettings.load();
- assertNull(settings.getThemeId());
- assertNull(settings.getStartTab());
- assertNull(settings.getDefaultFolder());
+ assertThat(settings.getThemeId()).isNull();
+ assertThat(settings.getStartTab()).isNull();
+ assertThat(settings.getDefaultFolder()).isNull();
+
assertThat(settings.getShellHistoryLimit()).isEqualTo(TuiHistoryLimits.DEFAULT_LIMIT);
+
assertThat(settings.getAiPromptHistoryLimit()).isEqualTo(TuiHistoryLimits.DEFAULT_LIMIT);
}
@Test
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiShellHistorySupportTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiShellHistorySupportTest.java
new file mode 100644
index 000000000000..4deac3c444b0
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiShellHistorySupportTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.Files;
+import java.nio.file.Path;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.jline.reader.LineReader;
+import org.jline.shell.Shell;
+import org.jline.shell.ShellBuilder;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TuiShellHistorySupportTest {
+
+ private String originalHome;
+
+ @AfterEach
+ void tearDown() {
+ if (originalHome != null) {
+ CommandLineHelper.useHomeDir(originalHome);
+ }
+ }
+
+ @Test
+ void zeroDisablesShellHistory(@TempDir Path tempDir) throws Exception {
+ useHome(tempDir);
+ TuiSettings settings = new TuiSettings();
+ settings.setShellHistory("0");
+
+ ShellBuilder builder = newShellBuilder();
+ TuiShellHistorySupport.configure(builder, settings);
+ try (var shell = builder.build()) {
+ LineReader reader = shell.reader();
+
assertThat(reader.getVariable(LineReader.DISABLE_HISTORY)).isEqualTo(true);
+ }
+ }
+
+ @Test
+ void positiveLimitUsesCamelHistoryFile(@TempDir Path tempDir) throws
Exception {
+ useHome(tempDir);
+ TuiSettings settings = new TuiSettings();
+ settings.setShellHistory("50");
+
+ ShellBuilder builder = newShellBuilder();
+ TuiShellHistorySupport.configure(builder, settings);
+ try (var shell = builder.build()) {
+ LineReader reader = shell.reader();
+
assertThat(reader.getVariable(LineReader.HISTORY_SIZE)).isEqualTo(50);
+
assertThat(reader.getVariable(LineReader.HISTORY_FILE_SIZE)).isEqualTo(50);
+
assertThat(Path.of(String.valueOf(reader.getVariable(LineReader.HISTORY_FILE))))
+ .isEqualTo(TuiHistoryFiles.shellHistoryFile());
+
assertThat(Files.isDirectory(tempDir.resolve(CommandLineHelper.CAMEL_DIR))).isTrue();
+ }
+ }
+
+ private static ShellBuilder newShellBuilder() throws Exception {
+ Terminal terminal = TerminalBuilder.builder().dumb(true).build();
+ return Shell.builder().terminal(terminal).prompt("camel> ");
+ }
+
+ private void useHome(Path dir) {
+ originalHome = CommandLineHelper.getHomeDir().toString();
+ CommandLineHelper.useHomeDir(dir.toString());
+ }
+}