Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,10 @@ now also stops the route.
Routes that relied on steps after these processors running for unauthenticated requests must be
restructured. The authenticated paths are unchanged: a successfully authenticated request continues
through the rest of the route exactly as before, and `OAuthLogoutProcessor` is unchanged.

=== 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.
29 changes: 29 additions & 0 deletions docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
ammachado marked this conversation as resolved.
----

`--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
Expand Down Expand Up @@ -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`
|===
6 changes: 6 additions & 0 deletions dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -184,6 +213,68 @@ public CamelMonitor(CamelJBangMain main, ClassLoader classLoader) {
this.classLoader = classLoader;
}

/**
* Parses a {@code --record-size} value such as {@code 160x44} into {@code [cols, rows]}.
*/
int[] parseRecordSize(String size) {
Comment thread
ammachado marked this conversation as resolved.
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — rejecting --web + --record up front is the right call given JVM-wide tamboui.record* properties. Upgrade-guide entry documents the behaviour change for upgraders.

// 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");
Expand All @@ -209,16 +300,7 @@ public Integer doCall() throws Exception {
}

// 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);

Expand Down Expand Up @@ -649,6 +731,10 @@ public void resetIntegrationTabState() {
}
deleteMcpJson(mcpJsonFile);
this.runner = null;
if (record != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolvedclearRecordingProperties() in finally paired with the RECORD_PROPERTIES constant keeps set/clear in sync. The javadoc explaining why clearing is safe after RecordingConfig.load() is helpful.

// Only the session that set the properties clears them again
clearRecordingProperties();
}
}
return 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,10 +37,28 @@ static TuiRunner createTuiRunner() throws Exception {
// 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;
Comment thread
ammachado marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -71,6 +86,17 @@ public TuiCommand(CamelJBangMain main, ClassLoader classLoader) {

@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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolvedbuildArgs() now forwards all three recording tuning flags to CamelMonitor, and TuiCommandRecordOptionsTest pins the wiring so the documented camel tui --record-size=160x44 path keeps working. Nice refactor extracting buildArgs() for testability.

List<String> args = new ArrayList<>();
if (name != null) {
args.add(name);
Expand All @@ -97,11 +123,22 @@ public Integer doCall() throws Exception {
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;
}
}
Loading