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 2b8227a44ce7 CAMEL-24397: camel-tui - Fix --record producing no cast
file
2b8227a44ce7 is described below
commit 2b8227a44ce7c7f994489b95b76fa521e0af37ad
Author: Adriano Machado <[email protected]>
AuthorDate: Mon Aug 24 08:15:08 2026 -0400
CAMEL-24397: camel-tui - Fix --record producing no cast file
TamboUI only applies its recording wrapper in BackendFactory.create(),
but TuiBackendHelper always supplies an explicit JLineBackend, so that
factory path — and recording — never ran. --record exited cleanly
without replaying a tape or writing a .cast file.
Apply the recording wrapper explicitly in TuiBackendHelper instead of
relying on auto-discovery (which stays disabled here to avoid picking
an Aesh backend that doesn't shut down cleanly under --web). Replace
the hardcoded 200x50 recording geometry with --record-size,
--record-fps and --record-duration, forwarded from TuiCommand (the
plugin's actual entry point). Reject --record combined with --web,
since the underlying tamboui.record* properties are process-wide and
every browser session would otherwise share one cast file.
Closes #25514
Co-Authored-By: Claude Opus 5 <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 7 +
.../modules/ROOT/pages/camel-jbang-tui.adoc | 29 ++++
dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml | 6 +
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 106 +++++++++++--
.../jbang/core/commands/tui/TuiBackendHelper.java | 24 ++-
.../dsl/jbang/core/commands/tui/TuiCommand.java | 41 ++++-
.../tui/CamelMonitorRecordOptionsTest.java | 122 +++++++++++++++
.../tui/TuiBackendHelperRecordingTest.java | 168 +++++++++++++++++++++
.../commands/tui/TuiCommandRecordOptionsTest.java | 76 ++++++++++
9 files changed, 565 insertions(+), 14 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 7de03cbc6c53..ee297b9db3c3 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -85,6 +85,13 @@ The same default is now also applied to the `ClientConfig`
that Camel builds for
endpoints, when neither a referenced `ClientConfig` nor `hazelcastConfigUri`
is supplied. Client mode
previously behaved differently from node mode for an otherwise identical
endpoint configuration.
+=== camel-jbang (TUI)
+
+`camel tui --record` is now rejected when combined with `--web`. The recording
configuration applies
+to the whole process, so a browser session served by `--web` would be recorded
into the same `.cast`
+file as the local session. Previously the combination was accepted, but
recording never produced any
+output, so run the two modes in separate processes instead.
+
=== camel-mail
`MimeMultipartDataFormat` now uses `MailHeaderFilterStrategy` instead of a
plain
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 9c7192eab078..eac4a6e96dc5 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -892,6 +892,23 @@ https://asciinema.org/[Asciinema] `.cast` recording:
camel tui --record=demo.tape
----
+The `.cast` file is written next to the tape, with the `.tape` suffix replaced
by `.cast`.
+Recording is headless: the TUI is driven entirely by the tape rather than by
your terminal,
+so no keyboard input is read and nothing is drawn on screen.
+
+The recorded terminal is 200x50 by default, which is wider than a
documentation page can
+display. Use `--record-size` to record at a size that fits, and `--record-fps`
or
+`--record-duration` to control the capture rate and the cut-off:
+
+[source,bash]
+----
+camel tui --record=demo.tape --record-size=160x44 --record-fps=15
+----
+
+`--record` cannot be combined with `--web`. Recording drives a headless TUI
from the tape, and
+the recording configuration applies to the whole process, so every browser
session would be
+recorded into the same `.cast` file. Camel rejects the combination with an
error instead.
+
=== Converting to GIF
Convert recordings using https://github.com/asciinema/agg[agg] (for `.cast`
files) or
@@ -940,4 +957,16 @@ vhs demo.tape # .tape -> .gif
| `--record`
| Replay a `.tape` file and record the session to an Asciinema `.cast` file.
|
+
+| `--record-size`
+| Size of the recorded terminal for `--record`, as `<cols>x<rows>`.
+| `200x50`
+
+| `--record-fps`
+| Frames per second captured by `--record`.
+| `10`
+
+| `--record-duration`
+| Maximum duration in milliseconds captured by `--record`.
+| `120000`
|===
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
index 241adb18dbad..9882e20e78a9 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
@@ -130,6 +130,12 @@
<version>${awaitility-version}</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.junit-pioneer</groupId>
+ <artifactId>junit-pioneer</artifactId>
+ <version>${junit-pioneer-version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 92fdfaebe6c9..197aa3e99377 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -27,6 +27,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
+import java.util.Locale;
import java.util.Optional;
import java.util.Queue;
import java.util.concurrent.CompletableFuture;
@@ -82,6 +83,19 @@ public class CamelMonitor extends CamelCommand {
private static final Logger LOG =
System.getLogger(CamelMonitor.class.getName());
private static final long DEFAULT_REFRESH_MS = 500;
+ /**
+ * The TamboUI system properties {@code --record} configures. They are
process-wide, so the session that sets them
+ * clears them again on the way out; otherwise a later TUI backend created
in the same JVM would still see recording
+ * enabled via {@code RecordingConfig.isEnabled()}.
+ */
+ static final List<String> RECORD_PROPERTIES = List.of(
+ "tamboui.record",
+ "tamboui.record.config",
+ "tamboui.record.width",
+ "tamboui.record.height",
+ "tamboui.record.duration",
+ "tamboui.record.fps");
+
// Compact tab bar (10 labels + 9 "|" dividers) needs 88 chars — that is
the true minimum
private static final int MIN_WIDTH = 88;
private static final int MIN_HEIGHT = 24;
@@ -101,6 +115,21 @@ public class CamelMonitor extends CamelCommand {
arity = "0..1")
String record;
+ @CommandLine.Option(names = { "--record-size" },
+ description = "Size of the recorded terminal for
--record, as <cols>x<rows> (default: ${DEFAULT-VALUE})",
+ defaultValue = "200x50")
+ String recordSize = "200x50";
+
+ @CommandLine.Option(names = { "--record-fps" },
+ description = "Frames per second captured by --record
(default: ${DEFAULT-VALUE})",
+ defaultValue = "10")
+ int recordFps = 10;
+
+ @CommandLine.Option(names = { "--record-duration" },
+ description = "Maximum duration in milliseconds
captured by --record (default: ${DEFAULT-VALUE})",
+ defaultValue = "120000")
+ int recordDuration = 120000;
+
@CommandLine.Option(names = { "--mcp" },
description = "Enable embedded MCP server for AI agent
access to the TUI")
boolean mcp;
@@ -184,6 +213,68 @@ public class CamelMonitor extends CamelCommand {
this.classLoader = classLoader;
}
+ /**
+ * Parses a {@code --record-size} value such as {@code 160x44} into {@code
[cols, rows]}.
+ */
+ int[] parseRecordSize(String size) {
+ String[] parts = size == null ? new String[0] :
size.toLowerCase(Locale.ROOT).split("x", -1);
+ if (parts.length == 2) {
+ try {
+ int cols = Integer.parseInt(parts[0].trim());
+ int rows = Integer.parseInt(parts[1].trim());
+ if (cols > 0 && rows > 0) {
+ return new int[] { cols, rows };
+ }
+ } catch (NumberFormatException e) {
+ // fall through to the parameter error below
+ }
+ }
+ throw new CommandLine.ParameterException(
+ new CommandLine(this),
+ "Invalid value for option '--record-size': expected
'<cols>x<rows>' with positive numbers, was '"
+ + size + "'");
+ }
+
+ /**
+ * Hands the {@code --record*} options to TamboUI through the {@link
#RECORD_PROPERTIES} system properties, which is
+ * the only way TamboUI accepts a recording configuration.
+ */
+ void configureRecording() {
+ if (record == null) {
+ return;
+ }
+ if (web) {
+ // The properties below are process-wide, so every browser session
spawned by TuiWebServer would be
+ // wrapped for recording too, all writing the same cast file. The
two modes are also conceptually
+ // exclusive: --record drives a headless TUI from a tape rather
than from a connected terminal.
+ throw new CommandLine.ParameterException(
+ new CommandLine(this),
+ "Option '--record' cannot be combined with '--web':
recording replays a tape headlessly "
+ + "and would be inherited by every
browser session");
+ }
+ Path tapeFile = Path.of(record);
+ Path castFile = Path.of(record.replaceAll("\\.tape$", "") + ".cast");
+ int[] size = parseRecordSize(recordSize);
+ System.setProperty("tamboui.record",
castFile.toAbsolutePath().toString());
+ System.setProperty("tamboui.record.config",
tapeFile.toAbsolutePath().toString());
+ System.setProperty("tamboui.record.width", String.valueOf(size[0]));
+ System.setProperty("tamboui.record.height", String.valueOf(size[1]));
+ System.setProperty("tamboui.record.duration",
String.valueOf(recordDuration));
+ System.setProperty("tamboui.record.fps", String.valueOf(recordFps));
+ }
+
+ /**
+ * Undoes {@link #configureRecording()} at the end of the session that ran
it.
+ * <p>
+ * The already-loaded {@code RecordingConfig} keeps its own copy, so the
shutdown hook still writes the cast file;
+ * clearing only stops a TUI backend created later in the same JVM from
being wrapped for recording again.
+ */
+ void clearRecordingProperties() {
+ for (String key : RECORD_PROPERTIES) {
+ System.clearProperty(key);
+ }
+ }
+
@Override
public Integer doCall() throws Exception {
System.setProperty("java.awt.headless", "true");
@@ -209,16 +300,7 @@ public class CamelMonitor extends CamelCommand {
}
// Configure TamboUI recording if --record is specified
- if (record != null) {
- Path tapeFile = Path.of(record);
- Path castFile = Path.of(record.replaceAll("\\.tape$", "") +
".cast");
- System.setProperty("tamboui.record",
castFile.toAbsolutePath().toString());
- System.setProperty("tamboui.record.config",
tapeFile.toAbsolutePath().toString());
- System.setProperty("tamboui.record.width", "200");
- System.setProperty("tamboui.record.height", "50");
- System.setProperty("tamboui.record.duration", "120000");
- System.setProperty("tamboui.record.fps", "10");
- }
+ configureRecording();
recordingManager.init(record != null);
@@ -649,6 +731,10 @@ public class CamelMonitor extends CamelCommand {
}
deleteMcpJson(mcpJsonFile);
this.runner = null;
+ if (record != null) {
+ // Only the session that set the properties clears them again
+ clearRecordingProperties();
+ }
}
return 0;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
index d3abe4a7cda2..bbb0b052957f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java
@@ -17,6 +17,8 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import dev.tamboui.backend.jline3.JLineBackend;
+import dev.tamboui.internal.record.RecordingBackend;
+import dev.tamboui.internal.record.RecordingConfig;
import dev.tamboui.terminal.Backend;
import dev.tamboui.tui.TuiConfig;
import dev.tamboui.tui.TuiRunner;
@@ -35,10 +37,28 @@ final class TuiBackendHelper {
// classpath (for --web), auto-discovery can pick AeshBackend for the
local session too,
// which drives a native PosixSysTerminal that doesn't shut down
cleanly here.
JLineBackend backend = activeTerminal != null ? new
JLineBackend(activeTerminal) : new JLineBackend();
- return
TuiRunner.create(TuiConfig.builder().backend(backend).mouseCapture(true).build());
+ return createTuiRunner(backend);
}
static TuiRunner createTuiRunner(Backend backend) throws Exception {
- return
TuiRunner.create(TuiConfig.builder().backend(backend).mouseCapture(true).build());
+ return
TuiRunner.create(TuiConfig.builder().backend(applyRecording(backend)).mouseCapture(true).build());
+ }
+
+ /**
+ * Wraps the backend for Asciinema recording when {@code --record}
configured the {@code tamboui.record*} system
+ * properties.
+ * <p>
+ * TamboUI normally does this inside {@code BackendFactory.create()}, but
{@code TuiRunner} only calls that factory
+ * when no explicit backend is configured. Because we must pass an
explicit backend (see
+ * {@link #createTuiRunner()}), the wrapping has to be done here instead,
otherwise {@code --record} exits cleanly
+ * without replaying the tape or writing a {@code .cast} file.
+ */
+ static Backend applyRecording(Backend backend) {
+ // Guard on isEnabled() first: load() caches its result process-wide
and installs a System.out capture
+ if (!RecordingConfig.isEnabled()) {
+ return backend;
+ }
+ RecordingConfig config = RecordingConfig.load();
+ return config != null ? new RecordingBackend(backend, config) :
backend;
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
index b5e0b9fc3f80..fdbc5c91e531 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
@@ -59,6 +59,21 @@ public class TuiCommand extends CamelCommand {
arity = "0..1")
String record;
+ @CommandLine.Option(names = { "--record-size" },
+ description = "Size of the recorded terminal for
--record, as <cols>x<rows> (default: ${DEFAULT-VALUE})",
+ defaultValue = "200x50")
+ String recordSize = "200x50";
+
+ @CommandLine.Option(names = { "--record-fps" },
+ description = "Frames per second captured by --record
(default: ${DEFAULT-VALUE})",
+ defaultValue = "10")
+ int recordFps = 10;
+
+ @CommandLine.Option(names = { "--record-duration" },
+ description = "Maximum duration in milliseconds
captured by --record (default: ${DEFAULT-VALUE})",
+ defaultValue = "120000")
+ int recordDuration = 120000;
+
@CommandLine.Option(names = { "--theme" },
description = "Color theme: dark or light (overrides
persisted preference for this session)",
completionCandidates =
ThemeModeCompletionCandidates.class)
@@ -71,6 +86,17 @@ public class TuiCommand extends CamelCommand {
@Override
public Integer doCall() throws Exception {
+ CamelMonitor cmd = new CamelMonitor(getMain(), classLoader);
+ return new
CommandLine(cmd).execute(buildArgs().toArray(String[]::new));
+ }
+
+ /**
+ * Builds the {@link CamelMonitor} command line this command delegates to.
+ * <p>
+ * Every option declared here must be forwarded, otherwise the option is
silently accepted and then ignored. Only
+ * non-default values are passed on, so the delegate keeps applying its
own defaults.
+ */
+ List<String> buildArgs() {
List<String> args = new ArrayList<>();
if (name != null) {
args.add(name);
@@ -97,11 +123,22 @@ public class TuiCommand extends CamelCommand {
args.add("--record");
args.add(record);
}
+ if (!"200x50".equals(recordSize)) {
+ args.add("--record-size");
+ args.add(recordSize);
+ }
+ if (recordFps != 10) {
+ args.add("--record-fps");
+ args.add(String.valueOf(recordFps));
+ }
+ if (recordDuration != 120000) {
+ args.add("--record-duration");
+ args.add(String.valueOf(recordDuration));
+ }
if (theme != null) {
args.add("--theme");
args.add(theme);
}
- CamelMonitor cmd = new CamelMonitor(getMain(), classLoader);
- return new CommandLine(cmd).execute(args.toArray(String[]::new));
+ return args;
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java
new file mode 100644
index 000000000000..9b47a877c040
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.junit.jupiter.api.Test;
+import org.junitpioneer.jupiter.ClearSystemProperty;
+import picocli.CommandLine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for the {@code --record*} options.
+ * <p>
+ * The recording geometry used to be hardcoded at 200x50, which is too wide to
embed in a documentation page, so it is
+ * now configurable. A bad value must be rejected up front with a clear
message rather than silently recording at the
+ * wrong size, because the mistake would otherwise only surface once the cast
is rendered.
+ */
+// The options are handed to TamboUI through process-wide system properties,
so the tests that exercise them have to
+// leave the JVM as they found it; junit-pioneer restores the original values
after each test.
+@ClearSystemProperty(key = "tamboui.record")
+@ClearSystemProperty(key = "tamboui.record.config")
+@ClearSystemProperty(key = "tamboui.record.width")
+@ClearSystemProperty(key = "tamboui.record.height")
+@ClearSystemProperty(key = "tamboui.record.duration")
+@ClearSystemProperty(key = "tamboui.record.fps")
+class CamelMonitorRecordOptionsTest {
+
+ private final CamelMonitor monitor = new CamelMonitor(new
CamelJBangMain(), getClass().getClassLoader());
+
+ @Test
+ void parsesColumnsAndRows() {
+ assertThat(monitor.parseRecordSize("160x44")).containsExactly(160, 44);
+ }
+
+ @Test
+ void acceptsUppercaseSeparatorAndSurroundingSpaces() {
+ assertThat(monitor.parseRecordSize(" 160 X 44 ")).containsExactly(160,
44);
+ }
+
+ @Test
+ void rejectsNonPositiveDimensions() {
+ assertThatThrownBy(() -> monitor.parseRecordSize("0x44"))
+ .isInstanceOf(CommandLine.ParameterException.class)
+ .hasMessageContaining("--record-size");
+ }
+
+ @Test
+ void rejectsMalformedValues() {
+ assertThatThrownBy(() -> monitor.parseRecordSize("160"))
+ .isInstanceOf(CommandLine.ParameterException.class)
+ .hasMessageContaining("--record-size");
+ assertThatThrownBy(() -> monitor.parseRecordSize("wide x tall"))
+ .isInstanceOf(CommandLine.ParameterException.class)
+ .hasMessageContaining("--record-size");
+ }
+
+ @Test
+ void defaultsKeepTheHistoricRecordingGeometry() {
+ // 200x50 was the previously hardcoded value; keeping it as the
default means this change adds
+ // an override without altering the output of an existing command line.
+
assertThat(monitor.parseRecordSize(monitor.recordSize)).containsExactly(200,
50);
+ assertThat(monitor.recordFps).isEqualTo(10);
+ assertThat(monitor.recordDuration).isEqualTo(120000);
+ }
+
+ @Test
+ void rejectsRecordingCombinedWithTheWebTerminal() {
+ // tamboui.record* is JVM-wide, so a browser session started by
TuiWebServer would silently inherit the
+ // local session's recording and write to the same cast file. Failing
up front beats that surprise.
+ monitor.record = "demo.tape";
+ monitor.web = true;
+
+ assertThatThrownBy(monitor::configureRecording)
+ .isInstanceOf(CommandLine.ParameterException.class)
+ .hasMessageContaining("--record")
+ .hasMessageContaining("--web");
+ }
+
+ @Test
+ void configuringRecordingSetsEveryPropertyThatIsClearedAgainAfterwards() {
+ // The set and the clear list have to stay in sync: a property added
to configureRecording() but missing
+ // from RECORD_PROPERTIES would keep recording enabled for the rest of
the JVM's life.
+ monitor.record = "demo.tape";
+ monitor.recordSize = "160x44";
+
+ monitor.configureRecording();
+
+ assertThat(CamelMonitor.RECORD_PROPERTIES)
+ .allSatisfy(key ->
assertThat(System.getProperty(key)).as(key).isNotNull());
+
assertThat(System.getProperty("tamboui.record.width")).isEqualTo("160");
+
assertThat(System.getProperty("tamboui.record.height")).isEqualTo("44");
+
+ monitor.clearRecordingProperties();
+
+ assertThat(CamelMonitor.RECORD_PROPERTIES)
+ .allSatisfy(key ->
assertThat(System.getProperty(key)).as(key).isNull());
+ }
+
+ @Test
+ void leavesTheRecordingPropertiesAloneWithoutTheRecordOption() {
+ monitor.configureRecording();
+
+ assertThat(CamelMonitor.RECORD_PROPERTIES)
+ .allSatisfy(key ->
assertThat(System.getProperty(key)).as(key).isNull());
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java
new file mode 100644
index 000000000000..fa2e495ae974
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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 dev.tamboui.buffer.DiffResult;
+import dev.tamboui.internal.record.AnsiTerminalCapture;
+import dev.tamboui.layout.Position;
+import dev.tamboui.layout.Size;
+import dev.tamboui.terminal.Backend;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junitpioneer.jupiter.ClearSystemProperty;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that {@code camel tui --record} actually engages TamboUI's
recording backend.
+ * <p>
+ * TamboUI only wraps a backend for recording inside {@code
BackendFactory.create()}, and {@code TuiRunner} calls that
+ * factory <em>only</em> when no explicit backend is configured. Since
camel-tui must supply an explicit
+ * {@link dev.tamboui.backend.jline3.JLineBackend} (auto-discovery would
otherwise pick the Aesh backend that is on the
+ * classpath for {@code --web}), the recording wrapper is never applied unless
camel-tui applies it itself. When that
+ * wrapping is missing, {@code --record} replays no tape and writes no {@code
.cast} file, yet still exits cleanly, so
+ * only a test like this one catches the regression.
+ */
+// RecordingConfig.load() also reads fps and duration, so every key camel-tui
sets has to be managed here: a value
+// leaking out of this class would silently reconfigure recording in an
unrelated test. junit-pioneer clears each key
+// before the test and restores the original value afterwards, which also
covers values the test body sets itself.
+@ClearSystemProperty(key = "tamboui.record")
+@ClearSystemProperty(key = "tamboui.record.config")
+@ClearSystemProperty(key = "tamboui.record.width")
+@ClearSystemProperty(key = "tamboui.record.height")
+@ClearSystemProperty(key = "tamboui.record.duration")
+@ClearSystemProperty(key = "tamboui.record.fps")
+class TuiBackendHelperRecordingTest {
+
+ @TempDir
+ Path tempDir;
+
+ @AfterEach
+ void tearDown() {
+ // load() installs a System.out capture; restore the real stream so
other tests are unaffected
+ if (AnsiTerminalCapture.isInstalled()) {
+ AnsiTerminalCapture.uninstall();
+ }
+ }
+
+ @Test
+ void withoutRecordOptionTheBackendIsHandedToTuiRunnerUntouched() {
+ Backend original = new NoopBackend();
+
+ Backend result = TuiBackendHelper.applyRecording(original);
+
+ assertThat(result).isSameAs(original);
+ }
+
+ @Test
+ void withRecordOptionTheBackendIsWrappedAndSizedFromTheRecordingConfig()
throws Exception {
+ Path tape = tempDir.resolve("demo.tape");
+ Files.writeString(tape, "Sleep 100ms\nType \"q\"\n");
+ System.setProperty("tamboui.record",
tempDir.resolve("demo.cast").toString());
+ System.setProperty("tamboui.record.config", tape.toString());
+ System.setProperty("tamboui.record.width", "120");
+ System.setProperty("tamboui.record.height", "30");
+
+ Backend result = TuiBackendHelper.applyRecording(new NoopBackend());
+
+ // A recording backend reports the configured cast dimensions rather
than the real terminal
+ // size; asserting on those proves the configuration was applied, not
merely that some
+ // wrapper was returned.
+ assertThat(result).isNotInstanceOf(NoopBackend.class);
+ assertThat(result.size()).isEqualTo(new Size(120, 30));
+ }
+
+ /**
+ * Minimal {@link Backend} stand-in. Only {@link #size()} needs a
meaningful value, so that the test fails if the
+ * recording dimensions are taken from the delegate instead of the config.
+ */
+ private static final class NoopBackend implements Backend {
+
+ @Override
+ public void draw(DiffResult diff) throws IOException {
+ }
+
+ @Override
+ public void flush() throws IOException {
+ }
+
+ @Override
+ public void clear() throws IOException {
+ }
+
+ @Override
+ public Size size() throws IOException {
+ return new Size(80, 24);
+ }
+
+ @Override
+ public void showCursor() throws IOException {
+ }
+
+ @Override
+ public void hideCursor() throws IOException {
+ }
+
+ @Override
+ public Position getCursorPosition() throws IOException {
+ return new Position(0, 0);
+ }
+
+ @Override
+ public void setCursorPosition(Position position) throws IOException {
+ }
+
+ @Override
+ public void enterAlternateScreen() throws IOException {
+ }
+
+ @Override
+ public void leaveAlternateScreen() throws IOException {
+ }
+
+ @Override
+ public void enableRawMode() throws IOException {
+ }
+
+ @Override
+ public void disableRawMode() throws IOException {
+ }
+
+ @Override
+ public void onResize(Runnable handler) {
+ }
+
+ @Override
+ public int read(int timeoutMs) throws IOException {
+ return -2;
+ }
+
+ @Override
+ public int peek(int timeoutMs) throws IOException {
+ return -2;
+ }
+
+ @Override
+ public void close() throws IOException {
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommandRecordOptionsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommandRecordOptionsTest.java
new file mode 100644
index 000000000000..3dfb57e50cde
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommandRecordOptionsTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.util.List;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.junit.jupiter.api.Test;
+import picocli.CommandLine;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that {@code camel tui} forwards the recording options to {@link
CamelMonitor}.
+ * <p>
+ * {@link TuiCommand} is the entry point registered by the TUI plugin, but the
options are implemented on
+ * {@link CamelMonitor}, which it delegates to by rebuilding a command line.
An option declared on only one of the two
+ * fails in a way no compiler catches: declared only on {@code CamelMonitor}
it is rejected as an unknown option,
+ * declared only on {@code TuiCommand} it is accepted and then silently
dropped. This test pins the forwarding so the
+ * documented {@code camel tui --record=demo.tape --record-size=160x44
--record-fps=15} keeps working.
+ */
+class TuiCommandRecordOptionsTest {
+
+ @Test
+ void forwardsAllRecordingOptions() {
+ assertThat(buildArgs("--record=demo.tape", "--record-size=160x44",
"--record-fps=15", "--record-duration=30000"))
+ .containsExactly(
+ "--record", "demo.tape",
+ "--record-size", "160x44",
+ "--record-fps", "15",
+ "--record-duration", "30000");
+ }
+
+ @Test
+ void omitsRecordingOptionsLeftAtTheirDefault() {
+ // Passing the defaults through would be harmless but noisy; more
importantly the delegate must keep
+ // owning the default values, so they are only declared in one place.
+
assertThat(buildArgs("--record=demo.tape")).containsExactly("--record",
"demo.tape");
+ }
+
+ @Test
+ void everyRecordingOptionIsAcceptedByTheDelegate() {
+ // The forwarded command line is only useful if CamelMonitor
understands it — the bug this guards against
+ // was TuiCommand accepting --record-size and CamelMonitor never
seeing it.
+ CamelMonitor monitor = new CamelMonitor(new CamelJBangMain(),
getClass().getClassLoader());
+ String[] args = buildArgs("--record=demo.tape",
"--record-size=160x44", "--record-fps=15",
+ "--record-duration=30000").toArray(String[]::new);
+
+ new CommandLine(monitor).parseArgs(args);
+
+ assertThat(monitor.record).isEqualTo("demo.tape");
+ assertThat(monitor.recordSize).isEqualTo("160x44");
+ assertThat(monitor.recordFps).isEqualTo(15);
+ assertThat(monitor.recordDuration).isEqualTo(30000);
+ }
+
+ private List<String> buildArgs(String... args) {
+ TuiCommand command = new TuiCommand(new CamelJBangMain(),
getClass().getClassLoader());
+ new CommandLine(command).parseArgs(args);
+ return command.buildArgs();
+ }
+}