Skip to content

Commit ecc42eb

Browse files
authored
Integrate ascii-splash into the playground (#45)
2 parents ee451b3 + 136868f commit ecc42eb

16 files changed

Lines changed: 2086 additions & 73 deletions

docs/specs/tutorial.md

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ At the `/playground` route on the website. **Status: Implemented** (Epics 14, 15
1010

1111
### Implementation
1212

13-
- `website/src/pages/Playground.tsx` — Page component. Dynamically imports Wall (SSR-safe). Initializes `FakePtyAdapter`, `TutorialShell`, and `TutorialDetector`. Passes `onApiReady` to set up the 3-pane layout and `onEvent` for step detection.
13+
- `website/src/pages/Playground.tsx` — Page component. Dynamically imports Wall (SSR-safe). Initializes `FakePtyAdapter`, per-terminal `TutorialShell` instances through `PlaygroundShellRegistry`, `AsciiSplashRunner`, and `TutorialDetector`. Passes `onApiReady` to set up the 3-pane layout and `onEvent` for step detection.
1414
- `website/src/components/SiteHeader.tsx` — Shared header. Accepts an optional playground-only `controls` slot and a `themeAware` mode that reads the active VSCode theme variables.
1515
- `mouseterm-lib/components/ThemePicker` — Shared header dropdown for bundled and installed themes. The playground passes `variant="playground-header"` and the footer action opens the OpenVSX installer.
1616
- `website/vite.config.ts` — Vite alias `mouseterm-lib``../lib/src` for workspace imports.
@@ -19,13 +19,15 @@ At the `/playground` route on the website. **Status: Implemented** (Epics 14, 15
1919

2020
The sandbox starts pre-populated — not empty. Scenarios assigned via `FakePtyAdapter.setScenario()` before Wall mounts:
2121

22-
- **Pane 1** (`tut-main`, left, ~60%): `SCENARIO_TUTORIAL_MOTD` — MOTD welcome message + shell prompt. `TutorialShell` handles all input via `FakePtyAdapter.setInputHandler()`.
23-
- **Pane 2** (`tut-npm`, right-top, ~40%): `SCENARIO_LONG_RUNNING``npm install` with progress dots.
22+
- **Pane 1** (`tut-main`, left, ~60%): `SCENARIO_TUTORIAL_MOTD` — MOTD welcome message + shell prompt.
23+
- **Pane 2** (`tut-npm`, right-top, ~40%): `SCENARIO_LONG_RUNNING``npm install` with progress dots, then returns to the shell prompt.
2424
- **Pane 3** (`tut-ls`, right-bottom): `SCENARIO_LS_OUTPUT``ls -la` output with a prompt.
2525

2626
The two right-side panes are added in `onApiReady` with `position: { referencePanel, direction }` after Wall creates the initial main pane.
2727

28-
## The `tut` Command
28+
Every playground pane gets its own `TutorialShell` input handler through `PlaygroundShellRegistry`. Initial demo scenarios own their output while they are playing, then the shell handles Enter, line editing, `tut`, and `ascii-splash` / `splash`. Newly split or spawned fake terminals use `SCENARIO_SHELL_PROMPT` by default so they start at `user@mouseterm:~$` instead of a blank terminal.
29+
30+
## Playground Shell Commands
2931

3032
Implemented in `website/src/lib/tutorial-shell.ts` (`TutorialShell` class).
3133

@@ -34,9 +36,33 @@ The fake terminal accepts these inputs:
3436
- **`tut`** — Shows the current tutorial step (or the next incomplete one). Does NOT show the full checklist upfront.
3537
- **`tut status`** — Shows all 6 steps with `[x]`/`[ ]` completion markers, grouped by phase.
3638
- **`tut reset`** — Clears localStorage progress and confirms.
37-
- **Anything else**`Unknown command. Type tut to start the tutorial.`
39+
- **`ascii-splash` / `splash`** — Launches the browser playground runner for `ascii-splash@0.3.0`.
40+
- **Anything else**`Unknown command. Type tut or ascii-splash.`
41+
42+
`TutorialShell` provides line editing (character echo, backspace), command history (`Up` / `Down` over xterm cursor-key escape sequences), and parses commands on Enter. Output goes through `FakePtyAdapter.sendOutput()`.
43+
44+
### `ascii-splash`
45+
46+
Implemented in `website/src/lib/ascii-splash-runner.ts` (`AsciiSplashRunner` class). That file's top comment is the source note for the browser adapter: it lists the upstream `ascii-splash@0.3.0` internals being reused and the local modifications made for MouseTerm/xterm/FakePty integration.
47+
48+
The runner uses the real upstream `ascii-splash` engine, buffer, themes, UI overlays, command parser/executor, transitions, and pattern classes. It does **not** import the upstream CLI entrypoint or `terminal-kit` renderer. Instead, it provides a browser terminal boundary:
49+
50+
- Renderer output is ANSI bytes sent through `FakePtyAdapter.sendOutput()`.
51+
- Keyboard and SGR mouse bytes from `FakePtyAdapter.writePty()` are decoded and routed to the upstream command/pattern controls.
52+
- Resize events come from `FakePtyAdapter.onPtyResize()`.
53+
- Start/cleanup uses xterm alt-screen, cursor visibility, and mouse-reporting control sequences.
54+
55+
Supported CLI options in the playground runner:
56+
57+
- `--pattern` / `-p`
58+
- `--quality` / `-q`
59+
- `--fps` / `-f`
60+
- `--theme` / `-t`
61+
- `--no-mouse`
62+
- `--help` / `-h`
63+
- `--version` / `-V`
3864

39-
`TutorialShell` provides full line editing (character echo, backspace) and parses commands on Enter. Output goes through `FakePtyAdapter.sendOutput()`.
65+
Exit with `q`, Escape, or Ctrl+C. Config persistence is disabled in the playground; upstream save/favorite commands report that no config loader is available.
4066

4167
### Cold Start
4268

@@ -142,26 +168,28 @@ The picker restores the persisted active theme on mount. The playground header i
142168

143169
- All progress keyed as `mouseterm-tutorial-step-N` in localStorage (values: `'true'`).
144170
- `FakePtyAdapter` extensions: `setInputHandler(id, fn)` routes `writePty` calls to a custom handler; `sendOutput(id, data)` writes to a terminal's output stream.
171+
- `PlaygroundShellRegistry` creates one `TutorialShell` per pane id, clears input handlers on disposal, and starts `AsciiSplashRunner` against the pane that launched it.
172+
- `FakePtyAdapter` also tracks fake PTY dimensions from `spawnPty()` / `resizePty()`, exposes `getPtySize(id)`, and provides `onPtyResize(fn)` for browser-side fake programs such as `AsciiSplashRunner`.
145173
- `Wall` extensions: `initialPaneIds` prop seeds the first pane(s); `onApiReady` callback prop exposes `DockviewApi`; `onEvent` callback prop fires `WallEvent` for mode/zoom/minimize/selection/split changes (types: `modeChange`, `zoomChange`, `minimizeChange`, `split`, `selectionChange`).
146174
- `SCENARIO_TUTORIAL_MOTD` scenario added to `lib/src/lib/platform/fake-scenarios.ts`.
147175

148176
## Mouse and Clipboard Feature Coverage
149177

150-
The Playground is the primary dogfood surface for the features in `docs/specs/mouse-and-clipboard.md`. As of the current three-pane layout (tutorial MOTD, `npm install`, `ls -la`) most of those features are not reachable from the Playground — the scenarios don't emit the relevant escape sequences or the right kinds of text.
178+
The Playground is the primary dogfood surface for the features in `docs/specs/mouse-and-clipboard.md`. The initial three-pane layout (tutorial MOTD, `npm install`, `ls -la`) still has limited coverage, but the main pane can now launch `ascii-splash`, which exercises mouse reporting and animated redraw behavior.
151179

152180
### Current state
153181

154182
Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable.
155183

156184
| Spec § | Feature | Status | Why |
157185
|---|---|---|---|
158-
| §1 | Mouse icon visible when program requests reporting | | No scenario emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h`. |
159-
| §2 | Temporary/permanent override, banner, Make-permanent / Cancel | | Blocked on §1. |
186+
| §1 | Mouse icon visible when program requests reporting | | Run `ascii-splash`; the runner emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h` unless `--no-mouse` is used. |
187+
| §2 | Temporary/permanent override, banner, Make-permanent / Cancel | | Run `ascii-splash`, then use the header mouse icon while the animation is active. |
160188
| §3.1–§3.3 | Drag, Alt-block shape, "Hold Alt" hint || Works on any visible text. |
161189
| §3.3 | "Press e to select the full URL/path" hint || No qualifying tokens; bare filenames like `package.json` don't match the patterns in `lib/src/lib/smart-token.ts`. |
162-
| §3.4 | Pure-scroll follows, cancel-on-change, cancel-on-resize | ⚠️ | Scenarios are too short to scroll; nothing emits additional output after the initial burst; resize cancel works. |
190+
| §3.4 | Pure-scroll follows, cancel-on-change, cancel-on-resize | ⚠️ | `ascii-splash` makes cancel-on-change and resize cancel observable; scenarios are still too short for pure-scroll coverage. |
163191
| §3.5 | Scrollback-origin / cross-boundary drags | ⚠️ | Scrollback is too short to exercise. |
164-
| §3.6 | Keyboard routing during drag | ⚠️ | Works, but hard to observe — no program in Playground reacts to dropped keystrokes. |
192+
| §3.6 | Keyboard routing during drag | | `ascii-splash` reacts to keys and mouse; with override active, drag-time keyboard consumption is observable. |
165193
| §3.7 | Popup on mouse-up, new-drag-replaces || Any selection. |
166194
| §4.1.1 | Copy Raw || Any selection. |
167195
| §4.1.2 | Copy Rewrapped (box-strip + paragraph unwrap) || No box-drawing characters anywhere; no multi-line prose. Rewrapped output is identical to Raw. |
@@ -176,10 +204,10 @@ Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable.
176204

177205
### Remediation plan
178206

179-
Add three new scenarios in `lib/src/lib/platform/fake-scenarios.ts` and expand the Playground layout in `website/src/pages/Playground.tsx` to surface them alongside the existing tutorial pane. Each scenario closes a specific set of gaps; all three together plus the tutorial MOTD make every currently-implemented feature reachable.
207+
Add three new scenarios in `lib/src/lib/platform/fake-scenarios.ts` and expand the Playground layout in `website/src/pages/Playground.tsx` to surface them alongside the existing tutorial pane. Together with `ascii-splash`, these close the remaining content-shape gaps.
180208

181-
1. **`SCENARIO_MOUSE_TUI`** — closes §1, §2, §8.5.
182-
Emits `\x1b[?1000h\x1b[?1006h\x1b[?2004h` and then draws an idle `htop`-style ANSI-framed view. A minimal input handler for this pane discards any mouse-report bytes xterm forwards. With this pane present the Mouse icon appears in its header, clicking it activates the temporary-override banner, and pastes into it are wrapped in `\x1b[200~ … \x1b[201~`.
209+
1. **`SCENARIO_BRACKETED_PASTE_TUI`** — closes §8.5.
210+
Emits `\x1b[?2004h` and then draws an idle ANSI-framed view. A minimal input handler for this pane discards input. With this pane present, pastes into it are wrapped in `\x1b[200~ … \x1b[201~`.
183211

184212
2. **`SCENARIO_SMART_TOKENS`** — closes §3.3 extension hint, §5.1–§5.3.
185213
Prints one of each detectable shape so every branch in `lib/src/lib/smart-token.ts`'s `PATTERNS` list has a live example:
@@ -195,9 +223,9 @@ Add three new scenarios in `lib/src/lib/platform/fake-scenarios.ts` and expand t
195223

196224
Dragging across any of them shows "Press e to select the full URL/path" and `e` extends.
197225

198-
3. **`SCENARIO_BOXED_OUTPUT`** — closes §4.1.2 and §3.4.
226+
3. **`SCENARIO_BOXED_OUTPUT`** — closes §4.1.2.
199227
A short release-notes-shaped message framed in `┌─│└` so Copy Rewrapped (via `lib/src/lib/rewrap.ts`) strips the frame and joins the wrapped lines — clipboard contents visibly differ from Copy Raw. A slowly-updating ticker line at the bottom gives cancel-on-change something concrete to react to.
200228

201-
**Playground layout:** keep `PANE_MAIN` as the tutorial entry; replace `PANE_NPM` / `PANE_LS` with `PANE_TUI` / `PANE_TOKENS` / `PANE_BOXED` (three `api.addPanel` calls in `handleApiReady`, same pattern as the existing ones at `website/src/pages/Playground.tsx:62-75`). A 2×2 grid fits on load.
229+
**Playground layout:** keep `PANE_MAIN` as the tutorial entry; replace `PANE_NPM` / `PANE_LS` with `PANE_BRACKETED` / `PANE_TOKENS` / `PANE_BOXED` (three `api.addPanel` calls in `handleApiReady`, same pattern as the existing ones at `website/src/pages/Playground.tsx:62-75`). A 2×2 grid fits on load.
202230

203231
**Optional:** teach `TutorialShell.handleInput` to recognize `\x1b[200~ … \x1b[201~` and print `[pasted: …]` so bracketed-paste wrapping is visually distinct for users who paste into `PANE_MAIN`.

lib/src/lib/platform/fake-adapter.test.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,94 @@ describe('FakePtyAdapter', () => {
9090
expect(events2).toEqual(['x', 'y']);
9191
});
9292

93-
it('resizePty is a no-op', () => {
93+
it('tracks default PTY size on spawn', () => {
9494
const { adapter } = createAdapter();
9595
adapter.spawnPty('t1');
96-
// Should not throw
96+
expect(adapter.getPtySize('t1')).toEqual({ cols: 80, rows: 30 });
97+
});
98+
99+
it('tracks requested PTY size on spawn', () => {
100+
const { adapter } = createAdapter();
101+
adapter.spawnPty('t1', { cols: 132, rows: 43 });
102+
expect(adapter.getPtySize('t1')).toEqual({ cols: 132, rows: 43 });
103+
});
104+
105+
it('tracks resizePty and notifies resize subscribers', () => {
106+
const { adapter } = createAdapter();
107+
const resizes: { id: string; cols: number; rows: number }[] = [];
108+
adapter.spawnPty('t1');
109+
adapter.onPtyResize((detail) => resizes.push(detail));
110+
111+
adapter.resizePty('t1', 120, 40);
112+
adapter.resizePty('t1', 120, 40);
113+
adapter.resizePty('t1', 121, 40);
114+
115+
expect(adapter.getPtySize('t1')).toEqual({ cols: 121, rows: 40 });
116+
expect(resizes).toEqual([
117+
{ id: 't1', cols: 120, rows: 40 },
118+
{ id: 't1', cols: 121, rows: 40 },
119+
]);
120+
});
121+
122+
it('unsubscribes resize subscribers', () => {
123+
const { adapter } = createAdapter();
124+
const resizes: { id: string; cols: number; rows: number }[] = [];
125+
adapter.spawnPty('t1');
126+
const unsubscribe = adapter.onPtyResize((detail) => resizes.push(detail));
127+
97128
adapter.resizePty('t1', 120, 40);
129+
unsubscribe();
130+
adapter.resizePty('t1', 121, 41);
131+
132+
expect(resizes).toEqual([{ id: 't1', cols: 120, rows: 40 }]);
133+
});
134+
135+
it('ignores resizePty for non-spawned terminals', () => {
136+
const { adapter } = createAdapter();
137+
const resizes: { id: string; cols: number; rows: number }[] = [];
138+
adapter.onPtyResize((detail) => resizes.push(detail));
139+
140+
adapter.resizePty('nope', 120, 40);
141+
142+
expect(adapter.getPtySize('nope')).toEqual({ cols: 80, rows: 30 });
143+
expect(resizes).toEqual([]);
144+
});
145+
146+
it('clears tracked size on kill', () => {
147+
const { adapter } = createAdapter();
148+
adapter.spawnPty('t1', { cols: 132, rows: 43 });
149+
adapter.killPty('t1');
150+
expect(adapter.getPtySize('t1')).toEqual({ cols: 80, rows: 30 });
151+
});
152+
153+
it('clears input handlers on kill', () => {
154+
const { adapter, dataEvents } = createAdapter();
155+
const handled: string[] = [];
156+
adapter.spawnPty('t1');
157+
adapter.setInputHandler('t1', (data) => handled.push(data));
158+
159+
adapter.writePty('t1', 'before');
160+
adapter.killPty('t1');
161+
adapter.spawnPty('t1');
162+
adapter.writePty('t1', 'after');
163+
164+
expect(handled).toEqual(['before']);
165+
expect(dataEvents).toEqual([{ id: 't1', data: 'after' }]);
166+
});
167+
168+
it('clears input handlers on reset', () => {
169+
const { adapter, dataEvents } = createAdapter();
170+
const handled: string[] = [];
171+
adapter.spawnPty('t1');
172+
adapter.setInputHandler('t1', (data) => handled.push(data));
173+
174+
adapter.reset();
175+
adapter.onPtyData((detail) => dataEvents.push(detail));
176+
adapter.spawnPty('t1');
177+
adapter.writePty('t1', 'after-reset');
178+
179+
expect(handled).toEqual([]);
180+
expect(dataEvents).toEqual([{ id: 't1', data: 'after-reset' }]);
98181
});
99182

100183
// --- Scenario Playback (Story 11.2) ---

lib/src/lib/platform/fake-adapter.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,24 @@ export interface FakeScenario {
77
exitCode?: number;
88
}
99

10+
export interface FakePtySize {
11+
cols: number;
12+
rows: number;
13+
}
14+
15+
export interface FakePtyResizeDetail extends FakePtySize {
16+
id: string;
17+
}
18+
19+
const DEFAULT_PTY_SIZE: FakePtySize = { cols: 80, rows: 30 };
20+
1021
export class FakePtyAdapter implements PlatformAdapter {
1122
private dataHandlers = new Set<(detail: { id: string; data: string }) => void>();
1223
private exitHandlers = new Set<(detail: { id: string; exitCode: number }) => void>();
24+
private resizeHandlers = new Set<(detail: FakePtyResizeDetail) => void>();
1325
private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>();
1426
private terminals = new Set<string>();
27+
private terminalSizes = new Map<string, FakePtySize>();
1528
private activeTimers = new Map<string, ReturnType<typeof setTimeout>[]>();
1629
private defaultScenario: FakeScenario | null = null;
1730
private scenarioMap = new Map<string, FakeScenario>();
@@ -53,10 +66,13 @@ export class FakePtyAdapter implements PlatformAdapter {
5366
}
5467
this.activeTimers.clear();
5568
this.terminals.clear();
69+
this.terminalSizes.clear();
5670
this.defaultScenario = null;
5771
this.scenarioMap.clear();
5872
this.dataHandlers.clear();
5973
this.exitHandlers.clear();
74+
this.resizeHandlers.clear();
75+
this.inputHandlers.clear();
6076
this.alertManager.dispose();
6177
this.alertManager = new AlertManager();
6278
this.alertManager.onStateChange((id, state) => {
@@ -70,8 +86,12 @@ export class FakePtyAdapter implements PlatformAdapter {
7086
return [{ name: 'fake-shell', path: '/bin/fake', args: [] }];
7187
}
7288

73-
spawnPty(id: string): void {
89+
spawnPty(id: string, options?: { cols?: number; rows?: number }): void {
7490
this.terminals.add(id);
91+
this.terminalSizes.set(id, {
92+
cols: options?.cols ?? DEFAULT_PTY_SIZE.cols,
93+
rows: options?.rows ?? DEFAULT_PTY_SIZE.rows,
94+
});
7595
const scenario = this.scenarioMap.get(id) ?? this.defaultScenario;
7696
if (scenario) {
7797
this.playScenario(id, scenario);
@@ -94,7 +114,16 @@ export class FakePtyAdapter implements PlatformAdapter {
94114
}
95115
}
96116

97-
resizePty(_id: string, _cols: number, _rows: number): void {}
117+
resizePty(id: string, cols: number, rows: number): void {
118+
if (!this.terminals.has(id)) return;
119+
const next = { cols, rows };
120+
const prev = this.terminalSizes.get(id);
121+
if (prev?.cols === cols && prev.rows === rows) return;
122+
this.terminalSizes.set(id, next);
123+
for (const handler of this.resizeHandlers) {
124+
handler({ id, ...next });
125+
}
126+
}
98127

99128
killPty(id: string): void {
100129
const timers = this.activeTimers.get(id);
@@ -103,6 +132,8 @@ export class FakePtyAdapter implements PlatformAdapter {
103132
this.activeTimers.delete(id);
104133
}
105134
this.terminals.delete(id);
135+
this.terminalSizes.delete(id);
136+
this.inputHandlers.delete(id);
106137
for (const handler of this.exitHandlers) {
107138
handler({ id, exitCode: 0 });
108139
}
@@ -127,6 +158,10 @@ export class FakePtyAdapter implements PlatformAdapter {
127158
async getCwd(_id: string): Promise<string | null> { return null; }
128159
async getScrollback(_id: string): Promise<string | null> { return null; }
129160

161+
getPtySize(id: string): FakePtySize {
162+
return this.terminalSizes.get(id) ?? DEFAULT_PTY_SIZE;
163+
}
164+
130165
async readClipboardFilePaths(): Promise<string[] | null> { return null; }
131166
async readClipboardImageAsFilePath(): Promise<string | null> { return null; }
132167

@@ -135,6 +170,12 @@ export class FakePtyAdapter implements PlatformAdapter {
135170
offPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {}
136171
onPtyReplay(_handler: (detail: { id: string; data: string }) => void): void {}
137172
offPtyReplay(_handler: (detail: { id: string; data: string }) => void): void {}
173+
onPtyResize(handler: (detail: FakePtyResizeDetail) => void): () => void {
174+
this.resizeHandlers.add(handler);
175+
return () => {
176+
this.resizeHandlers.delete(handler);
177+
};
178+
}
138179
onRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {}
139180
offRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {}
140181
notifySessionFlushComplete(_requestId: string): void {}

0 commit comments

Comments
 (0)