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 fe01639b8396 chore: camel-jbang - camel tui --record plays the tape 
itself, with commands for captions, highlights and tabs (#27663)
fe01639b8396 is described below

commit fe01639b8396082354ba915ccf1c8905889cb21c
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 10 16:08:46 2026 +0200

    chore: camel-jbang - camel tui --record plays the tape itself, with 
commands for captions, highlights and tabs (#27663)
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../ROOT/images/jbang/camel-tui-tape-player.gif    | Bin 0 -> 305206 bytes
 .../ROOT/pages/camel-jbang-tui-ai-agents.adoc      |   4 +-
 .../ROOT/pages/camel-jbang-tui-settings.adoc       | 162 ++++++-
 .../modules/ROOT/pages/camel-jbang-tui.adoc        |   2 +-
 .../jbang-commands/camel-jbang-tui-monitor.adoc    |   2 +-
 .../ROOT/pages/jbang-commands/camel-jbang-tui.adoc |   2 +-
 .../camel-jbang-plugin-commands-metadata.json      |   2 +-
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |  56 ++-
 .../dsl/jbang/core/commands/tui/TapePlayer.java    | 480 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/TuiCommand.java    |   2 +-
 .../tui/CamelMonitorRecordOptionsTest.java         |  38 +-
 .../jbang/core/commands/tui/TapePlayerTest.java    | 256 +++++++++++
 12 files changed, 983 insertions(+), 23 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-tape-player.gif 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-tape-player.gif
new file mode 100644
index 000000000000..ce90576a81b3
Binary files /dev/null and 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-tape-player.gif differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
index fd312ae018d1..8e95e6e420f7 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
@@ -182,7 +182,9 @@ by purpose:
 * *Edit* -- write a source file in the integration's source directory 
(`camel_write_file`), see below
 * *Annotate* -- locate text and diagram nodes by coordinates, draw shapes 
(boxes, highlights,
   arrows, underlines, text labels), show captions with typewriter animation
-* *Present* -- take screenshots, record tape sessions, control demo pacing
+* *Present* -- take screenshots, record tape sessions, control demo pacing. To 
record a demo as a GIF,
+  an agent can also write a `.tape` file using the same commands and play it 
with `camel tui --record`, see
+  xref:camel-jbang-tui-settings.adoc#_scripted_recording[Scripted Recording]
 
 == Editing source files from an AI agent
 
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc
index 526f99653d19..d97bfa93f559 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc
@@ -284,27 +284,38 @@ same as if you'd run `camel tui` again in a new terminal.
 
 == Recording Demos
 
-The TUI can record terminal sessions for demos and documentation.
+The TUI can record its sessions for demos and documentation. A session is 
scripted as a `.tape`
+file, in the format of https://github.com/charmbracelet/vhs[VHS], which the 
TUI plays and records
+to an https://asciinema.org/[Asciinema] `.cast` file. The `.cast` file then 
converts to an
+animated GIF.
 
 === Live Recording
 
 Use the *F2* actions menu and select *Start Tape Recording* to start recording 
your session
 as a `.tape` file. Select *Stop Tape Recording* to stop. The tape captures 
your keystrokes
-with timing, producing a script that can be replayed or converted to an 
animated GIF.
+with timing, producing a script that can be played back with `--record` (see 
below), edited,
+or converted to an animated GIF.
 
 === Scripted Recording
 
-Use the `--record` flag to replay a `.tape` file in headless mode and produce 
an
-https://asciinema.org/[Asciinema] `.cast` recording:
+Use the `--record` flag to play a `.tape` file in the TUI and record what 
happens on screen:
 
 [source,bash]
 ----
-camel tui --record=demo.tape
+camel tui orders --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.
+so no keyboard input is read and nothing is drawn on screen. The integrations 
are the ones
+running on your machine, as with a plain `camel tui`, so start them before 
recording, and
+name the integration to select it from the start (`orders` above).
+
+The tape is played by the TUI itself: the keys go to the TUI as if typed, and 
on top of the
+VHS commands there are commands for the TUI, such as showing a caption, 
highlighting text on the
+screen, or switching tab by name. When the tape ends, the TUI quits and the 
recording is saved.
+Problems in the tape, such as an unknown command or a `Wait` that timed out, 
are printed as
+`Tape: ...` warnings, and the rest of the tape still plays.
 
 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
@@ -312,20 +323,149 @@ display. Use `--record-size` to record at a size that 
fits, and `--record-fps` o
 
 [source,bash]
 ----
-camel tui --record=demo.tape --record-size=160x44 --record-fps=15
+camel tui orders --record=demo.tape --record-size=120x32 
--record-duration=60000
 ----
 
+`--record-duration` is in milliseconds and stops the recording even when the 
tape is still
+playing, so give a long tape enough time.
+
 `--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.
 
+==== Tape commands
+
+A tape has one command per line. Lines starting with `#` are comments. 
Durations are written
+as `500ms`, `2s` or `1.5s`; a bare number is seconds. Text is in double 
quotes, where `\n` is
+a new line and `\"` a quote. Most commands take a timing as `Command@duration`.
+
+[cols="2,5",options="header"]
+|===
+| Command | Description
+
+| `Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `Insert`, `Space`, `Up`, 
`Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `F1` .. `F12`
+| Presses the key. A count repeats it (`Down 3`), and `@` sets the gap between 
the presses
+(`Down@300ms 3`, default 50ms).
+
+| `Ctrl+<key>`, `Alt+<key>`, `Shift+<key>`
+| Presses the key with the modifiers, which can be combined (`Ctrl+Shift+Up`). 
The key is a
+key name or a single character: `Ctrl+t`, `Shift+F8`, `Shift+Tab`.
+
+| `Type "text"`
+| Types the text, one character at a time, 50ms apart. `Type@100ms "text"` 
types slower.
+A `\n` in the text presses *Enter*, and `\t` presses *Tab*. A single letter
+key such as `q` is typed with `Type "q"`.
+
+| `Sleep <duration>`
+| Pauses the tape, so the viewer can read the screen.
+
+| `Wait /regex/`
+| Waits until a line on the screen matches the regular expression, up to 15 
seconds
+(`Wait@30s /orders/` waits longer). `Wait+Screen /regex/` matches the whole 
screen, with the
+lines separated by new lines, instead of each line. Use a `Wait` after a key 
that starts something
+slow, rather than guessing a `Sleep`.
+
+| `Screenshot`
+| Saves a screenshot of the screen, as the *F2* actions menu does.
+
+| `Source other.tape`
+| Plays the commands of another tape, relative to this one. Useful for a 
common opening.
+
+| `Caption "text"`
+| Shows a caption on the screen with a typewriter animation, which stays until 
a key is pressed.
+`Caption@4s "text"` closes it after 4 seconds, and then keys go to the TUI 
while it shows.
+Use `\n` to break lines.
+
+| `Highlight "text"`
+| Highlights the first place on the screen showing the text, such as a key in 
the footer or a
+node in the diagram. `Highlight@3s "text"` removes it after 3 seconds. Several 
highlights can be on
+the screen at the same time.
+
+| `ClearDrawing`
+| Removes the highlights.
+
+| `Tab "name"`
+| Switches to the tab, such as `Tab "Log"` or `Tab "Diagram"`, without 
pressing its number key.
+`Tab` alone, or with a count such as `Tab 2`, presses the *Tab* key.
+
+| `Integration "name"`
+| Selects the integration by its name or PID.
+
+| `Action "name"`
+| Runs an entry of the *F2* actions menu by its name, such as 
`show-keystrokes`, `reset-stats`
+or `next-theme`, or by its menu label.
+
+| `Theme "name"`
+| Switches the theme, such as `Theme "dracula"`.
+|===
+
+The `Set`, `Output` and `Require` commands of VHS are skipped: the recording 
takes its size and
+file from the `--record` options. `Hide` and `Show` are not supported, as 
everything the TUI
+shows is recorded.
+
+==== Writing a tape
+
+The tape below opens the Diagram tab of the `orders` integration, explains the 
screen with
+captions and a highlight, and asks the AI panel a question. Every step that 
depends on the TUI
+being ready waits for it on the screen first, so the tape plays the same on a 
slow machine:
+
+[source,text]
+----
+# wait for the integration to be discovered
+Wait@30s /orders/
+Caption@3s "The Camel TUI tape player"
+Sleep 3s
+
+Tab "Diagram"
+Wait /Topology/
+Sleep 1s
+Highlight@3s "orders"
+Caption@3s "Highlight finds text on the screen"
+Sleep 3s
+
+# the AI panel
+F8
+Wait /Ask a question/
+Type@80ms "hello"
+Sleep 1500ms
+F8
+Sleep 1s
+----
+
+Recorded with `camel tui orders --record=demo.tape --record-size=120x32` and 
converted with `agg`, it plays as:
+
+image::jbang/camel-tui-tape-player.gif[The example tape played: captions, a 
highlight on the Diagram tab, and a question typed in the AI panel]
+
+A few tips for a tape that reads well:
+
+* Start with a `Wait` for something the first screen shows, as the TUI needs a 
moment to find the
+  running integrations.
+* After each `Caption@<n>s`, `Sleep` for as long, or the next keys play while 
the caption is still
+  being typed.
+* Pair a `Highlight` with a `Caption` that says what the viewer looks at, and 
highlight a key
+  in the footer before pressing it, such as `Highlight@2s "Shift+F8"` followed 
by `Shift+F8`.
+* Use `Action "show-keystrokes"` at the start to show each key press at the 
bottom of the screen.
+* Record at the size the page shows, such as `--record-size=120x32`, so text 
is not scaled down.
+
+TIP: An AI agent can write and run the tape itself: the commands for the TUI 
are the same as the
+xref:camel-jbang-tui-ai-agents.adoc[MCP tools] of the TUI (`tui_show_caption`, 
`tui_locate` with
+`tui_draw_shape`, `tui_navigate`, `tui_action` and `tui_set_theme`), and 
`tui_get_screen` tells what
+text to `Wait` for. Run the tape, read the `Tape:` warnings, fix the tape, and 
run it again.
+
 === Converting to GIF
 
-Convert recordings using https://github.com/asciinema/agg[agg] (for `.cast` 
files) or
-https://github.com/charmbracelet/vhs[VHS] (for `.tape` files):
+Convert the `.cast` recording with https://github.com/asciinema/agg[agg]:
 
 [source,bash]
 ----
-agg recording.cast out.gif  # .cast -> .gif
-vhs demo.tape               # .tape -> .gif
+agg demo.cast demo.gif
+agg --speed 1.5 --font-size 14 --last-frame-duration 3 demo.cast demo.gif
 ----
+
+The `--speed` option plays the recording faster, and `--idle-time-limit` caps 
every pause at that many
+seconds (5 by default), so raise it when a pause is there for the viewer to 
read a caption.
+
+A tape that only uses VHS commands can also be turned into a GIF with
+https://github.com/charmbracelet/vhs[VHS] directly (`vhs demo.tape`), which 
runs the TUI in a
+terminal of its own. The commands for the TUI, such as `Caption`, `Highlight` 
and `Tab`, work
+with `--record` only.
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 a5ee7fda7e58..cc9122bcc532 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -182,7 +182,7 @@ See the 
xref:camel-jbang-tui-source-editor.adoc#_keyboard_shortcuts[keyboard sho
 |
 
 | `--record`
-| Replay a `.tape` file and record the session to an Asciinema `.cast` file.
+| Play a `.tape` file in the TUI and record it to an Asciinema `.cast` file. 
See xref:camel-jbang-tui-settings.adoc#_scripted_recording[Scripted Recording] 
for the tape commands.
 |
 
 | `--record-size`
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc
 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc
index 36fc29d9220e..a2942f5a0dab 100644
--- 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc
@@ -23,7 +23,7 @@ camel tui monitor [options]
 | Option | Description | Default | Type
 | `--mcp` | Enable embedded MCP server for AI agent access to the TUI |  | 
boolean
 | `--mcp-port` | MCP server port | 8123 | int
-| `--record` | Replay a .tape file inside the TUI and record to an Asciinema 
.cast file |  | String
+| `--record` | Play a .tape file in the TUI and record it to an Asciinema 
.cast file |  | String
 | `--record-duration` | Maximum duration in milliseconds captured by --record 
| 120000 | int
 | `--record-fps` | Frames per second captured by --record | 10 | int
 | `--record-size` | Size of the recorded terminal for --record, as 
<cols>x<rows> | 200x50 | String
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc
index b21f60019690..f716223e2286 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc
@@ -32,7 +32,7 @@ camel tui [options]
 | Option | Description | Default | Type
 | `--mcp` | Enable embedded MCP server for AI agent access to the TUI |  | 
boolean
 | `--mcp-port` | MCP server port | 8123 | int
-| `--record` | Replay a .tape file inside the TUI and record to an Asciinema 
.cast file |  | String
+| `--record` | Play a .tape file in the TUI and record it to an Asciinema 
.cast file |  | String
 | `--record-duration` | Maximum duration in milliseconds captured by --record 
| 120000 | int
 | `--record-fps` | Frames per second captured by --record | 10 | int
 | `--record-size` | Size of the recorded terminal for --record, as 
<cols>x<rows> | 200x50 | String
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
index 280ee0d05414..265a9523e427 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json
@@ -1,5 +1,5 @@
 {
   "commands": [
-    { "name": "tui", "fullName": "tui", "description": "Camel TUI", 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.tui.TuiCommand", 
"plugin": "tui", "options": [ { "names": "--mcp", "description": "Enable 
embedded MCP server for AI agent access to the TUI", "javaType": "boolean", 
"type": "boolean" }, { "names": "--mcp-port", "description": "MCP server port", 
"defaultValue": "8123", "javaType": "int", "type": "integer" }, { "names": 
"--record", "description": "Replay a .tape fil [...]
+    { "name": "tui", "fullName": "tui", "description": "Camel TUI", 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.tui.TuiCommand", 
"plugin": "tui", "options": [ { "names": "--mcp", "description": "Enable 
embedded MCP server for AI agent access to the TUI", "javaType": "boolean", 
"type": "boolean" }, { "names": "--mcp-port", "description": "MCP server port", 
"defaultValue": "8123", "javaType": "int", "type": "integer" }, { "names": 
"--record", "description": "Play a .tape file  [...]
   ]
 }
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 19d7b8f82d71..79c02ec1ffdc 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
@@ -29,6 +29,7 @@ import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Queue;
@@ -97,7 +98,6 @@ public class CamelMonitor extends CamelCommand {
      */
     static final List<String> RECORD_PROPERTIES = List.of(
             "tamboui.record",
-            "tamboui.record.config",
             "tamboui.record.width",
             "tamboui.record.height",
             "tamboui.record.duration",
@@ -118,7 +118,7 @@ public class CamelMonitor extends CamelCommand {
     long refreshInterval = DEFAULT_REFRESH_MS;
 
     @CommandLine.Option(names = { "--record" },
-                        description = "Replay a .tape file inside the TUI and 
record to an Asciinema .cast file",
+                        description = "Play a .tape file in the TUI and record 
it to an Asciinema .cast file",
                         arity = "0..1")
     String record;
 
@@ -270,7 +270,8 @@ public class CamelMonitor extends CamelCommand {
 
     /**
      * Hands the {@code --record*} options to TamboUI through the {@link 
#RECORD_PROPERTIES} system properties, which is
-     * the only way TamboUI accepts a recording configuration.
+     * the only way TamboUI accepts a recording configuration. TamboUI only 
records: the tape is played by
+     * {@link TapePlayer} (see {@link #startTapePlayer}), which knows the keys 
and commands of the TUI.
      */
     void configureRecording() {
         if (record == null) {
@@ -288,14 +289,58 @@ public class CamelMonitor extends CamelCommand {
         Path tapeFile = Path.of(record);
         Path castFile = Path.of(record.replaceAll("\\.tape$", "") + ".cast");
         int[] size = parseRecordSize(recordSize);
+        if (!Files.isRegularFile(tapeFile)) {
+            throw new CommandLine.ParameterException(
+                    new CommandLine(this), "Tape file not found: " + 
tapeFile.toAbsolutePath());
+        }
         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));
     }
 
+    /**
+     * Plays the {@code --record} tape on a background thread: its keys go 
through the same queue as the keys of an MCP
+     * client, its TUI commands call the MCP tools, and it quits the TUI at 
the end of the tape, which ends the
+     * recording.
+     */
+    private void startTapePlayer(TuiRunner tui) throws IOException {
+        TapePlayer player = new TapePlayer(warning -> 
System.err.println("Tape: " + warning));
+        List<TapePlayer.Step> steps = player.parse(Path.of(record));
+        TuiToolRegistry tools = new TuiToolRegistry(mcpFacade);
+        TapePlayer.Driver driver = new TapePlayer.Driver() {
+            @Override
+            public void key(KeyEvent event) {
+                pendingKeys.add(new McpFacade.PendingKey(event, 
System.currentTimeMillis()));
+            }
+
+            @Override
+            public String screen() {
+                return TapePlayer.screenText(recordingManager.getLastBuffer());
+            }
+
+            @Override
+            public String tool(String name, Map<String, Object> args) throws 
Exception {
+                return tools.execute(name, args);
+            }
+
+            @Override
+            public void quit() {
+                tui.quit();
+            }
+        };
+        Thread thread = new Thread(() -> {
+            try {
+                player.play(steps, driver);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+        }, "tui-tape-player");
+        thread.setDaemon(true);
+        thread.start();
+    }
+
     /**
      * Undoes {@link #configureRecording()} at the end of the session that ran 
it.
      * <p>
@@ -1148,6 +1193,9 @@ public class CamelMonitor extends CamelCommand {
                 Signal.handle(new Signal("INT"), sig -> tui.quit());
             }
             updateWindowTitle();
+            if (record != null) {
+                startTapePlayer(tui);
+            }
             tui.run(
                     this::handleEvent,
                     this::render);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java
new file mode 100644
index 000000000000..7612aa8c8014
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java
@@ -0,0 +1,480 @@
+/*
+ * 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 java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Plays a tape for {@code camel tui --record}: the TUI is driven by the tape 
while TamboUI records it to an asciinema
+ * {@code .cast} file.
+ * <p>
+ * A tape uses the <a href="https://github.com/charmbracelet/vhs";>VHS</a> 
syntax (keys, {@code Type}, {@code Sleep},
+ * {@code Wait /regex/}) plus commands for the TUI itself ({@code Caption}, 
{@code Highlight}, {@code Tab}, ...). The
+ * keys go through the same queue as the keys an MCP client sends, so every 
key works, function keys and modifiers
+ * included, and the TUI commands call the same tools an MCP client calls.
+ */
+final class TapePlayer {
+
+    /** What the player drives: the TUI, or a test double. */
+    interface Driver {
+        /** Presses a key. */
+        void key(KeyEvent event);
+
+        /** The screen as text, its rows separated by newlines; null before 
the first frame is rendered. */
+        String screen();
+
+        /** Calls a TUI tool (as an MCP client does) and returns its result. */
+        String tool(String name, Map<String, Object> args) throws Exception;
+
+        /** Ends the TUI, which ends the recording. */
+        void quit();
+    }
+
+    /** One step of a tape. */
+    sealed interface Step
+            permits KeyStep, SleepStep, WaitStep, ToolStep, HighlightStep {
+    }
+
+    record KeyStep(KeyEvent event) implements Step {
+    }
+
+    record SleepStep(long millis) implements Step {
+    }
+
+    /**
+     * Waits until a line of the screen (or the whole screen, its rows joined 
by newlines) matches the pattern.
+     */
+    record WaitStep(Pattern pattern, boolean wholeScreen, long timeoutMillis, 
String line) implements Step {
+    }
+
+    record ToolStep(String name, Map<String, Object> args, String line) 
implements Step {
+    }
+
+    /** Marks the first place on the screen showing the text, optionally for a 
number of seconds. */
+    record HighlightStep(String text, int seconds, String line) implements 
Step {
+    }
+
+    /** How long typing waits between characters, and keys between presses, 
unless the tape says otherwise. */
+    static final long DEFAULT_TYPING_MILLIS = 50;
+    /** How long a Wait waits for its text when the tape gives no timeout, as 
in VHS. */
+    static final long DEFAULT_WAIT_MILLIS = 15000;
+    /** How often the screen is checked while waiting. */
+    static final long WAIT_POLL_MILLIS = 100;
+    /** How long the first frame may take to render before the tape starts 
anyway. */
+    static final long FIRST_FRAME_MILLIS = 10000;
+    /** How long the last frame stays before the TUI quits, so the recording 
shows it. */
+    static final long END_PAUSE_MILLIS = 1000;
+
+    private final Consumer<String> warnings;
+
+    TapePlayer(Consumer<String> warnings) {
+        this.warnings = warnings;
+    }
+
+    // ---- Parsing ----
+
+    /** Parses a tape file; {@code Source} includes other tapes relative to 
it. */
+    List<Step> parse(Path tape) throws IOException {
+        List<Step> steps = new ArrayList<>();
+        Path file = tape.toAbsolutePath().normalize();
+        Deque<Path> including = new ArrayDeque<>();
+        including.push(file);
+        parse(Files.readAllLines(file), file.getParent(), including, steps);
+        return steps;
+    }
+
+    /** Parses the lines of a tape into steps; unknown lines are reported and 
skipped. */
+    List<Step> parse(List<String> lines) {
+        List<Step> steps = new ArrayList<>();
+        parse(lines, null, new ArrayDeque<>(), steps);
+        return steps;
+    }
+
+    /** The tapes being parsed are in {@code including}, so a tape that 
sources itself (in a loop) is reported. */
+    private void parse(List<String> lines, Path dir, Deque<Path> including, 
List<Step> steps) {
+        for (String raw : lines) {
+            String line = raw.strip();
+            if (line.isEmpty() || line.startsWith("#")) {
+                continue;
+            }
+            try {
+                parseLine(line, dir, including, steps);
+            } catch (IllegalArgumentException e) {
+                warnings.accept(e.getMessage() + ": " + line);
+            }
+        }
+    }
+
+    private void parseLine(String line, Path dir, Deque<Path> including, 
List<Step> steps) {
+        // The command is the first word, optionally with a timing: 
Command@duration ("Down@200ms 3", "Type@100ms
+        // "text"", "Wait@10s /Ready/", "Caption@5s "text""). Only the first 
word is checked for '@', so text such as
+        // Type "[email protected]" is typed as it is.
+        String[] parts = line.split("\\s+", 2);
+        String head = parts[0];
+        String args = parts.length > 1 ? parts[1].strip() : "";
+        long timing = -1;
+        int at = head.indexOf('@');
+        if (at > 0) {
+            timing = parseDuration(head.substring(at + 1));
+            head = head.substring(0, at);
+        }
+        String command = head.toLowerCase(Locale.ROOT);
+        // Tab alone or with a count (Tab 2) is the key, as in VHS; Tab "name" 
switches to the tab
+        if ("tab".equals(command) && (args.isEmpty() || 
args.chars().allMatch(Character::isDigit))) {
+            command = "";
+        }
+
+        switch (command) {
+            // directives for the vhs tool (terminal settings, output file, 
required programs): the recording takes
+            // its size and file from the --record options
+            case "set", "output", "require" -> {
+            }
+            case "source" -> {
+                Path file = (dir != null ? dir.resolve(args) : 
Path.of(args)).toAbsolutePath().normalize();
+                if (including.contains(file)) {
+                    throw new IllegalArgumentException("The tape includes 
itself");
+                }
+                List<String> included;
+                try {
+                    included = Files.readAllLines(file);
+                } catch (IOException e) {
+                    throw new IllegalArgumentException("Cannot read the tape 
to include (" + e.getMessage() + ")");
+                }
+                including.push(file);
+                parse(included, file.getParent(), including, steps);
+                including.pop();
+            }
+            case "hide", "show" -> throw new IllegalArgumentException(
+                    "Hide and Show are not supported, the interactions are 
recorded");
+            case "sleep" -> steps.add(new SleepStep(parseDuration(args)));
+            case "type" -> addTyped(unquote(args), timing >= 0 ? timing : 
DEFAULT_TYPING_MILLIS, steps);
+            case "wait", "wait+line", "wait+screen" -> steps.add(new WaitStep(
+                    parseRegex(args), "wait+screen".equals(command), timing > 
0 ? timing : DEFAULT_WAIT_MILLIS, line));
+            case "screenshot" -> steps.add(new ToolStep("tui_action", 
Map.of("action", "screenshot"), line));
+            // the commands for the TUI itself
+            case "caption" -> {
+                Map<String, Object> toolArgs = new LinkedHashMap<>();
+                toolArgs.put("text", unquote(args).replace("\\n", "\n"));
+                if (timing > 0) {
+                    toolArgs.put("duration", (int) Math.max(1, timing / 1000));
+                }
+                steps.add(new ToolStep("tui_show_caption", toolArgs, line));
+            }
+            case "highlight" -> steps.add(new HighlightStep(unquote(args), 
timing > 0 ? (int) (timing / 1000) : 0, line));
+            case "cleardrawing" -> steps.add(new ToolStep("tui_draw_clear", 
Map.of(), line));
+            case "tab" -> steps.add(new ToolStep("tui_navigate", Map.of("tab", 
unquote(args)), line));
+            case "integration" -> steps.add(new ToolStep("tui_navigate", 
Map.of("integration", unquote(args)), line));
+            case "action" -> steps.add(new ToolStep("tui_action", 
Map.of("action", unquote(args)), line));
+            case "theme" -> steps.add(new ToolStep("tui_set_theme", 
Map.of("theme", unquote(args)), line));
+            default -> {
+                KeyEvent key = parseKey(head);
+                if (key == null) {
+                    throw new IllegalArgumentException("Unknown tape command");
+                }
+                int count = args.isEmpty() ? 1 : parseCount(args);
+                long gap = timing >= 0 ? timing : DEFAULT_TYPING_MILLIS;
+                for (int i = 0; i < count; i++) {
+                    steps.add(new KeyStep(key));
+                    steps.add(new SleepStep(gap));
+                }
+            }
+        }
+    }
+
+    private static void addTyped(String text, long gap, List<Step> steps) {
+        for (int i = 0; i < text.length();) {
+            int cp = text.codePointAt(i);
+            i += Character.charCount(cp);
+            KeyEvent key = switch (cp) {
+                case '\n', '\r' -> KeyEvent.ofKey(KeyCode.ENTER);
+                case '\t' -> KeyEvent.ofKey(KeyCode.TAB);
+                default -> KeyEvent.ofChar(cp);
+            };
+            steps.add(new KeyStep(key));
+            steps.add(new SleepStep(gap));
+        }
+    }
+
+    /**
+     * A key name with optional modifiers ({@code Enter}, {@code F8}, {@code 
Shift+F8}, {@code Ctrl+c}, {@code Alt+x},
+     * {@code Shift+Tab}); null when it is not a key.
+     */
+    static KeyEvent parseKey(String spec) {
+        boolean ctrl = false;
+        boolean alt = false;
+        boolean shift = false;
+        String key = spec;
+        int plus;
+        // a lone "+" is a key of its own, not a modifier separator
+        while ((plus = key.indexOf('+')) > 0 && plus < key.length() - 1) {
+            switch (key.substring(0, plus).toLowerCase(Locale.ROOT)) {
+                case "ctrl", "control" -> ctrl = true;
+                case "alt", "meta" -> alt = true;
+                case "shift" -> shift = true;
+                default -> {
+                    return null;
+                }
+            }
+            key = key.substring(plus + 1);
+        }
+        boolean modified = ctrl || alt || shift;
+        KeyModifiers modifiers = KeyModifiers.of(ctrl, alt, shift);
+        KeyCode code = switch (key.toLowerCase(Locale.ROOT)) {
+            case "enter", "return" -> KeyCode.ENTER;
+            case "tab" -> KeyCode.TAB;
+            case "backspace" -> KeyCode.BACKSPACE;
+            case "delete" -> KeyCode.DELETE;
+            case "insert" -> KeyCode.INSERT;
+            case "escape", "esc" -> KeyCode.ESCAPE;
+            case "up" -> KeyCode.UP;
+            case "down" -> KeyCode.DOWN;
+            case "left" -> KeyCode.LEFT;
+            case "right" -> KeyCode.RIGHT;
+            case "home" -> KeyCode.HOME;
+            case "end" -> KeyCode.END;
+            case "pageup", "pgup" -> KeyCode.PAGE_UP;
+            case "pagedown", "pgdn" -> KeyCode.PAGE_DOWN;
+            default -> functionKey(key);
+        };
+        if (code != null) {
+            return KeyEvent.ofKey(code, modifiers);
+        }
+        if ("space".equalsIgnoreCase(key)) {
+            return KeyEvent.ofChar(' ', modifiers);
+        }
+        // a single character is a key only with a modifier (Ctrl+t); a bare 
letter on its own line is a typo
+        if (modified && key.codePointCount(0, key.length()) == 1) {
+            int cp = key.codePointAt(0);
+            return KeyEvent.ofChar(shift && Character.isLetter(cp) ? 
Character.toUpperCase(cp) : cp, modifiers);
+        }
+        return null;
+    }
+
+    private static KeyCode functionKey(String key) {
+        if (key.length() >= 2 && key.length() <= 3 && (key.charAt(0) == 'F' || 
key.charAt(0) == 'f')) {
+            try {
+                int n = Integer.parseInt(key.substring(1));
+                if (n >= 1 && n <= 12) {
+                    return KeyCode.valueOf("F" + n);
+                }
+            } catch (NumberFormatException e) {
+                return null;
+            }
+        }
+        return null;
+    }
+
+    /** A duration such as {@code 500ms}, {@code 2s} or {@code 1.5s}; a bare 
number is seconds, as in VHS. */
+    static long parseDuration(String text) {
+        String s = text.strip().toLowerCase(Locale.ROOT);
+        try {
+            if (s.endsWith("ms")) {
+                return Long.parseLong(s.substring(0, s.length() - 2).strip());
+            }
+            if (s.endsWith("s")) {
+                return Math.round(Double.parseDouble(s.substring(0, s.length() 
- 1).strip()) * 1000);
+            }
+            return Math.round(Double.parseDouble(s) * 1000);
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException("Invalid duration '" + text + 
"'");
+        }
+    }
+
+    private static int parseCount(String text) {
+        try {
+            return Math.max(1, Integer.parseInt(text.strip()));
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException("Invalid repeat count '" + text 
+ "'");
+        }
+    }
+
+    private static Pattern parseRegex(String text) {
+        if (text.length() < 2 || !text.startsWith("/") || !text.endsWith("/")) 
{
+            throw new IllegalArgumentException("Wait needs a /regex/ to wait 
for");
+        }
+        try {
+            return Pattern.compile(text.substring(1, text.length() - 1));
+        } catch (PatternSyntaxException e) {
+            throw new IllegalArgumentException("Invalid regex (" + 
e.getDescription() + ")");
+        }
+    }
+
+    /** The text of a quoted argument ("..." with \" \\ \n \t escapes), or the 
argument as it is when unquoted. */
+    static String unquote(String text) {
+        String s = text.strip();
+        if (s.length() < 2 || s.charAt(0) != '"' || s.charAt(s.length() - 1) 
!= '"') {
+            return s;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (int i = 1; i < s.length() - 1; i++) {
+            char c = s.charAt(i);
+            if (c == '\\' && i + 1 < s.length() - 1) {
+                char next = s.charAt(++i);
+                switch (next) {
+                    case 'n' -> sb.append('\n');
+                    case 't' -> sb.append('\t');
+                    case 'r' -> sb.append('\r');
+                    default -> sb.append(next);
+                }
+            } else {
+                sb.append(c);
+            }
+        }
+        return sb.toString();
+    }
+
+    // ---- Playing ----
+
+    /** Plays the steps on the calling thread, then quits the TUI. */
+    void play(List<Step> steps, Driver driver) throws InterruptedException {
+        long start = System.nanoTime();
+        while (driver.screen() == null && System.nanoTime() - start < 
TimeUnit.MILLISECONDS.toNanos(FIRST_FRAME_MILLIS)) {
+            Thread.sleep(WAIT_POLL_MILLIS);
+        }
+        for (Step step : steps) {
+            if (step instanceof KeyStep k) {
+                driver.key(k.event());
+            } else if (step instanceof SleepStep s) {
+                Thread.sleep(s.millis());
+            } else if (step instanceof WaitStep w) {
+                waitFor(w, driver);
+            } else if (step instanceof ToolStep t) {
+                callTool(t.name(), t.args(), t.line(), driver);
+            } else if (step instanceof HighlightStep h) {
+                highlight(h, driver);
+            }
+        }
+        Thread.sleep(END_PAUSE_MILLIS);
+        driver.quit();
+    }
+
+    private void waitFor(WaitStep wait, Driver driver) throws 
InterruptedException {
+        long deadline = System.nanoTime() + 
TimeUnit.MILLISECONDS.toNanos(wait.timeoutMillis());
+        while (!matches(wait, driver.screen())) {
+            if (System.nanoTime() - deadline >= 0) {
+                warnings.accept("Timed out after " + wait.timeoutMillis() + 
"ms: " + wait.line());
+                return;
+            }
+            Thread.sleep(WAIT_POLL_MILLIS);
+        }
+    }
+
+    static boolean matches(WaitStep wait, String screen) {
+        if (screen == null) {
+            return false;
+        }
+        if (wait.wholeScreen()) {
+            return wait.pattern().matcher(screen).find();
+        }
+        for (String row : screen.split("\n", -1)) {
+            if (wait.pattern().matcher(row).find()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String callTool(String name, Map<String, Object> args, String 
line, Driver driver) {
+        try {
+            String result = driver.tool(name, args);
+            if (result != null && result.startsWith("Error")) {
+                warnings.accept(result + ": " + line);
+            }
+            return result;
+        } catch (Exception e) {
+            warnings.accept("Failed (" + e.getMessage() + "): " + line);
+            return null;
+        }
+    }
+
+    private void highlight(HighlightStep step, Driver driver) {
+        String located = callTool("tui_locate", Map.of("text", step.text()), 
step.line(), driver);
+        JsonObject match = firstMatch(located);
+        if (match == null) {
+            warnings.accept("Text not on the screen: " + step.line());
+            return;
+        }
+        Map<String, Object> args = new LinkedHashMap<>();
+        args.put("shape", "highlight");
+        args.put("x", match.getInteger("x"));
+        args.put("y", match.getInteger("y"));
+        args.put("width", match.getInteger("width"));
+        args.put("append", true);
+        if (step.seconds() > 0) {
+            args.put("duration", step.seconds());
+        }
+        callTool("tui_draw_shape", args, step.line(), driver);
+    }
+
+    private static JsonObject firstMatch(String located) {
+        if (located == null) {
+            return null;
+        }
+        try {
+            Object parsed = Jsoner.deserialize(located);
+            if (parsed instanceof JsonObject result && result.get("matches") 
instanceof JsonArray matches
+                    && !matches.isEmpty() && matches.get(0) instanceof 
JsonObject first) {
+                return first;
+            }
+        } catch (Exception e) {
+            // not a locate result
+        }
+        return null;
+    }
+
+    /** The buffer as text, its rows separated by newlines (continuation cells 
of wide characters left out). */
+    static String screenText(Buffer buffer) {
+        if (buffer == null) {
+            return null;
+        }
+        Rect area = buffer.area();
+        StringBuilder sb = new StringBuilder();
+        for (int y = area.top(); y < area.bottom(); y++) {
+            if (y > area.top()) {
+                sb.append('\n');
+            }
+            for (int x = area.left(); x < area.right(); x++) {
+                if (!buffer.get(x, y).isContinuation()) {
+                    sb.append(buffer.get(x, y).symbol());
+                }
+            }
+        }
+        return sb.toString();
+    }
+}
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 fdbc5c91e531..a022f5138711 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
@@ -55,7 +55,7 @@ public class TuiCommand extends CamelCommand {
     long refreshInterval = 100;
 
     @CommandLine.Option(names = { "--record" },
-                        description = "Replay a .tape file inside the TUI and 
record to an Asciinema .cast file",
+                        description = "Play a .tape file in the TUI and record 
it to an Asciinema .cast file",
                         arity = "0..1")
     String record;
 
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
index 9b47a877c040..bd6400565db2 100644
--- 
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
@@ -16,8 +16,13 @@
  */
 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.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import org.junitpioneer.jupiter.ClearSystemProperty;
 import picocli.CommandLine;
 
@@ -43,6 +48,9 @@ class CamelMonitorRecordOptionsTest {
 
     private final CamelMonitor monitor = new CamelMonitor(new 
CamelJBangMain(), getClass().getClassLoader());
 
+    @TempDir
+    Path tempDir;
+
     @Test
     void parsesColumnsAndRows() {
         assertThat(monitor.parseRecordSize("160x44")).containsExactly(160, 44);
@@ -93,10 +101,10 @@ class CamelMonitorRecordOptionsTest {
     }
 
     @Test
-    void configuringRecordingSetsEveryPropertyThatIsClearedAgainAfterwards() {
+    void configuringRecordingSetsEveryPropertyThatIsClearedAgainAfterwards() 
throws IOException {
         // 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.record = tape().toString();
         monitor.recordSize = "160x44";
 
         monitor.configureRecording();
@@ -119,4 +127,30 @@ class CamelMonitorRecordOptionsTest {
         assertThat(CamelMonitor.RECORD_PROPERTIES)
                 .allSatisfy(key -> 
assertThat(System.getProperty(key)).as(key).isNull());
     }
+
+    @Test
+    void doesNotHandTheTapeToTamboui() throws IOException {
+        // TamboUI only records; TapePlayer plays the tape (TamboUI's own 
player has no function keys)
+        monitor.record = tape().toString();
+
+        monitor.configureRecording();
+
+        assertThat(System.getProperty("tamboui.record")).endsWith("demo.cast");
+        assertThat(System.getProperty("tamboui.record.config")).isNull();
+    }
+
+    @Test
+    void rejectsAMissingTapeFile() {
+        monitor.record = tempDir.resolve("missing.tape").toString();
+
+        assertThatThrownBy(monitor::configureRecording)
+                .isInstanceOf(CommandLine.ParameterException.class)
+                .hasMessageContaining("Tape file not found");
+    }
+
+    private Path tape() throws IOException {
+        Path tape = tempDir.resolve("demo.tape");
+        Files.writeString(tape, "Sleep 100ms\n");
+        return tape;
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayerTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayerTest.java
new file mode 100644
index 000000000000..f428afd75e09
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayerTest.java
@@ -0,0 +1,256 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Pattern;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TapePlayerTest {
+
+    private final List<String> warnings = new ArrayList<>();
+    private final TapePlayer player = new TapePlayer(warnings::add);
+
+    @Test
+    void parsesKeysWithModifiers() {
+        assertThat(TapePlayer.parseKey("F8").code()).isEqualTo(KeyCode.F8);
+        KeyEvent shiftF8 = TapePlayer.parseKey("Shift+F8");
+        assertThat(shiftF8.code()).isEqualTo(KeyCode.F8);
+        assertThat(shiftF8.hasShift()).isTrue();
+        KeyEvent ctrlT = TapePlayer.parseKey("Ctrl+t");
+        assertThat(ctrlT.isChar('t')).isTrue();
+        assertThat(ctrlT.hasCtrl()).isTrue();
+        assertThat(TapePlayer.parseKey("Alt+x").hasAlt()).isTrue();
+        
assertThat(TapePlayer.parseKey("Shift+Tab").code()).isEqualTo(KeyCode.TAB);
+        
assertThat(TapePlayer.parseKey("Escape").code()).isEqualTo(KeyCode.ESCAPE);
+        
assertThat(TapePlayer.parseKey("PageDown").code()).isEqualTo(KeyCode.PAGE_DOWN);
+    }
+
+    @Test
+    void doesNotTakeABareLetterOrAnUnknownNameForAKey() {
+        assertThat(TapePlayer.parseKey("q")).isNull();
+        assertThat(TapePlayer.parseKey("F13")).isNull();
+        assertThat(TapePlayer.parseKey("Hyper+x")).isNull();
+    }
+
+    @Test
+    void repeatsAKeyWithACount() {
+        List<TapePlayer.Step> steps = player.parse(List.of("Down 3", "Tab", 
"Tab 2"));
+
+        assertThat(keys(steps)).extracting(KeyEvent::code)
+                .containsExactly(KeyCode.DOWN, KeyCode.DOWN, KeyCode.DOWN, 
KeyCode.TAB, KeyCode.TAB, KeyCode.TAB);
+        assertThat(warnings).isEmpty();
+    }
+
+    @Test
+    void typesTextWithItsSpeedAndKeepsAtSigns() {
+        List<TapePlayer.Step> steps = player.parse(List.of("Type@100ms 
\"a@b\""));
+
+        
assertThat(keys(steps)).extracting(KeyEvent::string).containsExactly("a", "@", 
"b");
+        assertThat(steps).filteredOn(s -> s instanceof TapePlayer.SleepStep)
+                .extracting(s -> ((TapePlayer.SleepStep) s).millis())
+                .containsOnly(100L);
+    }
+
+    @Test
+    void parsesDurationsAsInVhs() {
+        assertThat(TapePlayer.parseDuration("500ms")).isEqualTo(500);
+        assertThat(TapePlayer.parseDuration("2s")).isEqualTo(2000);
+        assertThat(TapePlayer.parseDuration("1.5s")).isEqualTo(1500);
+        assertThat(TapePlayer.parseDuration("3")).isEqualTo(3000);
+    }
+
+    @Test
+    void parsesWaitWithItsTimeout() {
+        TapePlayer.WaitStep wait = (TapePlayer.WaitStep) 
player.parse(List.of("Wait+Screen@2s /Ready/")).get(0);
+
+        assertThat(wait.pattern().pattern()).isEqualTo("Ready");
+        assertThat(wait.wholeScreen()).isTrue();
+        assertThat(wait.timeoutMillis()).isEqualTo(2000);
+    }
+
+    @Test
+    void parsesTheTuiCommands() {
+        List<TapePlayer.Step> steps = player.parse(List.of(
+                "Caption@5s \"Fix with AI\\nShift+F8\"",
+                "Tab \"Errors\"",
+                "Integration orders",
+                "Action \"show-keystrokes\"",
+                "Theme \"dracula\"",
+                "ClearDrawing"));
+
+        assertThat(steps).hasSize(6).allMatch(s -> s instanceof 
TapePlayer.ToolStep);
+        TapePlayer.ToolStep caption = (TapePlayer.ToolStep) steps.get(0);
+        assertThat(caption.name()).isEqualTo("tui_show_caption");
+        assertThat(caption.args()).containsEntry("text", "Fix with 
AI\nShift+F8").containsEntry("duration", 5);
+        assertThat(((TapePlayer.ToolStep) 
steps.get(1)).args()).containsEntry("tab", "Errors");
+        assertThat(((TapePlayer.ToolStep) 
steps.get(2)).args()).containsEntry("integration", "orders");
+        assertThat(((TapePlayer.ToolStep) 
steps.get(3)).name()).isEqualTo("tui_action");
+        assertThat(((TapePlayer.ToolStep) 
steps.get(4)).name()).isEqualTo("tui_set_theme");
+        assertThat(((TapePlayer.ToolStep) 
steps.get(5)).name()).isEqualTo("tui_draw_clear");
+        assertThat(warnings).isEmpty();
+    }
+
+    @Test
+    void reportsAndSkipsWhatItDoesNotKnow() {
+        List<TapePlayer.Step> steps = player.parse(List.of(
+                "Set FontSize 32", "Output demo.gif", "Frobnicate", "q", 
"Hide", "Wait /[unclosed/", "Enter"));
+
+        
assertThat(keys(steps)).extracting(KeyEvent::code).containsExactly(KeyCode.ENTER);
+        assertThat(warnings).hasSize(4);
+        assertThat(warnings.get(0)).contains("Unknown tape command: 
Frobnicate");
+        assertThat(warnings.get(1)).contains("Unknown tape command: q");
+        assertThat(warnings.get(2)).contains("Hide and Show are not 
supported");
+        assertThat(warnings.get(3)).contains("Invalid regex");
+    }
+
+    @Test
+    void includesAnotherTapeAndReportsATapeThatIncludesItself(@TempDir Path 
dir) throws IOException {
+        Files.writeString(dir.resolve("main.tape"), "Source 
common.tape\nSource common.tape\nSource loop.tape\nEnter\n");
+        Files.writeString(dir.resolve("common.tape"), "Escape\n");
+        Files.writeString(dir.resolve("loop.tape"), "Tab\nSource ../" + 
dir.getFileName() + "/main.tape\n");
+
+        List<TapePlayer.Step> steps = player.parse(dir.resolve("main.tape"));
+
+        assertThat(keys(steps)).extracting(KeyEvent::code)
+                .containsExactly(KeyCode.ESCAPE, KeyCode.ESCAPE, KeyCode.TAB, 
KeyCode.ENTER);
+        assertThat(warnings).singleElement().asString().startsWith("The tape 
includes itself");
+    }
+
+    @Test
+    void waitMatchesALineOrTheWholeScreen() {
+        String screen = "Loading\nReady  ";
+        TapePlayer.WaitStep line = new 
TapePlayer.WaitStep(Pattern.compile("Loading\\s*\\nReady"), false, 0, "");
+        TapePlayer.WaitStep whole = new 
TapePlayer.WaitStep(Pattern.compile("Loading\\s*\\nReady"), true, 0, "");
+
+        assertThat(TapePlayer.matches(line, screen)).isFalse();
+        assertThat(TapePlayer.matches(whole, screen)).isTrue();
+        assertThat(TapePlayer.matches(whole, null)).isFalse();
+    }
+
+    @Test
+    void playsKeysWaitsForTheScreenHighlightsAndQuits() throws Exception {
+        AtomicReference<String> screen = new AtomicReference<>("Loading");
+        List<KeyEvent> pressed = new ArrayList<>();
+        List<String> tools = new ArrayList<>();
+        AtomicBoolean quit = new AtomicBoolean();
+        TapePlayer.Driver driver = new TapePlayer.Driver() {
+            @Override
+            public void key(KeyEvent event) {
+                pressed.add(event);
+                if (event.code() == KeyCode.F5) {
+                    screen.set("Errors\n Shift+F8  fix with AI");
+                }
+            }
+
+            @Override
+            public String screen() {
+                return screen.get();
+            }
+
+            @Override
+            public String tool(String name, Map<String, Object> args) {
+                tools.add(name + " " + args);
+                if ("tui_locate".equals(name)) {
+                    return 
"{\"matches\":[{\"x\":1,\"y\":1,\"width\":8,\"height\":1,\"text\":\"Shift+F8\"}]}";
+                }
+                return "ok";
+            }
+
+            @Override
+            public void quit() {
+                quit.set(true);
+            }
+        };
+
+        player.play(player.parse(List.of(
+                "F5",
+                "Wait@2s /fix with AI/",
+                "Highlight \"Shift+F8\"",
+                "Shift+F8")), driver);
+
+        
assertThat(pressed).extracting(KeyEvent::code).containsExactly(KeyCode.F5, 
KeyCode.F8);
+        assertThat(pressed.get(1).hasShift()).isTrue();
+        assertThat(tools).hasSize(2);
+        assertThat(tools.get(0)).startsWith("tui_locate");
+        
assertThat(tools.get(1)).startsWith("tui_draw_shape").contains("x=1").contains("y=1").contains("width=8");
+        assertThat(quit).isTrue();
+        assertThat(warnings).isEmpty();
+    }
+
+    @Test
+    void reportsAWaitThatTimesOut() throws Exception {
+        TapePlayer.Driver driver = new TapePlayer.Driver() {
+            @Override
+            public void key(KeyEvent event) {
+            }
+
+            @Override
+            public String screen() {
+                return "Loading";
+            }
+
+            @Override
+            public String tool(String name, Map<String, Object> args) {
+                return "ok";
+            }
+
+            @Override
+            public void quit() {
+            }
+        };
+
+        player.play(player.parse(List.of("Wait@200ms /Ready/")), driver);
+
+        assertThat(warnings).singleElement().asString().startsWith("Timed out 
after 200ms");
+    }
+
+    @Test
+    void screenTextLeavesOutTheContinuationOfWideCharacters() {
+        Buffer buffer = Buffer.empty(new Rect(0, 0, 4, 2));
+        buffer.setString(0, 0, "世a", Style.EMPTY);
+        buffer.setString(0, 1, "ok", Style.EMPTY);
+
+        assertThat(TapePlayer.screenText(buffer)).isEqualTo("世a \nok  ");
+    }
+
+    private static List<KeyEvent> keys(List<TapePlayer.Step> steps) {
+        List<KeyEvent> keys = new ArrayList<>();
+        for (TapePlayer.Step step : steps) {
+            if (step instanceof TapePlayer.KeyStep k) {
+                keys.add(k.event());
+            }
+        }
+        return keys;
+    }
+}

Reply via email to