hexastack-cli 0.10.0__tar.gz → 0.10.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/PKG-INFO +5 -4
  2. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/README.md +3 -2
  3. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/pyproject.toml +2 -2
  4. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/pyproject.toml.orig +2 -2
  5. hexastack_cli-0.10.0/src/hexastack_cli/testing/__init__.py +0 -9
  6. hexastack_cli-0.10.0/src/hexastack_cli/testing/events.py +0 -16
  7. hexastack_cli-0.10.0/src/hexastack_cli/testing/narrator.py +0 -323
  8. hexastack_cli-0.10.0/src/hexastack_cli/testing/terminal.py +0 -329
  9. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/__init__.py +0 -0
  10. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/__init__.py +0 -0
  11. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/presenter.py +0 -0
  12. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/routing.py +0 -0
  13. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/adapters/suggest.py +0 -0
  14. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/domain/__init__.py +0 -0
  15. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/domain/config.py +0 -0
  16. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/domain/options.py +0 -0
  17. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/__init__.py +0 -0
  18. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/app.py +0 -0
  19. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/autodiscovery.py +0 -0
  20. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/bootstrap.py +0 -0
  21. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/config.py +0 -0
  22. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/decorators.py +0 -0
  23. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/infra/options.py +0 -0
  24. {hexastack_cli-0.10.0 → hexastack_cli-0.10.1}/src/hexastack_cli/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexastack-cli
3
- Version: 0.10.0
3
+ Version: 0.10.1
4
4
  Summary: Hexastack CLI presentation adapter with Typer and Rich
5
5
  Author: Richard West
6
6
  Author-email: Richard West <dopplereffect.us@gmail.com>
@@ -11,7 +11,7 @@ Requires-Dist: questionary>=2.1.0
11
11
  Requires-Dist: rich>=13.8.0
12
12
  Requires-Dist: typer>=0.27.1
13
13
  Requires-Dist: playwright>=1.49.0 ; extra == 'testing'
14
- Requires-Python: >=3.13
14
+ Requires-Python: >=3.12
15
15
  Provides-Extra: testing
16
16
  Description-Content-Type: text/markdown
17
17
 
@@ -51,7 +51,6 @@ Description-Content-Type: text/markdown
51
51
  hexastack_cli/
52
52
  ├── domain/ # CliContext, OutputFormat enum
53
53
  ├── adapters/ # Rich presenters, Typer command runners, TyperSuggest
54
- ├── testing/ # CliNarrator, TerminalEvent (demo video & VTT recorder)
55
54
  └── infra/ # create_cli_app, CliBootstrapper (order=40), @cli_command, @cli_query, @cli_group, @feature_flag_command
56
55
  ```
57
56
 
@@ -189,8 +188,10 @@ In automated CI environments or redirected streams (`not sys.stdin.isatty()`), `
189
188
 
190
189
  ## 7. Automated Demo Recording & Video Slicing (`CliNarrator`)
191
190
 
191
+ > Automated narrative terminal recording and Playwright demo video rendering is provided by [`hexaqual.adapters.testing`](https://github.com/TheTrueSCU/hexaqual).
192
+
192
193
  ```python
193
- from hexastack_cli.testing import CliNarrator
194
+ from hexaqual.adapters.testing import CliNarrator
194
195
 
195
196
  narrator = CliNarrator(record_video=True)
196
197
  narrator.say("Scaffolding microservice...")
@@ -34,7 +34,6 @@
34
34
  hexastack_cli/
35
35
  ├── domain/ # CliContext, OutputFormat enum
36
36
  ├── adapters/ # Rich presenters, Typer command runners, TyperSuggest
37
- ├── testing/ # CliNarrator, TerminalEvent (demo video & VTT recorder)
38
37
  └── infra/ # create_cli_app, CliBootstrapper (order=40), @cli_command, @cli_query, @cli_group, @feature_flag_command
39
38
  ```
40
39
 
@@ -172,8 +171,10 @@ In automated CI environments or redirected streams (`not sys.stdin.isatty()`), `
172
171
 
173
172
  ## 7. Automated Demo Recording & Video Slicing (`CliNarrator`)
174
173
 
174
+ > Automated narrative terminal recording and Playwright demo video rendering is provided by [`hexaqual.adapters.testing`](https://github.com/TheTrueSCU/hexaqual).
175
+
175
176
  ```python
176
- from hexastack_cli.testing import CliNarrator
177
+ from hexaqual.adapters.testing import CliNarrator
177
178
 
178
179
  narrator = CliNarrator(record_video=True)
179
180
  narrator.say("Scaffolding microservice...")
@@ -1,10 +1,10 @@
1
1
  [project]
2
2
  name = "hexastack-cli"
3
- version = "0.10.0"
3
+ version = "0.10.1"
4
4
  description = "Hexastack CLI presentation adapter with Typer and Rich"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
7
- requires-python = ">=3.13"
7
+ requires-python = ">=3.12"
8
8
  dependencies = [
9
9
  "hexastack-core",
10
10
  "hexastack-cqrs",
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "hexastack-cli"
3
- version = "0.10.0"
3
+ version = "0.10.1"
4
4
  description = "Hexastack CLI presentation adapter with Typer and Rich"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
7
7
  authors = [
8
8
  { name = "Richard West", email = "dopplereffect.us@gmail.com" }
9
9
  ]
10
- requires-python = ">=3.13"
10
+ requires-python = ">=3.12"
11
11
  dependencies = [
12
12
  "hexastack-core",
13
13
  "hexastack-cqrs",
@@ -1,9 +0,0 @@
1
- from hexastack_cli.testing.events import TerminalEvent
2
- from hexastack_cli.testing.narrator import CliNarrator
3
- from hexastack_cli.testing.terminal import render_cli_demo_video
4
-
5
- __all__ = [
6
- "CliNarrator",
7
- "render_cli_demo_video",
8
- "TerminalEvent",
9
- ]
@@ -1,16 +0,0 @@
1
- """Domain models and data contracts for CLI testing and narrative demonstration."""
2
-
3
- from dataclasses import dataclass
4
-
5
- __all__ = [
6
- "TerminalEvent",
7
- ]
8
-
9
-
10
- @dataclass(frozen=True, slots=True)
11
- class TerminalEvent:
12
- """A discrete timestamped event within a CLI demo recording session."""
13
-
14
- event_type: str # "step" | "input" | "output"
15
- payload: str
16
- timestamp: float
@@ -1,323 +0,0 @@
1
- """Feature demo narrator and Playwright terminal video recorder for CLI commands."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import os
7
- import time
8
- from concurrent.futures import ThreadPoolExecutor
9
- from pathlib import Path
10
- from typing import Any
11
-
12
- from typer.testing import CliRunner, Result
13
-
14
- from hexastack_cli.testing.events import TerminalEvent
15
- from hexastack_core.utils.fs import atomic_write_text
16
-
17
-
18
- class CliNarrator:
19
- """Orchestrates structured narrative CLI execution, step recording, and video rendering.
20
-
21
- Notes/Architectural Intent:
22
- 1. In standard CI/unit test mode, runs instantly with zero delay via in-memory CliRunner.
23
- 2. In demo recording mode (`RECORD_DEMO=1`), captures keystrokes, output tokens, and narrative steps,
24
- and directly renders a fixed-window, auto-scrolling .webm video via Playwright.
25
- """
26
-
27
- def __init__(
28
- self,
29
- app: Any,
30
- output_name: str | None = None,
31
- output_dir: Path | None = None,
32
- width: int = 100,
33
- height: int = 24,
34
- bin_name: str | None = None,
35
- ) -> None:
36
- """Initialize CLI narrator attached to a Typer or Click application.
37
-
38
- Args:
39
- app: Target Typer application instance.
40
- output_name: Base filename (without extension) for saving recordings.
41
- output_dir: Target output directory for generated demo artifacts.
42
- width: Terminal column width.
43
- height: Terminal row height.
44
- bin_name: Display binary name for recorded command lines (defaults to app name or 'hexastack').
45
-
46
- Notes/Architectural Intent:
47
- Configurable bin_name allows narrating commands from across the hexa- family of tools
48
- (e.g., hexaflow, hexaqual, hq) with accurate terminal prompts in generated demo videos.
49
- """
50
- self.app = app
51
- self.output_name = output_name
52
- self.output_dir = output_dir or Path("docs/assets/demos")
53
- self.width = width
54
- self.height = height
55
- app_name = getattr(getattr(app, "info", None), "name", None)
56
- self.bin_name = bin_name or app_name or "hexastack"
57
- self.record_mode = os.environ.get("RECORD_DEMO") in ("1", "true", "True")
58
- self.runner = CliRunner()
59
- self.start_time: float = time.time()
60
- self.events: list[TerminalEvent] = []
61
- self.captions: list[tuple[float, float, str]] = []
62
- self._current_step_start: float | None = None
63
- self._current_step_text: str | None = None
64
- self._segment_count: int = 0
65
- self.segments: dict[str, dict[str, Path]] = {}
66
-
67
- def step(self, title: str, *, record: bool = True) -> None:
68
- """Mark a new narrative step in the CLI demo recording.
69
-
70
- Args:
71
- title: Human-readable narrative explanation shown in subtitle banner.
72
- record: Whether to emit the step subtitle and recording marker (defaults to True).
73
- """
74
- if not record:
75
- return
76
-
77
- now = time.time() - self.start_time
78
- if self._current_step_start is not None and self._current_step_text is not None:
79
- self.captions.append(
80
- (self._current_step_start, now, self._current_step_text)
81
- )
82
-
83
- self._current_step_start = now
84
- self._current_step_text = title
85
-
86
- if self.record_mode:
87
- self.events.append(
88
- TerminalEvent(event_type="step", payload=title, timestamp=now)
89
- )
90
-
91
- def run_command(
92
- self,
93
- args: list[str],
94
- env: dict[str, str] | None = None,
95
- input: str | None = None,
96
- record: bool = True,
97
- ) -> Result:
98
- """Execute a CLI command within the active Typer application.
99
-
100
- Args:
101
- args: Command-line argument strings.
102
- env: Optional environment variables.
103
- input: Optional stdin input string.
104
- record: Whether to record this command invocation and output (defaults to True).
105
-
106
- Returns:
107
- CliRunner Result containing exit code, stdout, and stderr.
108
- """
109
- cmd_str = f"{self.bin_name} {' '.join(args)}"
110
-
111
- if self.record_mode and record:
112
- # 1. Record input command string
113
- now = time.time() - self.start_time
114
- self.events.append(
115
- TerminalEvent(
116
- event_type="input",
117
- payload=f"$ {cmd_str}\n",
118
- timestamp=now,
119
- )
120
- )
121
-
122
- # Execute command in-memory
123
- # If an event loop is running in the active thread, offload execution to a thread pool
124
- # to ensure commands invoking asyncio.run() (e.g. Hexaflow simulate/run) succeed without RuntimeError.
125
- try:
126
- asyncio.get_running_loop()
127
- is_loop_running = True
128
- except RuntimeError:
129
- is_loop_running = False
130
-
131
- if is_loop_running:
132
- with ThreadPoolExecutor(max_workers=1) as pool:
133
- res = pool.submit(
134
- self.runner.invoke, self.app, args, input=input, env=env
135
- ).result()
136
- else:
137
- res = self.runner.invoke(self.app, args, input=input, env=env)
138
-
139
- if self.record_mode and record:
140
- # 2. Record command output text
141
- now = time.time() - self.start_time
142
- output_text = res.stdout if res.stdout else ""
143
- if output_text:
144
- self.events.append(
145
- TerminalEvent(
146
- event_type="output",
147
- payload=output_text,
148
- timestamp=now,
149
- )
150
- )
151
-
152
- return res
153
-
154
- def checkpoint_segment(self, name: str | None = None) -> dict[str, Path]:
155
- """Flush the current recording segment to video and subtitle artifacts.
156
-
157
- Slices recorded terminal events and narration captions since the last checkpoint,
158
- renders the segment video (.webm) and WebVTT subtitle track (.vtt), and resets
159
- the segment event buffer while preserving the underlying execution state
160
- (Typer application context, filesystem, and databases).
161
-
162
- Args:
163
- name: Suffix or name for this segment (e.g. 'part1-scaffolding'). If omitted,
164
- defaults to 'segment-<N>'.
165
-
166
- Returns:
167
- Dictionary containing paths to generated segment artifacts ('webm', 'vtt').
168
-
169
- Notes/Architectural Intent:
170
- Enables dogfooding multi-step tutorials (e.g. Hexaflow stages) to produce digestible
171
- micro-demo videos per stage without incurring the overhead of running separate,
172
- disconnected test harnesses.
173
- """
174
- self._segment_count += 1
175
- if name:
176
- segment_name = (
177
- f"{self.output_name}-{name}"
178
- if self.output_name and not name.startswith(f"{self.output_name}-")
179
- else name
180
- )
181
- else:
182
- base = self.output_name or "cli-demo"
183
- segment_name = f"{base}-segment-{self._segment_count}"
184
-
185
- if not self.record_mode:
186
- self.events = []
187
- self.captions = []
188
- self.start_time = time.time()
189
- self._current_step_start = None
190
- self._current_step_text = None
191
- return {}
192
-
193
- now = time.time() - self.start_time
194
- if self._current_step_start is not None and self._current_step_text is not None:
195
- self.captions.append(
196
- (self._current_step_start, now, self._current_step_text)
197
- )
198
-
199
- out_dir = self.output_dir
200
- out_dir.mkdir(parents=True, exist_ok=True)
201
- artifacts: dict[str, Path] = {}
202
-
203
- # 1. Normalize segment event timestamps so the segment video begins at 0.0s
204
- if self.events:
205
- base_ts = self.events[0].timestamp
206
- segment_events = [
207
- TerminalEvent(
208
- event_type=e.event_type,
209
- payload=e.payload,
210
- timestamp=max(0.0, e.timestamp - base_ts),
211
- )
212
- for e in self.events
213
- ]
214
- else:
215
- segment_events = []
216
-
217
- # 2. Render segment video and true-timing VTT via Playwright Rich Terminal
218
- from hexastack_cli.testing.terminal import render_cli_demo_video
219
-
220
- video_path = out_dir / f"{segment_name}.webm"
221
- video_file, vtt_file = render_cli_demo_video(
222
- events=segment_events,
223
- output_path=video_path,
224
- title=f"{self.bin_name} CLI — {segment_name}",
225
- )
226
- artifacts["webm"] = video_file
227
- artifacts["vtt"] = vtt_file
228
- self.segments[segment_name] = artifacts
229
-
230
- # 3. Reset segment event buffer and start time for the next segment
231
- self.events = []
232
- self.captions = []
233
- self.start_time = time.time()
234
- self._current_step_start = None
235
- self._current_step_text = None
236
-
237
- return artifacts
238
-
239
- def finish(self) -> dict[str, Path]:
240
- """Finalize the recording and render video and caption artifacts.
241
-
242
- Returns:
243
- Dictionary containing paths to generated artifact files (e.g. 'webm', 'vtt').
244
- """
245
- if not self.record_mode:
246
- return {}
247
-
248
- if self.events:
249
- if self._segment_count > 0:
250
- self.checkpoint_segment("final")
251
- all_artifacts: dict[str, Path] = {}
252
- for seg_name, arts in self.segments.items():
253
- for k, v in arts.items():
254
- all_artifacts[f"{seg_name}_{k}"] = v
255
- if self.segments:
256
- latest_seg = list(self.segments.values())[-1]
257
- all_artifacts.update(latest_seg)
258
- return all_artifacts
259
-
260
- now = time.time() - self.start_time
261
- if (
262
- self._current_step_start is not None
263
- and self._current_step_text is not None
264
- ):
265
- self.captions.append(
266
- (self._current_step_start, now, self._current_step_text)
267
- )
268
-
269
- out_dir = self.output_dir
270
- out_dir.mkdir(parents=True, exist_ok=True)
271
- base_name = self.output_name or "cli-demo"
272
- artifacts: dict[str, Path] = {}
273
-
274
- # 1. Export WebVTT (.vtt) synchronized narration subtitles
275
- vtt_lines = ["WEBVTT", ""]
276
- for idx, (start_s, end_s, text) in enumerate(self.captions, start=1):
277
- vtt_lines.append(str(idx))
278
- vtt_lines.append(
279
- f"{self._format_vtt_time(start_s)} --> {self._format_vtt_time(end_s)}"
280
- )
281
- vtt_lines.append(text)
282
- vtt_lines.append("")
283
-
284
- vtt_path = out_dir / f"{base_name}.vtt"
285
- atomic_write_text(vtt_path, "\n".join(vtt_lines), encoding="utf-8")
286
- artifacts["vtt"] = vtt_path
287
-
288
- # 2. Render directly to .webm video via Playwright Rich Terminal
289
- from hexastack_cli.testing.terminal import render_cli_demo_video
290
-
291
- video_path = out_dir / f"{base_name}.webm"
292
- render_cli_demo_video(
293
- events=self.events,
294
- output_path=video_path,
295
- title=f"{self.bin_name} CLI — {base_name}",
296
- )
297
- artifacts["webm"] = video_path
298
-
299
- return artifacts
300
-
301
- all_artifacts: dict[str, Path] = {}
302
- for seg_name, arts in self.segments.items():
303
- for k, v in arts.items():
304
- all_artifacts[f"{seg_name}_{k}"] = v
305
- if self.segments:
306
- latest_seg = list(self.segments.values())[-1]
307
- all_artifacts.update(latest_seg)
308
- return all_artifacts
309
-
310
- @staticmethod
311
- def _format_vtt_time(seconds: float) -> str:
312
- """Format seconds float into standard WebVTT timestamp (HH:MM:SS.mmm)."""
313
- hrs = int(seconds // 3600)
314
- mins = int((seconds % 3600) // 60)
315
- secs = int(seconds % 60)
316
- millis = int(round((seconds - int(seconds)) * 1000))
317
- return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
318
-
319
-
320
- __all__ = [
321
- "CliNarrator",
322
- "TerminalEvent",
323
- ]
@@ -1,329 +0,0 @@
1
- """Rich HTML Terminal view and Playwright video renderer for CLI demo recordings."""
2
-
3
- from __future__ import annotations
4
-
5
- import concurrent.futures
6
- import html
7
- import json
8
- import re
9
- import shutil
10
- from pathlib import Path
11
-
12
- from hexastack_cli.testing.events import TerminalEvent
13
- from hexastack_core.domain.exceptions import MissingDependencyError
14
- from hexastack_core.utils.fs import atomic_write_text
15
-
16
- TERMINAL_HTML_TEMPLATE = """<!DOCTYPE html>
17
- <html lang="en">
18
- <head>
19
- <meta charset="UTF-8">
20
- <title>{title}</title>
21
- <style>
22
- * {{ box-sizing: border-box; margin: 0; padding: 0; }}
23
- body {{
24
- background: #0d1117;
25
- color: #e6edf3;
26
- font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
27
- display: flex;
28
- align-items: center;
29
- justify-content: center;
30
- height: 100vh;
31
- overflow: hidden;
32
- padding: 40px;
33
- }}
34
- .window {{
35
- background: #161b22;
36
- border-radius: 12px;
37
- box-shadow: 0 20px 50px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1);
38
- width: 1080px;
39
- height: 560px;
40
- max-height: 560px;
41
- display: flex;
42
- flex-direction: column;
43
- overflow: hidden;
44
- }}
45
- .titlebar {{
46
- background: #21262d;
47
- padding: 12px 16px;
48
- display: flex;
49
- align-items: center;
50
- border-bottom: 1px solid rgba(255,255,255,0.08);
51
- flex-shrink: 0;
52
- }}
53
- .dots {{
54
- display: flex;
55
- gap: 8px;
56
- }}
57
- .dot {{
58
- width: 12px;
59
- height: 12px;
60
- border-radius: 50%;
61
- }}
62
- .dot-red {{ background: #ff5f56; }}
63
- .dot-yellow {{ background: #ffbd2e; }}
64
- .dot-green {{ background: #27c93f; }}
65
- .title {{
66
- flex: 1;
67
- text-align: center;
68
- color: #8b949e;
69
- font-size: 13px;
70
- font-weight: 500;
71
- margin-right: 48px;
72
- }}
73
- .content {{
74
- padding: 24px;
75
- font-size: 15px;
76
- line-height: 1.6;
77
- flex: 1;
78
- overflow-y: auto;
79
- white-space: pre-wrap;
80
- word-break: break-word;
81
- scroll-behavior: smooth;
82
- }}
83
- .content::-webkit-scrollbar {{
84
- display: none;
85
- }}
86
- .content {{
87
- -ms-overflow-style: none;
88
- scrollbar-width: none;
89
- }}
90
- .prompt {{ color: #7ee787; font-weight: bold; }}
91
- .command {{ color: #79c0ff; font-weight: bold; }}
92
- .step-banner {{
93
- position: fixed;
94
- bottom: 32px;
95
- left: 50%;
96
- transform: translateX(-50%);
97
- background: rgba(15, 23, 42, 0.92);
98
- color: #ffffff;
99
- padding: 12px 24px;
100
- border-radius: 8px;
101
- font-size: 17px;
102
- font-family: system-ui, -apple-system, sans-serif;
103
- font-weight: 500;
104
- box-shadow: 0 10px 25px rgba(0,0,0,0.5);
105
- border: 1px solid rgba(255,255,255,0.15);
106
- backdrop-filter: blur(8px);
107
- z-index: 9999;
108
- transition: opacity 0.2s ease-in-out;
109
- text-align: center;
110
- max-width: 80%;
111
- }}
112
- .cursor {{
113
- display: inline-block;
114
- width: 8px;
115
- height: 16px;
116
- background: #58a6ff;
117
- vertical-align: text-bottom;
118
- animation: blink 1s step-end infinite;
119
- }}
120
- @keyframes blink {{ 50% {{ opacity: 0; }} }}
121
- </style>
122
- </head>
123
- <body>
124
- <div class="window">
125
- <div class="titlebar">
126
- <div class="dots">
127
- <div class="dot dot-red"></div>
128
- <div class="dot dot-yellow"></div>
129
- <div class="dot dot-green"></div>
130
- </div>
131
- <div class="title">{title} — bash</div>
132
- </div>
133
- <div class="content" id="terminal-content"><span class="cursor"></span></div>
134
- </div>
135
- <div class="step-banner" id="banner" style="display: none;"></div>
136
- </body>
137
- </html>
138
- """
139
-
140
-
141
- def _strip_ansi(text: str) -> str:
142
- """Remove ANSI color and escape sequences from text."""
143
- ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
144
- return ansi_escape.sub("", text)
145
-
146
-
147
- def _render_in_clean_thread(
148
- events: list[TerminalEvent],
149
- output_path: Path,
150
- title: str,
151
- width: int,
152
- height: int,
153
- ) -> tuple[Path, Path]:
154
- """Execute Playwright video recording in a fresh thread with its own event loop."""
155
- try:
156
- from playwright.sync_api import sync_playwright
157
- except ImportError as e:
158
- raise MissingDependencyError(
159
- "Playwright is required to record CLI demos into .webm video. "
160
- "Install with 'pip install hexastack-cli[testing]' or 'pip install playwright'."
161
- ) from e
162
-
163
- output_path.parent.mkdir(parents=True, exist_ok=True)
164
- temp_dir = output_path.parent / ".temp_video"
165
- temp_dir.mkdir(parents=True, exist_ok=True)
166
-
167
- html_content = TERMINAL_HTML_TEMPLATE.format(title=html.escape(title))
168
- vtt_captions: list[tuple[float, float, str]] = []
169
- current_step_start: float | None = None
170
- current_step_text: str | None = None
171
-
172
- with sync_playwright() as p:
173
- try:
174
- browser = p.chromium.launch(headless=True)
175
- except Exception:
176
- # If Playwright browser binaries are not installed, create placeholder video artifact
177
- output_path.write_bytes(b"")
178
- return output_path, output_path.with_suffix(".vtt")
179
-
180
- context = browser.new_context(
181
- viewport={"width": width, "height": height},
182
- record_video_dir=str(temp_dir),
183
- record_video_size={"width": width, "height": height},
184
- )
185
- page = context.new_page()
186
- page.set_content(html_content)
187
- page.wait_for_timeout(500)
188
- start_playback_time = 0.5
189
-
190
- current_time = start_playback_time
191
-
192
- for ev in events:
193
- if ev.event_type == "step":
194
- if current_step_start is not None and current_step_text is not None:
195
- vtt_captions.append(
196
- (current_step_start, current_time, current_step_text)
197
- )
198
-
199
- current_step_start = current_time
200
- current_step_text = ev.payload
201
-
202
- json_text = json.dumps(ev.payload)
203
- page.evaluate(
204
- f"""(() => {{
205
- const banner = document.getElementById('banner');
206
- if (banner) {{
207
- banner.innerText = {json_text};
208
- banner.style.display = 'block';
209
- }}
210
- }})();"""
211
- )
212
- page.wait_for_timeout(600)
213
- current_time += 0.6
214
-
215
- elif ev.event_type == "input":
216
- char_str = html.escape(ev.payload)
217
- if char_str == "\n":
218
- char_str = "<br/>"
219
- json_html = json.dumps(char_str)
220
- page.evaluate(
221
- f"""(() => {{
222
- const terminal = document.getElementById('terminal-content');
223
- const cursor = terminal.querySelector('.cursor');
224
- const span = document.createElement('span');
225
- span.className = 'command';
226
- span.innerHTML = {json_html};
227
- terminal.insertBefore(span, cursor);
228
- terminal.scrollTop = terminal.scrollHeight;
229
- }})();"""
230
- )
231
- page.wait_for_timeout(35)
232
- current_time += 0.035
233
-
234
- elif ev.event_type == "output":
235
- clean_text = _strip_ansi(ev.payload)
236
- escaped_output = html.escape(clean_text).replace("\n", "<br/>")
237
- json_output = json.dumps(escaped_output)
238
- page.evaluate(
239
- f"""(() => {{
240
- const terminal = document.getElementById('terminal-content');
241
- const cursor = terminal.querySelector('.cursor');
242
- const div = document.createElement('div');
243
- div.style.color = '#c9d1d9';
244
- div.style.margin = '4px 0 12px 0';
245
- div.innerHTML = {json_output};
246
- terminal.insertBefore(div, cursor);
247
- terminal.scrollTop = terminal.scrollHeight;
248
- }})();"""
249
- )
250
- page.wait_for_timeout(1000)
251
- current_time += 1.0
252
-
253
- if current_step_start is not None and current_step_text is not None:
254
- vtt_captions.append(
255
- (current_step_start, current_time + 1.5, current_step_text)
256
- )
257
-
258
- page.wait_for_timeout(1500)
259
- video = page.video
260
- page.close()
261
-
262
- if video:
263
- video.save_as(output_path)
264
-
265
- context.close()
266
- browser.close()
267
-
268
- shutil.rmtree(temp_dir, ignore_errors=True)
269
-
270
- # Export synchronized WebVTT subtitles with true video timestamps
271
- vtt_lines = ["WEBVTT", ""]
272
- for idx, (start_s, end_s, text) in enumerate(vtt_captions, start=1):
273
- vtt_lines.append(str(idx))
274
- vtt_lines.append(f"{_format_vtt_time(start_s)} --> {_format_vtt_time(end_s)}")
275
- vtt_lines.append(text)
276
- vtt_lines.append("")
277
-
278
- vtt_path = output_path.with_suffix(".vtt")
279
- atomic_write_text(vtt_path, "\n".join(vtt_lines), encoding="utf-8")
280
-
281
- return output_path, vtt_path
282
-
283
-
284
- def render_cli_demo_video(
285
- events: list[TerminalEvent],
286
- output_path: Path,
287
- title: str = "Hexastack CLI",
288
- width: int = 1280,
289
- height: int = 720,
290
- ) -> tuple[Path, Path]:
291
- """Render a sequence of TerminalEvents into synchronized .webm video and .vtt subtitle track.
292
-
293
- Args:
294
- events: Chronological sequence of input, output, and step narrative events.
295
- output_path: Destination Path for .webm video file.
296
- title: Window title text for terminal banner.
297
- width: Video frame width in pixels.
298
- height: Video frame height in pixels.
299
-
300
- Returns:
301
- Tuple of (video_path, vtt_path).
302
-
303
- Raises:
304
- MissingDependencyError: If `playwright` is not installed.
305
- """
306
- with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
307
- future = executor.submit(
308
- _render_in_clean_thread,
309
- events=events,
310
- output_path=output_path,
311
- title=title,
312
- width=width,
313
- height=height,
314
- )
315
- return future.result()
316
-
317
-
318
- def _format_vtt_time(seconds: float) -> str:
319
- """Format seconds into WebVTT timestamp (HH:MM:SS.mmm)."""
320
- hrs = int(seconds // 3600)
321
- mins = int((seconds % 3600) // 60)
322
- secs = int(seconds % 60)
323
- millis = int(round((seconds - int(seconds)) * 1000))
324
- return f"{hrs:02d}:{mins:02d}:{secs:02d}.{millis:03d}"
325
-
326
-
327
- __all__ = [
328
- "render_cli_demo_video",
329
- ]