@@ -1570,6 +1779,7 @@ def _get_editor_html() -> str:
←→ Seek
[] Prev/Next chapter
I Show Info
+
G Settings
Y View YAML
⌘S Save
Del Delete selected
@@ -1938,9 +2148,19 @@ def _get_editor_html() -> str:
}
}
+ // Track prompt positions via input events
+ const promptRows = new Set(); // rows where prompts were detected
+ let lastOutputRow = -1;
+
for (const ev of data.recording.events) {
if (ev.time > currentTime) break;
+ if (ev.code === 'i') {
+ // Input event: current row has a prompt (text before cursor)
+ promptRows.add(curRow);
+ continue;
+ }
if (ev.code !== 'o') continue;
+ lastOutputRow = curRow;
const s = ev.data;
let i = 0;
while (i < s.length) {
@@ -2003,6 +2223,61 @@ def _get_editor_html() -> str:
}
}
+ // --- Prompt substitution ---
+ const promptSetting = data.script.settings.prompt;
+ const promptPattern = data.script.settings.prompt_pattern;
+ if (promptSetting) {
+ const PROMPT_CHARS = ['$', '%', '#', '>', '\u276f', '\u2192', '\u25b6', '\u27e9', '\u03bb'];
+ if (promptRows.size > 0) {
+ // Structural detection: substitute prompt char on identified rows
+ for (const r of promptRows) {
+ if (r >= rows) continue;
+ for (let c2 = 0; c2 < cols; c2++) {
+ const ch = grid[r][c2].char;
+ if (PROMPT_CHARS.includes(ch)) {
+ grid[r][c2] = {...grid[r][c2], char: promptSetting};
+ break;
+ }
+ if (ch !== ' ' && !PROMPT_CHARS.includes(ch)) break;
+ }
+ }
+ } else if (promptPattern) {
+ // Regex fallback for recordings without input events
+ try {
+ const re = new RegExp(promptPattern);
+ for (let r = 0; r < rows; r++) {
+ const rowText = grid[r].map(c2 => c2.char).join('');
+ if (re.test(rowText)) {
+ for (let c2 = 0; c2 < cols; c2++) {
+ const ch = grid[r][c2].char;
+ if (PROMPT_CHARS.includes(ch)) {
+ grid[r][c2] = {...grid[r][c2], char: promptSetting};
+ break;
+ }
+ if (ch !== ' ' && !PROMPT_CHARS.includes(ch)) break;
+ }
+ }
+ }
+ } catch(e) { /* invalid regex, skip */ }
+ } else {
+ // Heuristic fallback: scan all rows for a leading prompt char
+ // (used when no input events and no prompt_pattern)
+ for (let r = 0; r < rows; r++) {
+ for (let c2 = 0; c2 < cols; c2++) {
+ const ch = grid[r][c2].char;
+ if (ch === ' ') continue; // skip leading whitespace
+ if (PROMPT_CHARS.includes(ch)) {
+ // Verify it looks like a prompt: char followed by a space
+ if (c2 + 1 < cols && grid[r][c2 + 1].char === ' ') {
+ grid[r][c2] = {...grid[r][c2], char: promptSetting};
+ }
+ }
+ break; // only check the first non-space char per row
+ }
+ }
+ }
+ }
+
// Render grid as HTML with inline color styles
const htmlLines = [];
for (let r = 0; r < rows; r++) {
@@ -3360,6 +3635,138 @@ def _get_editor_html() -> str:
btnInspector.classList.remove('active');
}
+ // --- Settings panel ---
+ const settingsPanel = document.getElementById('settings-panel');
+ const btnSettings = document.getElementById('btn-settings');
+ const settingSpeed = document.getElementById('setting-speed');
+ const settingIdle = document.getElementById('setting-idle');
+ const settingChrome = document.getElementById('setting-chrome');
+ const settingFontFamily = document.getElementById('setting-font-family');
+ const settingPrompt = document.getElementById('setting-prompt');
+ const settingPromptPattern = document.getElementById('setting-prompt-pattern');
+
+ btnSettings.addEventListener('click', toggleSettings);
+ document.getElementById('settings-close').addEventListener('click', hideSettings);
+
+ // Add custom number spinner buttons to settings number inputs (same as properties panel)
+ settingsPanel.querySelectorAll('input[type="number"]').forEach(input => {
+ const wrap = document.createElement('div');
+ wrap.className = 'number-wrap';
+ input.parentNode.insertBefore(wrap, input);
+ wrap.appendChild(input);
+ const step = parseFloat(input.step) || 1;
+ const btnUp = document.createElement('button');
+ btnUp.type = 'button';
+ btnUp.className = 'num-btn num-btn-up';
+ btnUp.innerHTML = '▴';
+ btnUp.addEventListener('click', () => { input.value = (parseFloat(input.value || 0) + step).toFixed(2); input.dispatchEvent(new Event('input')); });
+ const btnDown = document.createElement('button');
+ btnDown.type = 'button';
+ btnDown.className = 'num-btn num-btn-down';
+ btnDown.innerHTML = '▾';
+ btnDown.addEventListener('click', () => { input.value = (parseFloat(input.value || 0) - step).toFixed(2); input.dispatchEvent(new Event('input')); });
+ wrap.appendChild(btnUp);
+ wrap.appendChild(btnDown);
+ });
+
+ function toggleSettings() {
+ if (settingsPanel.classList.contains('hidden')) {
+ showSettings();
+ } else {
+ hideSettings();
+ }
+ }
+
+ function showSettings() {
+ // Populate from current data
+ const s = data.script.settings;
+ settingSpeed.value = s.speed != null ? s.speed : 1.0;
+ settingIdle.value = s.idle_time_limit != null ? s.idle_time_limit : '';
+ settingChrome.value = s.window_chrome || 'colorful';
+ settingFontFamily.value = s.font_family || '';
+ settingPrompt.value = s.prompt || '';
+ settingPromptPattern.value = s.prompt_pattern || '';
+ updatePromptPresets();
+ settingsPanel.classList.remove('hidden');
+ btnSettings.classList.add('active');
+ // Hide inspector if open
+ hideInspector();
+ }
+
+ function hideSettings() {
+ settingsPanel.classList.add('hidden');
+ btnSettings.classList.remove('active');
+ }
+
+ function updatePromptPresets() {
+ const current = settingPrompt.value;
+ settingsPanel.querySelectorAll('.prompt-preset').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.prompt === current);
+ });
+ }
+
+ settingSpeed.addEventListener('change', () => {
+ data.script.settings.speed = parseFloat(settingSpeed.value);
+ markDirty();
+ applySettingsPreview();
+ });
+
+ settingIdle.addEventListener('input', () => {
+ const v = parseFloat(settingIdle.value);
+ if (settingIdle.value === '' || settingIdle.value == null) {
+ data.script.settings.idle_time_limit = null;
+ } else if (!isNaN(v) && v >= 0) {
+ data.script.settings.idle_time_limit = v;
+ }
+ markDirty();
+ applySettingsPreview();
+ });
+
+ settingChrome.addEventListener('change', () => {
+ data.script.settings.window_chrome = settingChrome.value;
+ markDirty();
+ applySettingsPreview();
+ });
+
+ settingFontFamily.addEventListener('input', () => {
+ const v = settingFontFamily.value.trim();
+ data.script.settings.font_family = v || null;
+ markDirty();
+ applySettingsPreview();
+ });
+
+ settingPrompt.addEventListener('input', () => {
+ const v = settingPrompt.value.trim();
+ data.script.settings.prompt = v || null;
+ updatePromptPresets();
+ markDirty();
+ applySettingsPreview();
+ });
+
+ settingPromptPattern.addEventListener('input', () => {
+ const v = settingPromptPattern.value.trim();
+ data.script.settings.prompt_pattern = v || null;
+ markDirty();
+ applySettingsPreview();
+ });
+
+ // Prompt preset clicks
+ settingsPanel.addEventListener('click', (e) => {
+ const preset = e.target.closest('.prompt-preset');
+ if (!preset) return;
+ const val = preset.dataset.prompt;
+ settingPrompt.value = val;
+ data.script.settings.prompt = val;
+ updatePromptPresets();
+ markDirty();
+ applySettingsPreview();
+ });
+
+ // Reset playhead and re-render when global settings change
+ function applySettingsPreview() {
+ seek(0);
+ }
+
// --- Resize handle (preview ↔ transport/timeline) ---
(function() {
const handle = document.getElementById('resize-handle');
@@ -3543,11 +3950,13 @@ def _get_editor_html() -> str:
else if (e.key === 'Delete' || e.key === 'Backspace') { window.deleteSelected(); }
else if (e.key === 'Escape') {
if (yamlBackdrop.classList.contains('visible')) { hideYamlPreview(); }
+ else if (!settingsPanel.classList.contains('hidden')) { hideSettings(); }
else { propsPanel.classList.remove('open'); selectedItem = null; clearCutHighlights(); }
}
else if (e.key === 's' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); save(); }
else if (e.key === 'y' || e.key === 'Y') { showYamlPreview(); }
else if (e.key === 'i' || e.key === 'I') { toggleInspector(); }
+ else if (e.key === 'g' || e.key === 'G') { toggleSettings(); }
});
// Close panel on click outside
diff --git a/great_docs/_term_player/manifest.py b/great_docs/_term_player/manifest.py
index d469a4bd..0b56b1fa 100644
--- a/great_docs/_term_player/manifest.py
+++ b/great_docs/_term_player/manifest.py
@@ -3,10 +3,11 @@
from __future__ import annotations
import json
+import re
from dataclasses import dataclass, field
from pathlib import Path
-from .emulator import TerminalEmulator
+from .emulator import ScreenState, TerminalEmulator
from .parser import Recording
from .renderer import render_frame
from .script import Annotation, Chapter, Highlight, Script, Snippet
@@ -173,6 +174,17 @@ def generate_manifest(
if event.code == "m":
chapters.append(Chapter(time=event.time, label=event.data))
+ # Detect prompt prefix for substitution (if prompt setting is configured)
+ prompt_prefix: str | None = None
+ prompt_replacement: str | None = None
+ prompt_pattern: str | None = None
+
+ if script and script.prompt:
+ prompt_replacement = script.prompt
+ prompt_pattern = script.prompt_pattern
+ # Run a pre-pass to detect the prompt from input events
+ prompt_prefix = _detect_prompt_prefix(recording)
+
# Determine keyframe times
keyframe_times = _compute_keyframe_times(recording.duration, keyframe_interval, chapters)
@@ -215,6 +227,18 @@ def generate_manifest(
# Capture keyframe
state = emu.screen
+
+ # Apply prompt substitution if configured
+ if prompt_replacement:
+ if prompt_prefix:
+ state = _apply_prompt_substitution(
+ state, prompt_prefix, prompt_replacement, prompt_pattern
+ )
+ elif prompt_pattern:
+ state = _apply_prompt_pattern_substitution(
+ state, prompt_pattern, prompt_replacement
+ )
+
frame_num = len(keyframes)
filename = f"{prefix}-{frame_num:03d}.svg"
@@ -293,3 +317,164 @@ def _delta_change_to_dict(change: DeltaChange) -> dict:
if change.bold:
d["bold"] = True
return d
+
+
+# ---------------------------------------------------------------------------
+# Prompt substitution
+# ---------------------------------------------------------------------------
+
+
+def _detect_prompt_prefix(recording: Recording) -> str | None:
+ """Detect the prompt prefix string by correlating input events with screen state.
+
+ Runs through the recording events with an emulator. At each input ("i") event,
+ captures the text on the cursor row from column 0 up to the cursor position.
+ Returns the most common prompt prefix found, or None if no input events exist.
+ """
+ emu = TerminalEmulator(cols=recording.term.cols, rows=recording.term.rows)
+ prompt_texts: list[str] = []
+
+ for event in recording.events:
+ if event.code == "o":
+ emu.feed(event.data)
+ elif event.code == "r":
+ parts = event.data.split("x")
+ if len(parts) == 2:
+ try:
+ emu.resize(int(parts[0]), int(parts[1]))
+ except ValueError:
+ pass
+ elif event.code == "i":
+ screen = emu.screen
+ row = screen.cursor_row
+ col = screen.cursor_col
+ if col > 0:
+ # Extract text on this row up to cursor position
+ text = "".join(screen.cells[row][c].char for c in range(col))
+ prompt_texts.append(text)
+
+ if not prompt_texts:
+ return None
+
+ # Find the most common prompt prefix
+ from collections import Counter
+
+ counts = Counter(prompt_texts)
+ most_common = counts.most_common(1)[0][0]
+ return most_common
+
+
+# Common prompt characters, ordered by specificity
+_PROMPT_CHARS = ("❯", "➜", "→", "▶", "⟩", "λ", "%", "$", ">", "#")
+
+
+def _find_prompt_char_in_prefix(prefix: str) -> tuple[int, str] | None:
+ """Find the last prompt character in a detected prefix string.
+
+ Returns (col_index, char) or None if no known prompt char is found.
+ """
+ # Search backwards for the last known prompt char
+ for i in range(len(prefix) - 1, -1, -1):
+ if prefix[i] in _PROMPT_CHARS:
+ return (i, prefix[i])
+ return None
+
+
+def _apply_prompt_substitution(
+ state: ScreenState,
+ prompt_prefix: str,
+ replacement: str,
+ prompt_pattern: str | None = None,
+) -> ScreenState:
+ """Apply prompt character substitution to a screen state.
+
+ Finds rows that start with the detected prompt prefix and replaces the
+ prompt character with the configured replacement string.
+
+ Parameters
+ ----------
+ state
+ The terminal screen state to modify.
+ prompt_prefix
+ The detected prompt prefix (e.g., "$ " or "user@host:~ $ ").
+ replacement
+ The string to substitute for the prompt character.
+ prompt_pattern
+ Optional regex pattern for fallback prompt detection (used when
+ no input events were available to detect the prefix).
+
+ Returns
+ -------
+ ScreenState
+ A copy of the state with prompt characters substituted.
+ """
+ # Find the prompt character position within the prefix
+ char_info = _find_prompt_char_in_prefix(prompt_prefix)
+ if char_info is None:
+ return state
+
+ prompt_col, original_char = char_info
+
+ # Make a copy so we don't mutate the original
+ new_state = state.copy()
+
+ for row_idx in range(new_state.rows):
+ # Extract the row text up to the prompt prefix length
+ row_text = "".join(
+ new_state.cells[row_idx][c].char for c in range(min(len(prompt_prefix), new_state.cols))
+ )
+
+ # Check if this row starts with the detected prompt prefix
+ if row_text == prompt_prefix:
+ # Substitute the prompt character cell
+ new_state.cells[row_idx][prompt_col].char = replacement
+
+ return new_state
+
+
+def _apply_prompt_pattern_substitution(
+ state: ScreenState,
+ pattern: str,
+ replacement: str,
+) -> ScreenState:
+ """Apply prompt substitution using a regex pattern (fallback mode).
+
+ Used when no input events are available to detect prompts structurally.
+ The pattern should match the prompt portion at the start of a line.
+
+ Parameters
+ ----------
+ state
+ The terminal screen state to modify.
+ pattern
+ Regex pattern that matches the prompt at line start. The last
+ character of the match is replaced.
+ replacement
+ The string to substitute for the prompt character.
+
+ Returns
+ -------
+ ScreenState
+ A copy of the state with prompt characters substituted.
+ """
+ try:
+ regex = re.compile(pattern)
+ except re.error:
+ return state
+
+ new_state = state.copy()
+
+ for row_idx in range(new_state.rows):
+ # Extract full row text
+ row_text = "".join(new_state.cells[row_idx][c].char for c in range(new_state.cols))
+
+ m = regex.match(row_text)
+ if m:
+ # Find the prompt char — last non-space char in the match
+ matched = m.group(0)
+ for i in range(len(matched) - 1, -1, -1):
+ if matched[i].strip():
+ new_state.cells[row_idx][i].char = replacement
+ break
+
+ return new_state
diff --git a/great_docs/_term_player/script.py b/great_docs/_term_player/script.py
index b3361702..6b62b98a 100644
--- a/great_docs/_term_player/script.py
+++ b/great_docs/_term_player/script.py
@@ -85,6 +85,8 @@ class Script:
font_family: str | None = None
show_cursor: bool = True
window_chrome: str = "none"
+ prompt: str | None = None
+ prompt_pattern: str | None = None
chapters: list[Chapter] = field(default_factory=list)
cuts: list[Cut] = field(default_factory=list)
annotations: list[Annotation] = field(default_factory=list)
@@ -131,6 +133,12 @@ def _parse_script_data(data: dict[str, Any]) -> Script:
script.font_family = settings.get("font_family")
script.show_cursor = settings.get("show_cursor", True)
script.window_chrome = settings.get("window_chrome", "none")
+ if "prompt" in settings:
+ script.prompt = str(settings["prompt"]) if settings["prompt"] is not None else None
+ if "prompt_pattern" in settings:
+ script.prompt_pattern = (
+ str(settings["prompt_pattern"]) if settings["prompt_pattern"] is not None else None
+ )
# Chapters
for ch in data.get("chapters", []):
diff --git a/tests/test_term_editor.py b/tests/test_term_editor.py
new file mode 100644
index 00000000..601fbd07
--- /dev/null
+++ b/tests/test_term_editor.py
@@ -0,0 +1,513 @@
+"""Tests for the _term_player.editor module (data pass and YAML serializer)."""
+
+from __future__ import annotations
+
+import yaml
+import pytest
+
+from great_docs._term_player.editor import _build_editor_data, _serialize_script
+from great_docs._term_player.parser import Event, Recording, TermInfo
+from great_docs._term_player.script import (
+ Annotation,
+ Chapter,
+ Cut,
+ Script,
+ Snippet,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_recording(**kwargs) -> Recording:
+ """Create a minimal Recording for testing."""
+ duration = kwargs.pop("duration", 10.0)
+ defaults = {
+ "events": [Event(time=duration, code="o", data="")],
+ "term": TermInfo(cols=80, rows=24),
+ "title": "test",
+ }
+ defaults.update(kwargs)
+ return Recording(**defaults)
+
+
+def _make_script(**kwargs) -> Script:
+ """Create a Script with sensible defaults, overridable via kwargs."""
+ defaults = {
+ "source": "demo.termshow",
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": None,
+ "prompt": None,
+ "prompt_pattern": None,
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ defaults.update(kwargs)
+ return Script(**defaults)
+
+
+# ---------------------------------------------------------------------------
+# _build_editor_data
+# ---------------------------------------------------------------------------
+
+
+class TestBuildEditorData:
+ """Tests for _build_editor_data()."""
+
+ def test_settings_defaults_no_script(self):
+ rec = _make_recording()
+ data = _build_editor_data(rec, None)
+ s = data["script"]["settings"]
+ assert s["idle_time_limit"] is None
+ assert s["speed"] == 1.0
+ assert s["window_chrome"] == "colorful"
+ assert s["font_family"] is None
+ assert s["prompt"] is None
+ assert s["prompt_pattern"] is None
+
+ def test_settings_with_all_fields(self):
+ script = _make_script(
+ idle_time_limit=2.0,
+ speed=1.5,
+ window_chrome="simple",
+ font_family="JetBrains Mono, monospace",
+ prompt="❯",
+ prompt_pattern=r"^\$ ",
+ )
+ data = _build_editor_data(_make_recording(), script)
+ s = data["script"]["settings"]
+ assert s["idle_time_limit"] == 2.0
+ assert s["speed"] == 1.5
+ assert s["window_chrome"] == "simple"
+ assert s["font_family"] == "JetBrains Mono, monospace"
+ assert s["prompt"] == "❯"
+ assert s["prompt_pattern"] == r"^\$ "
+
+ def test_settings_prompt_none(self):
+ script = _make_script(prompt=None, prompt_pattern=None)
+ data = _build_editor_data(_make_recording(), script)
+ s = data["script"]["settings"]
+ assert s["prompt"] is None
+ assert s["prompt_pattern"] is None
+
+ def test_chapters_passed_through(self):
+ script = _make_script(
+ chapters=[
+ Chapter(time=0.0, label="Intro"),
+ Chapter(time=5.0, label="Demo"),
+ ]
+ )
+ data = _build_editor_data(_make_recording(), script)
+ chs = data["script"]["chapters"]
+ assert len(chs) == 2
+ assert chs[0] == {"time": 0.0, "label": "Intro"}
+ assert chs[1] == {"time": 5.0, "label": "Demo"}
+
+ def test_annotations_passed_through(self):
+ script = _make_script(
+ annotations=[
+ Annotation(
+ time=1.0, duration=3.0, text="Hello", position="top-right", style="callout"
+ ),
+ ]
+ )
+ data = _build_editor_data(_make_recording(), script)
+ anns = data["script"]["annotations"]
+ assert len(anns) == 1
+ assert anns[0]["text"] == "Hello"
+ assert anns[0]["position"] == "top-right"
+
+ def test_cuts_passed_through(self):
+ script = _make_script(
+ cuts=[
+ Cut(start=2.0, end=4.0, type="ellipsis"),
+ ]
+ )
+ data = _build_editor_data(_make_recording(), script)
+ cuts = data["script"]["cuts"]
+ assert len(cuts) == 1
+ assert cuts[0] == {"start": 2.0, "end": 4.0, "type": "ellipsis"}
+
+ def test_snippets_passed_through(self):
+ script = _make_script(
+ snippets=[
+ Snippet(time=1.0, duration=5.0, text="pip install x", match="", label="Install"),
+ ]
+ )
+ data = _build_editor_data(_make_recording(), script)
+ snips = data["script"]["snippets"]
+ assert len(snips) == 1
+ assert snips[0]["text"] == "pip install x"
+ assert snips[0]["label"] == "Install"
+
+ def test_recording_fields(self):
+ rec = _make_recording(
+ events=[Event(time=0.5, code="o", data="hello"), Event(time=5.0, code="o", data="")],
+ title="My Demo",
+ )
+ data = _build_editor_data(rec, _make_script())
+ r = data["recording"]
+ assert r["title"] == "My Demo"
+ assert r["duration"] == 5.0
+ assert r["term"] == {"cols": 80, "rows": 24}
+ assert len(r["events"]) == 2
+ assert r["events"][0] == {"time": 0.5, "code": "o", "data": "hello"}
+
+
+# ---------------------------------------------------------------------------
+# _serialize_script
+# ---------------------------------------------------------------------------
+
+
+class TestSerializeScript:
+ """Tests for _serialize_script()."""
+
+ def test_minimal_settings(self):
+ """Only window_chrome set (speed=1.0 is default, so omitted)."""
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": None,
+ "prompt": None,
+ "prompt_pattern": None,
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["source"] == "demo.termshow"
+ assert parsed["settings"]["window_chrome"] == "colorful"
+ assert "prompt" not in parsed["settings"]
+ assert "prompt_pattern" not in parsed["settings"]
+ assert "font_family" not in parsed["settings"]
+
+ def test_prompt_serialized(self):
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": None,
+ "prompt": "❯",
+ "prompt_pattern": None,
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["settings"]["prompt"] == "❯"
+ assert "prompt_pattern" not in parsed["settings"]
+
+ def test_prompt_and_pattern_serialized(self):
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": None,
+ "prompt": "→",
+ "prompt_pattern": r"^\$ ",
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["settings"]["prompt"] == "→"
+ assert parsed["settings"]["prompt_pattern"] == r"^\$ "
+
+ def test_font_family_single(self):
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": "JetBrains Mono",
+ "prompt": None,
+ "prompt_pattern": None,
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["settings"]["font_family"] == "JetBrains Mono"
+
+ def test_font_family_comma_list(self):
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": "JetBrains Mono, Fira Code, monospace",
+ "prompt": None,
+ "prompt_pattern": None,
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["settings"]["font_family"] == "JetBrains Mono, Fira Code, monospace"
+
+ def test_speed_non_default_serialized(self):
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 2.0,
+ "window_chrome": "colorful",
+ "font_family": None,
+ "prompt": None,
+ "prompt_pattern": None,
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["settings"]["speed"] == 2.0
+
+ def test_speed_default_omitted(self):
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": None,
+ "prompt": None,
+ "prompt_pattern": None,
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert "speed" not in parsed["settings"]
+
+ def test_idle_time_limit_serialized(self):
+ script_data = {
+ "settings": {
+ "idle_time_limit": 2.5,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": None,
+ "prompt": None,
+ "prompt_pattern": None,
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["settings"]["idle_time_limit"] == 2.5
+
+ def test_chapters_serialized_sorted(self):
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": None,
+ "prompt": None,
+ "prompt_pattern": None,
+ },
+ "chapters": [
+ {"time": 5.0, "label": "Second"},
+ {"time": 0.0, "label": "First"},
+ ],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["chapters"][0]["label"] == "First"
+ assert parsed["chapters"][1]["label"] == "Second"
+
+ def test_snippets_with_match(self):
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": "colorful",
+ "font_family": None,
+ "prompt": None,
+ "prompt_pattern": None,
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [
+ {"time": 1.0, "duration": 5.0, "text": "", "match": r"\$ (.+)", "label": "cmd"},
+ ],
+ }
+ yaml_str = _serialize_script(script_data, "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["snippets"][0]["match"] == r"\$ (.+)"
+ assert parsed["snippets"][0]["label"] == "cmd"
+
+ def test_all_settings_combined(self):
+ """Full settings with every field populated."""
+ script_data = {
+ "settings": {
+ "idle_time_limit": 1.5,
+ "speed": 3.0,
+ "window_chrome": "simple",
+ "font_family": "Fira Code, monospace",
+ "prompt": "$",
+ "prompt_pattern": r"^\$ ",
+ },
+ "chapters": [{"time": 0.0, "label": "Start"}],
+ "annotations": [
+ {
+ "time": 1.0,
+ "duration": 2.0,
+ "text": "Hi",
+ "position": "top-right",
+ "style": "callout",
+ "width": "medium",
+ }
+ ],
+ "cuts": [{"start": 3.0, "end": 4.0, "type": "jump"}],
+ "snippets": [
+ {"time": 0.5, "duration": 3.0, "text": "echo hello", "match": "", "label": "Run"}
+ ],
+ }
+ yaml_str = _serialize_script(script_data, "rec.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ s = parsed["settings"]
+ assert s["idle_time_limit"] == 1.5
+ assert s["speed"] == 3.0
+ assert s["window_chrome"] == "simple"
+ assert s["font_family"] == "Fira Code, monospace"
+ assert s["prompt"] == "$"
+ assert s["prompt_pattern"] == r"^\$ "
+ assert len(parsed["chapters"]) == 1
+ assert len(parsed["annotations"]) == 1
+ assert len(parsed["cuts"]) == 1
+ assert len(parsed["snippets"]) == 1
+
+ def test_empty_script(self):
+ """All None/empty produces minimal YAML."""
+ script_data = {
+ "settings": {
+ "idle_time_limit": None,
+ "speed": 1.0,
+ "window_chrome": None,
+ "font_family": None,
+ "prompt": None,
+ "prompt_pattern": None,
+ },
+ "chapters": [],
+ "annotations": [],
+ "cuts": [],
+ "snippets": [],
+ }
+ yaml_str = _serialize_script(script_data, "x.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["source"] == "x.termshow"
+ # No chapters/annotations/cuts/snippets keys when empty
+ assert parsed.get("chapters") is None
+ assert parsed.get("annotations") is None
+ assert parsed.get("cuts") is None
+ assert parsed.get("snippets") is None
+
+
+# ---------------------------------------------------------------------------
+# Round-trip: _build_editor_data → _serialize_script → yaml.safe_load
+# ---------------------------------------------------------------------------
+
+
+class TestEditorRoundTrip:
+ """Test that data passes through build → serialize → parse intact."""
+
+ def test_settings_round_trip(self):
+ script = _make_script(
+ idle_time_limit=2.0,
+ speed=1.5,
+ window_chrome="simple",
+ font_family="Cascadia Code",
+ prompt="❯",
+ prompt_pattern=r"^\$ ",
+ )
+ data = _build_editor_data(_make_recording(), script)
+ yaml_str = _serialize_script(data["script"], "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ s = parsed["settings"]
+ assert s["idle_time_limit"] == 2.0
+ assert s["speed"] == 1.5
+ assert s["window_chrome"] == "simple"
+ assert s["font_family"] == "Cascadia Code"
+ assert s["prompt"] == "❯"
+ assert s["prompt_pattern"] == r"^\$ "
+
+ def test_prompt_round_trip_none(self):
+ script = _make_script(prompt=None, prompt_pattern=None)
+ data = _build_editor_data(_make_recording(), script)
+ yaml_str = _serialize_script(data["script"], "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert "prompt" not in parsed["settings"]
+ assert "prompt_pattern" not in parsed["settings"]
+
+ def test_font_family_round_trip_list(self):
+ script = _make_script(font_family="JetBrains Mono, Fira Code, monospace")
+ data = _build_editor_data(_make_recording(), script)
+ yaml_str = _serialize_script(data["script"], "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+ assert parsed["settings"]["font_family"] == "JetBrains Mono, Fira Code, monospace"
+
+ def test_full_round_trip(self):
+ script = _make_script(
+ idle_time_limit=1.0,
+ speed=2.0,
+ window_chrome="colorful",
+ font_family="Menlo",
+ prompt=">",
+ chapters=[Chapter(time=0.0, label="Start"), Chapter(time=5.0, label="End")],
+ annotations=[
+ Annotation(
+ time=1.0, duration=3.0, text="Note", position="top-right", style="subtle"
+ )
+ ],
+ cuts=[Cut(start=2.0, end=3.0, type="ellipsis")],
+ snippets=[Snippet(time=0.5, duration=4.0, text="echo hi", label="Run")],
+ )
+ data = _build_editor_data(_make_recording(), script)
+ yaml_str = _serialize_script(data["script"], "demo.termshow")
+ parsed = yaml.safe_load(yaml_str)
+
+ assert parsed["settings"]["prompt"] == ">"
+ assert parsed["settings"]["font_family"] == "Menlo"
+ assert len(parsed["chapters"]) == 2
+ assert parsed["chapters"][0]["label"] == "Start"
+ assert len(parsed["annotations"]) == 1
+ assert parsed["annotations"][0]["text"] == "Note"
+ assert len(parsed["cuts"]) == 1
+ assert len(parsed["snippets"]) == 1
+ assert parsed["snippets"][0]["text"] == "echo hi"
diff --git a/tests/test_term_manifest.py b/tests/test_term_manifest.py
index 18bd28b7..52fa0a68 100644
--- a/tests/test_term_manifest.py
+++ b/tests/test_term_manifest.py
@@ -12,8 +12,12 @@
DeltaEntry,
KeyframeEntry,
Manifest,
+ _apply_prompt_pattern_substitution,
+ _apply_prompt_substitution,
_compute_keyframe_times,
_delta_change_to_dict,
+ _detect_prompt_prefix,
+ _find_prompt_char_in_prefix,
generate_manifest,
)
from great_docs._term_player.parser import Event, Recording, TermInfo
@@ -277,3 +281,219 @@ def test_manifest_json_valid(self, tmp_path: Path):
assert data["version"] == 1
assert "keyframes" in data
assert "chapters" in data
+
+
+# ---------------------------------------------------------------------------
+# Prompt substitution helpers
+# ---------------------------------------------------------------------------
+
+
+class TestFindPromptCharInPrefix:
+ def test_dollar_sign(self):
+ result = _find_prompt_char_in_prefix("$ ")
+ assert result == (0, "$")
+
+ def test_dollar_with_path(self):
+ result = _find_prompt_char_in_prefix("user@host:~ $ ")
+ assert result == (12, "$")
+
+ def test_chevron(self):
+ result = _find_prompt_char_in_prefix("❯ ")
+ assert result == (0, "❯")
+
+ def test_hash(self):
+ result = _find_prompt_char_in_prefix("root# ")
+ assert result == (4, "#")
+
+ def test_no_prompt_char(self):
+ result = _find_prompt_char_in_prefix("hello ")
+ assert result is None
+
+ def test_percent(self):
+ result = _find_prompt_char_in_prefix("% ")
+ assert result == (0, "%")
+
+
+class TestDetectPromptPrefix:
+ def test_detects_from_input_events(self):
+ # Simulate: prompt appears, then user types
+ events = [
+ Event(time=0.0, code="o", data="$ "),
+ Event(time=0.5, code="i", data="ls"),
+ Event(time=1.0, code="o", data="ls\r\nfile1\r\n"),
+ Event(time=2.0, code="o", data="$ "),
+ Event(time=2.5, code="i", data="pwd"),
+ ]
+ rec = Recording(events=events, term=TermInfo(cols=80, rows=24))
+ prefix = _detect_prompt_prefix(rec)
+ assert prefix == "$ "
+
+ def test_no_input_events(self):
+ events = [
+ Event(time=0.0, code="o", data="$ hello\r\n"),
+ ]
+ rec = Recording(events=events, term=TermInfo(cols=80, rows=24))
+ prefix = _detect_prompt_prefix(rec)
+ assert prefix is None
+
+ def test_custom_prompt(self):
+ events = [
+ Event(time=0.0, code="o", data="❯ "),
+ Event(time=0.5, code="i", data="git status"),
+ ]
+ rec = Recording(events=events, term=TermInfo(cols=80, rows=24))
+ prefix = _detect_prompt_prefix(rec)
+ assert prefix == "❯ "
+
+
+class TestApplyPromptSubstitution:
+ def _make_screen(self, rows_text: list[str], cols: int = 80) -> "ScreenState":
+ """Create a ScreenState from text rows."""
+ from great_docs._term_player.emulator import Cell, CellStyle, ScreenState
+
+ n_rows = len(rows_text)
+ cells = []
+ for text in rows_text:
+ row = []
+ for i in range(cols):
+ ch = text[i] if i < len(text) else " "
+ row.append(Cell(char=ch, style=CellStyle()))
+ cells.append(row)
+ return ScreenState(cols=cols, rows=n_rows, cells=cells)
+
+ def test_substitutes_dollar_prompt(self):
+ state = self._make_screen(["$ hello", "output", "$ "])
+ result = _apply_prompt_substitution(state, "$ ", "❯")
+ # Row 0: "$ " prefix → "$" replaced with "❯"
+ assert result.cells[0][0].char == "❯"
+ # Row 1: "output" doesn't match → untouched
+ assert result.cells[1][0].char == "o"
+ # Row 2: "$ " prefix → substituted
+ assert result.cells[2][0].char == "❯"
+
+ def test_does_not_touch_dollar_in_output(self):
+ state = self._make_screen(["$ echo", "$HOME is set", "$ "])
+ result = _apply_prompt_substitution(state, "$ ", "❯")
+ # Row 0: matches prefix "$ " → substituted
+ assert result.cells[0][0].char == "❯"
+ # Row 1: "$HOME" does NOT start with "$ " (no space after) → untouched
+ assert result.cells[1][0].char == "$"
+ # Row 2: matches
+ assert result.cells[2][0].char == "❯"
+
+ def test_does_not_mutate_original(self):
+ state = self._make_screen(["$ hello"])
+ result = _apply_prompt_substitution(state, "$ ", "→")
+ assert state.cells[0][0].char == "$"
+ assert result.cells[0][0].char == "→"
+
+ def test_no_match_returns_copy(self):
+ state = self._make_screen(["hello", "world"])
+ result = _apply_prompt_substitution(state, "$ ", "❯")
+ assert result.cells[0][0].char == "h"
+ assert result.cells[1][0].char == "w"
+
+ def test_complex_prompt_prefix(self):
+ state = self._make_screen(["user@host:~ $ ls", "output"])
+ result = _apply_prompt_substitution(state, "user@host:~ $ ", "❯")
+ # The $ at col 12 should be replaced
+ assert result.cells[0][12].char == "❯"
+ # The rest is untouched
+ assert result.cells[0][0].char == "u"
+
+
+class TestApplyPromptPatternSubstitution:
+ def _make_screen(self, rows_text: list[str], cols: int = 80) -> "ScreenState":
+ from great_docs._term_player.emulator import Cell, CellStyle, ScreenState
+
+ n_rows = len(rows_text)
+ cells = []
+ for text in rows_text:
+ row = []
+ for i in range(cols):
+ ch = text[i] if i < len(text) else " "
+ row.append(Cell(char=ch, style=CellStyle()))
+ cells.append(row)
+ return ScreenState(cols=cols, rows=n_rows, cells=cells)
+
+ def test_pattern_substitution(self):
+ state = self._make_screen(["$ hello", "output", "$ "])
+ result = _apply_prompt_pattern_substitution(state, r"^\$ ", "❯")
+ assert result.cells[0][0].char == "❯"
+ assert result.cells[1][0].char == "o"
+ assert result.cells[2][0].char == "❯"
+
+ def test_invalid_regex_returns_unchanged(self):
+ state = self._make_screen(["$ hello"])
+ result = _apply_prompt_pattern_substitution(state, "[invalid", "❯")
+ assert result.cells[0][0].char == "$"
+
+ def test_does_not_match_mid_line(self):
+ state = self._make_screen(["echo $HOME", "$ cmd"])
+ result = _apply_prompt_pattern_substitution(state, r"^\$ ", "❯")
+ # "echo $HOME" doesn't match ^
+ assert result.cells[0][5].char == "$"
+ # "$ cmd" matches
+ assert result.cells[1][0].char == "❯"
+
+
+class TestPromptSubstitutionIntegration:
+ def test_generate_manifest_with_prompt(self, tmp_path: Path):
+ """End-to-end test: prompt setting changes rendered SVG content."""
+ events = [
+ Event(time=0.0, code="o", data="$ "),
+ Event(time=0.5, code="i", data="ls"),
+ Event(time=0.6, code="o", data="ls\r\n"),
+ Event(time=1.0, code="o", data="file1.txt\r\n"),
+ Event(time=2.0, code="o", data="$ "),
+ Event(time=2.5, code="i", data="pwd"),
+ ]
+ rec = Recording(events=events, term=TermInfo(cols=40, rows=10), title="Prompt Test")
+ script = Script(prompt="❯")
+
+ out = tmp_path / "prompt_test"
+ manifest = generate_manifest(rec, script, output_dir=out)
+
+ # Read the last frame (should have "$ " → "❯" substituted)
+ last_frame = manifest.keyframes[-1].file
+ svg = (out / last_frame).read_text()
+
+ # The SVG should contain the substituted prompt char
+ assert "❯" in svg
+ # Frame at t=0.0 has only "$ " displayed
+ first_svg = (out / manifest.keyframes[0].file).read_text()
+ assert "❯" in first_svg
+
+ def test_generate_manifest_without_prompt_no_change(self, tmp_path: Path):
+ """Without prompt setting, $ renders as-is."""
+ events = [
+ Event(time=0.0, code="o", data="$ "),
+ Event(time=0.5, code="i", data="ls"),
+ ]
+ rec = Recording(events=events, term=TermInfo(cols=40, rows=10))
+ script = Script() # No prompt setting
+
+ out = tmp_path / "no_prompt"
+ generate_manifest(rec, script, output_dir=out)
+
+ svg = (out / "frame-000.svg").read_text()
+ # Should still have the original $
+ assert "$" in svg
+
+ def test_prompt_pattern_fallback(self, tmp_path: Path):
+ """When no input events exist, prompt_pattern regex is used."""
+ events = [
+ Event(time=0.0, code="o", data="$ hello\r\n"),
+ Event(time=1.0, code="o", data="output\r\n"),
+ Event(time=2.0, code="o", data="$ "),
+ ]
+ rec = Recording(events=events, term=TermInfo(cols=40, rows=10))
+ script = Script(prompt="→", prompt_pattern=r"^\$ ")
+
+ out = tmp_path / "pattern_test"
+ generate_manifest(rec, script, output_dir=out)
+
+ # The last frame should have → instead of $
+ last_frame_file = sorted(out.glob("frame-*.svg"))[-1]
+ svg = last_frame_file.read_text()
+ assert "→" in svg
diff --git a/tests/test_term_script.py b/tests/test_term_script.py
index 2396ef99..07ce40cd 100644
--- a/tests/test_term_script.py
+++ b/tests/test_term_script.py
@@ -364,3 +364,39 @@ def test_load_invalid_yaml_returns_empty(self, tmp_path: Path):
script = load_script(f)
assert isinstance(script, Script)
+
+ def test_load_with_prompt_setting(self, tmp_path: Path):
+ content = """\
+settings:
+ prompt: "❯"
+"""
+ f = tmp_path / "test.yml"
+ f.write_text(content, encoding="utf-8")
+
+ script = load_script(f)
+ assert script.prompt == "❯"
+ assert script.prompt_pattern is None
+
+ def test_load_with_prompt_and_pattern(self, tmp_path: Path):
+ content = """\
+settings:
+ prompt: "→"
+ prompt_pattern: '^\\$ '
+"""
+ f = tmp_path / "test.yml"
+ f.write_text(content, encoding="utf-8")
+
+ script = load_script(f)
+ assert script.prompt == "→"
+ assert script.prompt_pattern == r"^\$ "
+
+ def test_load_prompt_null_stays_none(self, tmp_path: Path):
+ content = """\
+settings:
+ prompt: null
+"""
+ f = tmp_path / "test.yml"
+ f.write_text(content, encoding="utf-8")
+
+ script = load_script(f)
+ assert script.prompt is None