diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 11f915c..f54b407 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -10,7 +10,7 @@ At the `/playground` route on the website. **Status: Implemented** (Epics 14, 15 ### Implementation -- `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. +- `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. - `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. - `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. - `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 The sandbox starts pre-populated — not empty. Scenarios assigned via `FakePtyAdapter.setScenario()` before Wall mounts: -- **Pane 1** (`tut-main`, left, ~60%): `SCENARIO_TUTORIAL_MOTD` — MOTD welcome message + shell prompt. `TutorialShell` handles all input via `FakePtyAdapter.setInputHandler()`. -- **Pane 2** (`tut-npm`, right-top, ~40%): `SCENARIO_LONG_RUNNING` — `npm install` with progress dots. +- **Pane 1** (`tut-main`, left, ~60%): `SCENARIO_TUTORIAL_MOTD` — MOTD welcome message + shell prompt. +- **Pane 2** (`tut-npm`, right-top, ~40%): `SCENARIO_LONG_RUNNING` — `npm install` with progress dots, then returns to the shell prompt. - **Pane 3** (`tut-ls`, right-bottom): `SCENARIO_LS_OUTPUT` — `ls -la` output with a prompt. The two right-side panes are added in `onApiReady` with `position: { referencePanel, direction }` after Wall creates the initial main pane. -## The `tut` Command +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. + +## Playground Shell Commands Implemented in `website/src/lib/tutorial-shell.ts` (`TutorialShell` class). @@ -34,9 +36,33 @@ The fake terminal accepts these inputs: - **`tut`** — Shows the current tutorial step (or the next incomplete one). Does NOT show the full checklist upfront. - **`tut status`** — Shows all 6 steps with `[x]`/`[ ]` completion markers, grouped by phase. - **`tut reset`** — Clears localStorage progress and confirms. -- **Anything else** — `Unknown command. Type tut to start the tutorial.` +- **`ascii-splash` / `splash`** — Launches the browser playground runner for `ascii-splash@0.3.0`. +- **Anything else** — `Unknown command. Type tut or ascii-splash.` + +`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()`. + +### `ascii-splash` + +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. + +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: + +- Renderer output is ANSI bytes sent through `FakePtyAdapter.sendOutput()`. +- Keyboard and SGR mouse bytes from `FakePtyAdapter.writePty()` are decoded and routed to the upstream command/pattern controls. +- Resize events come from `FakePtyAdapter.onPtyResize()`. +- Start/cleanup uses xterm alt-screen, cursor visibility, and mouse-reporting control sequences. + +Supported CLI options in the playground runner: + +- `--pattern` / `-p` +- `--quality` / `-q` +- `--fps` / `-f` +- `--theme` / `-t` +- `--no-mouse` +- `--help` / `-h` +- `--version` / `-V` -`TutorialShell` provides full line editing (character echo, backspace) and parses commands on Enter. Output goes through `FakePtyAdapter.sendOutput()`. +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. ### Cold Start @@ -142,12 +168,14 @@ The picker restores the persisted active theme on mount. The playground header i - All progress keyed as `mouseterm-tutorial-step-N` in localStorage (values: `'true'`). - `FakePtyAdapter` extensions: `setInputHandler(id, fn)` routes `writePty` calls to a custom handler; `sendOutput(id, data)` writes to a terminal's output stream. +- `PlaygroundShellRegistry` creates one `TutorialShell` per pane id, clears input handlers on disposal, and starts `AsciiSplashRunner` against the pane that launched it. +- `FakePtyAdapter` also tracks fake PTY dimensions from `spawnPty()` / `resizePty()`, exposes `getPtySize(id)`, and provides `onPtyResize(fn)` for browser-side fake programs such as `AsciiSplashRunner`. - `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`). - `SCENARIO_TUTORIAL_MOTD` scenario added to `lib/src/lib/platform/fake-scenarios.ts`. ## Mouse and Clipboard Feature Coverage -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. +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. ### Current state @@ -155,13 +183,13 @@ Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable. | Spec § | Feature | Status | Why | |---|---|---|---| -| §1 | Mouse icon visible when program requests reporting | ❌ | No scenario emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h`. | -| §2 | Temporary/permanent override, banner, Make-permanent / Cancel | ❌ | Blocked on §1. | +| §1 | Mouse icon visible when program requests reporting | ✅ | Run `ascii-splash`; the runner emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h` unless `--no-mouse` is used. | +| §2 | Temporary/permanent override, banner, Make-permanent / Cancel | ✅ | Run `ascii-splash`, then use the header mouse icon while the animation is active. | | §3.1–§3.3 | Drag, Alt-block shape, "Hold Alt" hint | ✅ | Works on any visible text. | | §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`. | -| §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. | +| §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. | | §3.5 | Scrollback-origin / cross-boundary drags | ⚠️ | Scrollback is too short to exercise. | -| §3.6 | Keyboard routing during drag | ⚠️ | Works, but hard to observe — no program in Playground reacts to dropped keystrokes. | +| §3.6 | Keyboard routing during drag | ✅ | `ascii-splash` reacts to keys and mouse; with override active, drag-time keyboard consumption is observable. | | §3.7 | Popup on mouse-up, new-drag-replaces | ✅ | Any selection. | | §4.1.1 | Copy Raw | ✅ | Any selection. | | §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. ### Remediation plan -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. +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. -1. **`SCENARIO_MOUSE_TUI`** — closes §1, §2, §8.5. - 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~`. +1. **`SCENARIO_BRACKETED_PASTE_TUI`** — closes §8.5. + 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~`. 2. **`SCENARIO_SMART_TOKENS`** — closes §3.3 extension hint, §5.1–§5.3. 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 Dragging across any of them shows "Press e to select the full URL/path" and `e` extends. -3. **`SCENARIO_BOXED_OUTPUT`** — closes §4.1.2 and §3.4. +3. **`SCENARIO_BOXED_OUTPUT`** — closes §4.1.2. 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. -**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. +**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. **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`. diff --git a/lib/src/lib/platform/fake-adapter.test.ts b/lib/src/lib/platform/fake-adapter.test.ts index 34593cb..f9d4981 100644 --- a/lib/src/lib/platform/fake-adapter.test.ts +++ b/lib/src/lib/platform/fake-adapter.test.ts @@ -90,11 +90,94 @@ describe('FakePtyAdapter', () => { expect(events2).toEqual(['x', 'y']); }); - it('resizePty is a no-op', () => { + it('tracks default PTY size on spawn', () => { const { adapter } = createAdapter(); adapter.spawnPty('t1'); - // Should not throw + expect(adapter.getPtySize('t1')).toEqual({ cols: 80, rows: 30 }); + }); + + it('tracks requested PTY size on spawn', () => { + const { adapter } = createAdapter(); + adapter.spawnPty('t1', { cols: 132, rows: 43 }); + expect(adapter.getPtySize('t1')).toEqual({ cols: 132, rows: 43 }); + }); + + it('tracks resizePty and notifies resize subscribers', () => { + const { adapter } = createAdapter(); + const resizes: { id: string; cols: number; rows: number }[] = []; + adapter.spawnPty('t1'); + adapter.onPtyResize((detail) => resizes.push(detail)); + + adapter.resizePty('t1', 120, 40); + adapter.resizePty('t1', 120, 40); + adapter.resizePty('t1', 121, 40); + + expect(adapter.getPtySize('t1')).toEqual({ cols: 121, rows: 40 }); + expect(resizes).toEqual([ + { id: 't1', cols: 120, rows: 40 }, + { id: 't1', cols: 121, rows: 40 }, + ]); + }); + + it('unsubscribes resize subscribers', () => { + const { adapter } = createAdapter(); + const resizes: { id: string; cols: number; rows: number }[] = []; + adapter.spawnPty('t1'); + const unsubscribe = adapter.onPtyResize((detail) => resizes.push(detail)); + adapter.resizePty('t1', 120, 40); + unsubscribe(); + adapter.resizePty('t1', 121, 41); + + expect(resizes).toEqual([{ id: 't1', cols: 120, rows: 40 }]); + }); + + it('ignores resizePty for non-spawned terminals', () => { + const { adapter } = createAdapter(); + const resizes: { id: string; cols: number; rows: number }[] = []; + adapter.onPtyResize((detail) => resizes.push(detail)); + + adapter.resizePty('nope', 120, 40); + + expect(adapter.getPtySize('nope')).toEqual({ cols: 80, rows: 30 }); + expect(resizes).toEqual([]); + }); + + it('clears tracked size on kill', () => { + const { adapter } = createAdapter(); + adapter.spawnPty('t1', { cols: 132, rows: 43 }); + adapter.killPty('t1'); + expect(adapter.getPtySize('t1')).toEqual({ cols: 80, rows: 30 }); + }); + + it('clears input handlers on kill', () => { + const { adapter, dataEvents } = createAdapter(); + const handled: string[] = []; + adapter.spawnPty('t1'); + adapter.setInputHandler('t1', (data) => handled.push(data)); + + adapter.writePty('t1', 'before'); + adapter.killPty('t1'); + adapter.spawnPty('t1'); + adapter.writePty('t1', 'after'); + + expect(handled).toEqual(['before']); + expect(dataEvents).toEqual([{ id: 't1', data: 'after' }]); + }); + + it('clears input handlers on reset', () => { + const { adapter, dataEvents } = createAdapter(); + const handled: string[] = []; + adapter.spawnPty('t1'); + adapter.setInputHandler('t1', (data) => handled.push(data)); + + adapter.reset(); + adapter.onPtyData((detail) => dataEvents.push(detail)); + adapter.spawnPty('t1'); + adapter.writePty('t1', 'after-reset'); + + expect(handled).toEqual([]); + expect(dataEvents).toEqual([{ id: 't1', data: 'after-reset' }]); }); // --- Scenario Playback (Story 11.2) --- diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 00170da..9b18dc4 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -7,11 +7,24 @@ export interface FakeScenario { exitCode?: number; } +export interface FakePtySize { + cols: number; + rows: number; +} + +export interface FakePtyResizeDetail extends FakePtySize { + id: string; +} + +const DEFAULT_PTY_SIZE: FakePtySize = { cols: 80, rows: 30 }; + export class FakePtyAdapter implements PlatformAdapter { private dataHandlers = new Set<(detail: { id: string; data: string }) => void>(); private exitHandlers = new Set<(detail: { id: string; exitCode: number }) => void>(); + private resizeHandlers = new Set<(detail: FakePtyResizeDetail) => void>(); private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); private terminals = new Set(); + private terminalSizes = new Map(); private activeTimers = new Map[]>(); private defaultScenario: FakeScenario | null = null; private scenarioMap = new Map(); @@ -53,10 +66,13 @@ export class FakePtyAdapter implements PlatformAdapter { } this.activeTimers.clear(); this.terminals.clear(); + this.terminalSizes.clear(); this.defaultScenario = null; this.scenarioMap.clear(); this.dataHandlers.clear(); this.exitHandlers.clear(); + this.resizeHandlers.clear(); + this.inputHandlers.clear(); this.alertManager.dispose(); this.alertManager = new AlertManager(); this.alertManager.onStateChange((id, state) => { @@ -70,8 +86,12 @@ export class FakePtyAdapter implements PlatformAdapter { return [{ name: 'fake-shell', path: '/bin/fake', args: [] }]; } - spawnPty(id: string): void { + spawnPty(id: string, options?: { cols?: number; rows?: number }): void { this.terminals.add(id); + this.terminalSizes.set(id, { + cols: options?.cols ?? DEFAULT_PTY_SIZE.cols, + rows: options?.rows ?? DEFAULT_PTY_SIZE.rows, + }); const scenario = this.scenarioMap.get(id) ?? this.defaultScenario; if (scenario) { this.playScenario(id, scenario); @@ -94,7 +114,16 @@ export class FakePtyAdapter implements PlatformAdapter { } } - resizePty(_id: string, _cols: number, _rows: number): void {} + resizePty(id: string, cols: number, rows: number): void { + if (!this.terminals.has(id)) return; + const next = { cols, rows }; + const prev = this.terminalSizes.get(id); + if (prev?.cols === cols && prev.rows === rows) return; + this.terminalSizes.set(id, next); + for (const handler of this.resizeHandlers) { + handler({ id, ...next }); + } + } killPty(id: string): void { const timers = this.activeTimers.get(id); @@ -103,6 +132,8 @@ export class FakePtyAdapter implements PlatformAdapter { this.activeTimers.delete(id); } this.terminals.delete(id); + this.terminalSizes.delete(id); + this.inputHandlers.delete(id); for (const handler of this.exitHandlers) { handler({ id, exitCode: 0 }); } @@ -127,6 +158,10 @@ export class FakePtyAdapter implements PlatformAdapter { async getCwd(_id: string): Promise { return null; } async getScrollback(_id: string): Promise { return null; } + getPtySize(id: string): FakePtySize { + return this.terminalSizes.get(id) ?? DEFAULT_PTY_SIZE; + } + async readClipboardFilePaths(): Promise { return null; } async readClipboardImageAsFilePath(): Promise { return null; } @@ -135,6 +170,12 @@ export class FakePtyAdapter implements PlatformAdapter { offPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {} onPtyReplay(_handler: (detail: { id: string; data: string }) => void): void {} offPtyReplay(_handler: (detail: { id: string; data: string }) => void): void {} + onPtyResize(handler: (detail: FakePtyResizeDetail) => void): () => void { + this.resizeHandlers.add(handler); + return () => { + this.resizeHandlers.delete(handler); + }; + } onRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {} offRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {} notifySessionFlushComplete(_requestId: string): void {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 169243c..e3ede9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + ascii-splash: + specifier: 0.3.0 + version: 0.3.0 mouseterm-lib: specifier: workspace:* version: link:../lib @@ -230,6 +233,9 @@ importers: vite-react-ssg: specifier: ^0.8.9 version: 0.8.9(react-dom@19.2.4(react@19.2.4))(react-router-dom@6.30.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + vitest: + specifier: ^4.1.1 + version: 4.1.1(jsdom@29.0.2)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) packages: @@ -375,6 +381,9 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@cronvel/get-pixels@3.4.1': + resolution: {integrity: sha512-gB5C5nDIacLUdsMuW8YsM9SzK3vaFANe4J11CVXpovpy7bZUGrcJKmc6m/0gWG789pKr6XSZY2aEetjFvSRw5g==} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -1646,6 +1655,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -1679,6 +1696,11 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + ascii-splash@0.3.0: + resolution: {integrity: sha512-wpZEh1o/BwWmcvjyfzhce5E282v7tf2/imC5eoj08Hj21oc02qvGLhqdNAEmEFtdeVBqPGSxHCEk80FooJmw6A==} + engines: {node: '>=20.0.0'} + hasBin: true + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1694,6 +1716,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomically@2.1.1: + resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + azure-devops-node-api@12.5.0: resolution: {integrity: sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==} @@ -1798,6 +1823,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chroma-js@2.6.0: + resolution: {integrity: sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==} + chromatic@15.3.0: resolution: {integrity: sha512-ficw/Pz9OpBnPoWDRmuwFDwzLPSN0o90x6X+0+rbnMFYtDTPWXddW6R14jQ56SgYSByJ67OyHZg2gW6U6HF2Qw==} hasBin: true @@ -1840,6 +1868,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@6.2.1: resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} engines: {node: '>= 6'} @@ -1847,6 +1879,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + conf@15.1.0: + resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==} + engines: {node: '>=20'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1875,6 +1911,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cwise-compiler@1.1.3: + resolution: {integrity: sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -1883,6 +1922,10 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debounce-fn@6.0.0: + resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1973,6 +2016,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-prop@10.1.0: + resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} + engines: {node: '>=20'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2016,6 +2063,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -2268,6 +2319,12 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iota-array@1.0.0: + resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} + + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + is-ci@2.0.0: resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} hasBin: true @@ -2328,6 +2385,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2361,6 +2421,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2388,6 +2451,10 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + lazyness@1.2.0: + resolution: {integrity: sha512-KenL6EFbwxBwRxG93t0gcUyi0Nw0Ub31FJKN1laA4UscdkL1K1AxUd0gYZdcLU3v+x+wcFi4uQKS5hL+fk500g==} + engines: {node: '>=6.0.0'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -2559,6 +2626,10 @@ packages: engines: {node: '>=4'} hasBin: true + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -2598,6 +2669,16 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + ndarray-pack@1.2.1: + resolution: {integrity: sha512-51cECUJMT0rUZNQa09EoKsnFeDL4x2dHRT0VR5U2H5ZgEcm95ZDWcMA5JShroXjHOejmAD/fg8+H+OvUnVXz2g==} + + ndarray@1.0.19: + resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==} + + nextgen-events@1.5.3: + resolution: {integrity: sha512-P6qw6kenNXP+J9XlKJNi/MNHUQ+Lx5K8FEcSfX7/w8KJdZan5+BB5MKzuNgL2RTjHG1Svg8SehfseVEp8zAqwA==} + engines: {node: '>=6.0.0'} + node-abi@3.89.0: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} engines: {node: '>=10'} @@ -2608,6 +2689,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-bitmap@0.0.1: + resolution: {integrity: sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==} + engines: {node: '>=v0.6.5'} + node-pty@1.2.0-beta.12: resolution: {integrity: sha512-uExTCG/4VmSJa4+TjxFwPXv8BfacmfFEBL6JpxCMDghcwqzvD0yTcGmZ1fKOK6HY33tp0CelLblqTECJizc+Yw==} @@ -2643,6 +2728,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + omggif@1.0.10: + resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2732,6 +2820,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -2919,6 +3011,13 @@ packages: engines: {node: '>=10'} hasBin: true + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + seventh@0.9.4: + resolution: {integrity: sha512-O85mosi4sOfxG+slvqy0j7zLuFD4ylUgEMt7Pvt9Q/wnwNwG/6MNnHKzV9JkAoPoPM26t/DLFn17p7o7u5kIBA==} + engines: {node: '>=16.13.0'} + shallowequal@1.1.0: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} @@ -2963,6 +3062,9 @@ packages: resolution: {integrity: sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==} engines: {node: '>=10'} + simplex-noise@4.0.3: + resolution: {integrity: sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==} + slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} @@ -3006,6 +3108,10 @@ packages: prettier: optional: true + string-kit@0.19.3: + resolution: {integrity: sha512-94p913R6+Ea6656A39bqJeHgt64HM9RSq36oc0F8N8Mz76g5+cLaqEwkouqguEDrJ4rhVQpS+tKz214FdtMoqA==} + engines: {node: '>=14.15.0'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3040,6 +3146,12 @@ packages: structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3059,6 +3171,10 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -3086,6 +3202,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + terminal-kit@3.1.2: + resolution: {integrity: sha512-ro2FyU4A+NwA74DLTYTnoCFYuFpgV1aM07IS6MPrJeajoI2hwF44EdUqjoTmKEl6srYDWtbVkc/b1C16iUnxFQ==} + engines: {node: '>=16.13.0'} + terminal-link@4.0.0: resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} engines: {node: '>=18'} @@ -3154,6 +3274,10 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + tree-kit@0.8.10: + resolution: {integrity: sha512-mwuV3lHL+utI9z7vxah/27wrMJprx925xhkw1N4KuGa1dqIi0DLHWfXJpHEyR+ZI0Ij80zN58ztiGp3KlH0wtw==} + engines: {node: '>=16.13.0'} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -3176,6 +3300,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + typed-rest-client@1.8.11: resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} @@ -3187,6 +3315,10 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} @@ -3202,6 +3334,9 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + uniq@1.0.1: + resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==} + universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} @@ -3386,6 +3521,9 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3685,6 +3823,15 @@ snapshots: dependencies: css-tree: 3.2.1 + '@cronvel/get-pixels@3.4.1': + dependencies: + jpeg-js: 0.4.4 + ndarray: 1.0.19 + ndarray-pack: 1.2.1 + node-bitmap: 0.0.1 + omggif: 1.0.10 + pngjs: 6.0.0 + '@csstools/color-helpers@5.1.0': {} '@csstools/color-helpers@6.0.2': {} @@ -4706,6 +4853,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -4735,6 +4886,13 @@ snapshots: aria-query@5.3.2: {} + ascii-splash@0.3.0: + dependencies: + commander: 14.0.3 + conf: 15.1.0 + simplex-noise: 4.0.3 + terminal-kit: 3.1.2 + assertion-error@2.0.1: {} ast-types@0.16.1: @@ -4745,6 +4903,11 @@ snapshots: asynckit@0.4.0: {} + atomically@2.1.1: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + azure-devops-node-api@12.5.0: dependencies: tunnel: 0.0.6 @@ -4870,6 +5033,8 @@ snapshots: chownr@1.1.4: optional: true + chroma-js@2.6.0: {} + chromatic@15.3.0: {} ci-info@2.0.0: {} @@ -4896,10 +5061,24 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + commander@6.2.1: {} concat-map@0.0.1: {} + conf@15.1.0: + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + atomically: 2.1.1 + debounce-fn: 6.0.0 + dot-prop: 10.1.0 + env-paths: 3.0.0 + json-schema-typed: 8.0.2 + semver: 7.7.4 + uint8array-extras: 1.5.0 + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -4932,6 +5111,10 @@ snapshots: csstype@3.2.3: {} + cwise-compiler@1.1.3: + dependencies: + uniq: 1.0.1 + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -4944,6 +5127,10 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + debounce-fn@6.0.0: + dependencies: + mimic-function: 5.0.1 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5026,6 +5213,10 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dot-prop@10.1.0: + dependencies: + type-fest: 5.6.0 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5067,6 +5258,8 @@ snapshots: entities@7.0.1: {} + env-paths@3.0.0: {} + environment@1.1.0: {} es-define-property@1.0.1: {} @@ -5366,6 +5559,10 @@ snapshots: dependencies: loose-envify: 1.4.0 + iota-array@1.0.0: {} + + is-buffer@1.1.6: {} + is-ci@2.0.0: dependencies: ci-info: 2.0.0 @@ -5414,6 +5611,8 @@ snapshots: jiti@2.6.1: {} + jpeg-js@0.4.4: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -5478,6 +5677,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -5520,6 +5721,8 @@ snapshots: kolorist@1.8.0: {} + lazyness@1.2.0: {} + leven@3.1.0: {} lightningcss-android-arm64@1.32.0: @@ -5647,6 +5850,8 @@ snapshots: mime@1.6.0: {} + mimic-function@5.0.1: {} + mimic-response@3.1.0: optional: true @@ -5676,6 +5881,18 @@ snapshots: napi-build-utils@2.0.0: optional: true + ndarray-pack@1.2.1: + dependencies: + cwise-compiler: 1.1.3 + ndarray: 1.0.19 + + ndarray@1.0.19: + dependencies: + iota-array: 1.0.0 + is-buffer: 1.1.6 + + nextgen-events@1.5.3: {} + node-abi@3.89.0: dependencies: semver: 7.7.4 @@ -5686,6 +5903,8 @@ snapshots: node-addon-api@7.1.1: {} + node-bitmap@0.0.1: {} + node-pty@1.2.0-beta.12: dependencies: node-addon-api: 7.1.1 @@ -5717,6 +5936,8 @@ snapshots: obug@2.1.1: {} + omggif@1.0.10: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5808,6 +6029,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@6.0.0: {} + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -6052,6 +6275,12 @@ snapshots: semver@7.7.4: {} + setimmediate@1.0.5: {} + + seventh@0.9.4: + dependencies: + setimmediate: 1.0.5 + shallowequal@1.1.0: {} shebang-command@2.0.0: @@ -6104,6 +6333,8 @@ snapshots: simple-invariant@2.0.1: {} + simplex-noise@4.0.3: {} + slash@5.1.0: {} slice-ansi@4.0.0: @@ -6155,6 +6386,8 @@ snapshots: - react-dom - utf-8-validate + string-kit@0.19.3: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -6189,6 +6422,12 @@ snapshots: dependencies: boundary: 2.0.0 + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6210,6 +6449,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tagged-tag@1.0.0: {} + tailwind-merge@3.5.0: {} tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2): @@ -6239,6 +6480,17 @@ snapshots: readable-stream: 3.6.2 optional: true + terminal-kit@3.1.2: + dependencies: + '@cronvel/get-pixels': 3.4.1 + chroma-js: 2.6.0 + lazyness: 1.2.0 + ndarray: 1.0.19 + nextgen-events: 1.5.3 + seventh: 0.9.4 + string-kit: 0.19.3 + tree-kit: 0.8.10 + terminal-link@4.0.0: dependencies: ansi-escapes: 7.3.0 @@ -6298,6 +6550,8 @@ snapshots: dependencies: punycode: 2.3.1 + tree-kit@0.8.10: {} + ts-dedent@2.2.0: {} tsconfig-paths@4.2.0: @@ -6317,6 +6571,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + typed-rest-client@1.8.11: dependencies: qs: 6.15.0 @@ -6327,6 +6585,8 @@ snapshots: uc.micro@2.1.0: {} + uint8array-extras@1.5.0: {} + underscore@1.13.8: {} undici@7.24.5: {} @@ -6335,6 +6595,8 @@ snapshots: unicorn-magic@0.3.0: {} + uniq@1.0.1: {} + universalify@0.2.0: {} universalify@2.0.1: {} @@ -6466,6 +6728,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + when-exit@2.1.5: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/website/package.json b/website/package.json index 12b03ac..e4a5d78 100644 --- a/website/package.json +++ b/website/package.json @@ -8,10 +8,12 @@ "dev": "vite-react-ssg dev", "prebuild": "node scripts/generate-deps.js", "build": "vite-react-ssg build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@phosphor-icons/react": "^2.1.10", + "ascii-splash": "0.3.0", "mouseterm-lib": "workspace:*", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -25,6 +27,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.9.0", "vite": "^7.3.0", - "vite-react-ssg": "^0.8.9" + "vite-react-ssg": "^0.8.9", + "vitest": "^4.1.1" } } diff --git a/website/scripts/generate-deps.js b/website/scripts/generate-deps.js index 05684f8..11a1c5a 100644 --- a/website/scripts/generate-deps.js +++ b/website/scripts/generate-deps.js @@ -1,5 +1,5 @@ -import { execSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -7,9 +7,36 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "../.."); const outPath = resolve(__dirname, "../src/data/dependencies.json"); const themeExtensionsPath = resolve(repoRoot, "lib/src/lib/themes/bundled-extensions.json"); +const productDependencyFilters = [ + "mouseterm", + "mouseterm-standalone", + "mouseterm-lib", +]; +function getInstalledStoreDir() { + if (process.env.PNPM_STORE_DIR) return process.env.PNPM_STORE_DIR; + try { + const modulesYaml = readFileSync(resolve(repoRoot, "node_modules/.modules.yaml"), "utf-8"); + return modulesYaml.match(/"storeDir":\s*"([^"]+)"/)?.[1] ?? null; + } catch { + // Codex worktrees often do not have node_modules, but the shared pnpm + // store is still present under PNPM_HOME. + const pnpmHomeStore = process.env.PNPM_HOME ? resolve(process.env.PNPM_HOME, "store/v10") : null; + return pnpmHomeStore && existsSync(pnpmHomeStore) ? pnpmHomeStore : null; + } +} + +const storeDir = getInstalledStoreDir(); +const licenseArgs = [ + ...(storeDir ? [`--config.store-dir=${storeDir}`] : []), + ...productDependencyFilters.flatMap((filter) => ["--filter", filter]), + "licenses", + "list", + "--prod", + "--json", +]; const raw = JSON.parse( - execSync("pnpm licenses list --prod --json", { cwd: repoRoot, encoding: "utf-8" }) + execFileSync("pnpm", licenseArgs, { cwd: repoRoot, encoding: "utf-8" }) ); const licenseAliases = { @@ -61,11 +88,16 @@ const missingAuthor = { "@tauri-apps/plugin-shell": "Tauri Apps Contributors", "@tauri-apps/plugin-updater": "Tauri Apps Contributors", "@xterm/xterm": "Christopher Jeffrey, SourceLair Private Company, xterm.js authors", + "atomically": "Fabio Spampinato", "node-addon-api": "Node.js API collaborators", + "pngjs": "pngjs contributors", "react": "Meta Platforms, Inc. and affiliates", "react-dom": "Meta Platforms, Inc. and affiliates", "scheduler": "Meta Platforms, Inc. and affiliates", + "stubborn-fs": "Fabio Spampinato", + "stubborn-utils": "Fabio Spampinato", "tailwindcss": "Tailwind Labs, Inc.", + "when-exit": "Fabio Spampinato", }; for (const dep of deps) { if (!dep.license) { diff --git a/website/src/data/dependencies.json b/website/src/data/dependencies.json index 1ea551d..9550101 100644 --- a/website/src/data/dependencies.json +++ b/website/src/data/dependencies.json @@ -6,13 +6,6 @@ "author": "Tobias Fried", "homepage": "https://phosphoricons.com" }, - { - "name": "@remix-run/router", - "version": "1.23.2", - "license": "MIT", - "author": "Remix Software", - "homepage": "https://github.com/remix-run/react-router#readme" - }, { "name": "@tauri-apps/api", "version": "2.10.1", @@ -118,20 +111,6 @@ "author": "Meta Platforms, Inc. and affiliates", "homepage": "https://react.dev/" }, - { - "name": "react-router", - "version": "6.30.3", - "license": "MIT", - "author": "Remix Software", - "homepage": "https://github.com/remix-run/react-router#readme" - }, - { - "name": "react-router-dom", - "version": "6.30.3", - "license": "MIT", - "author": "Remix Software", - "homepage": "https://github.com/remix-run/react-router#readme" - }, { "name": "scheduler", "version": "0.27.0", diff --git a/website/src/lib/ascii-splash-runner.test.ts b/website/src/lib/ascii-splash-runner.test.ts new file mode 100644 index 0000000..439d9e4 --- /dev/null +++ b/website/src/lib/ascii-splash-runner.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; +import { AsciiSplashRunner } from "./ascii-splash-runner"; + +function createHarness(args: string[] = []) { + const adapter = new FakePtyAdapter(); + const output: string[] = []; + const onExit = vi.fn(); + adapter.onPtyData((detail) => { + if (detail.id === "splash") output.push(detail.data); + }); + adapter.spawnPty("splash", { cols: 40, rows: 12 }); + const runner = new AsciiSplashRunner({ + adapter, + terminalId: "splash", + args, + onExit, + }); + return { adapter, output, onExit, runner }; +} + +function stripAnsi(data: string): string { + return data.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function getRunnerState(runner: AsciiSplashRunner) { + return runner as unknown as { + commandExecutor: { patterns: unknown[] } | null; + currentPatternIndex: number; + engine: { getPattern(): unknown } | null; + patterns: unknown[]; + }; +} + +describe("AsciiSplashRunner", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("starts in alt-screen with mouse reporting enabled by default", () => { + const { output, runner } = createHarness(); + + runner.start(); + + const data = output.join(""); + expect(data).toContain("\x1b[?1049h"); + expect(data).toContain("\x1b[?25l"); + expect(data).toContain("\x1b[?1003h"); + expect(data).toContain("\x1b[?1006h"); + + runner.dispose(); + }); + + it("can start without mouse reporting", () => { + const { output, runner } = createHarness(["--no-mouse"]); + + runner.start(); + + const data = output.join(""); + expect(data).toContain("\x1b[?1049h"); + expect(data).not.toContain("\x1b[?1003h"); + expect(data).not.toContain("\x1b[?1006h"); + + runner.dispose(); + }); + + it("renders frames with the real upstream animation engine", () => { + const { output, runner } = createHarness(["--pattern", "waves", "--fps", "30"]); + runner.start(); + output.length = 0; + + vi.advanceTimersByTime(40); + + const data = output.join(""); + expect(data).toContain("\x1b[1;"); + expect(data).toContain("\x1b[38;2;"); + + runner.dispose(); + }); + + it("handles resize notifications before rendering the next frame", () => { + const { adapter, output, runner } = createHarness(); + runner.start(); + output.length = 0; + + adapter.resizePty("splash", 24, 8); + vi.advanceTimersByTime(40); + + const data = output.join(""); + expect(data).toContain("\x1b[2J\x1b[H"); + expect(data).toContain("\x1b[8;"); + + runner.dispose(); + }); + + it("parses SGR mouse input without leaving the byte stream", () => { + const { output, runner } = createHarness(["--pattern", "waves"]); + runner.start(); + output.length = 0; + + runner.handleInput("\x1b[<35;10;5M"); + vi.advanceTimersByTime(40); + + expect(output.join("")).toContain("\x1b[38;2;"); + runner.dispose(); + }); + + it("exits cleanly on q", () => { + const { output, onExit, runner } = createHarness(); + runner.start(); + output.length = 0; + + runner.handleInput("q"); + + const data = output.join(""); + expect(data).toContain("\x1b[?1003l"); + expect(data).toContain("\x1b[?1049l"); + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it("prints help and returns to the shell", async () => { + const { output, onExit, runner } = createHarness(["--help"]); + + runner.start(); + await Promise.resolve(); + + expect(output.join("")).toContain("Usage: ascii-splash [options]"); + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it("keeps overlays isolated across simultaneous runner instances", () => { + const adapter = new FakePtyAdapter(); + const outputA: string[] = []; + const outputB: string[] = []; + adapter.onPtyData((detail) => { + if (detail.id === "a") outputA.push(detail.data); + if (detail.id === "b") outputB.push(detail.data); + }); + adapter.spawnPty("a", { cols: 80, rows: 28 }); + adapter.spawnPty("b", { cols: 80, rows: 28 }); + const runnerA = new AsciiSplashRunner({ + adapter, + terminalId: "a", + args: ["--pattern", "waves"], + onExit: vi.fn(), + }); + const runnerB = new AsciiSplashRunner({ + adapter, + terminalId: "b", + args: ["--pattern", "waves"], + onExit: vi.fn(), + }); + + runnerA.start(); + runnerB.start(); + outputA.length = 0; + outputB.length = 0; + + runnerA.handleInput("?"); + vi.advanceTimersByTime(40); + + expect(stripAnsi(outputA.join(""))).toContain("ascii-splash Help"); + expect(stripAnsi(outputB.join(""))).not.toContain("ascii-splash Help"); + + runnerA.dispose(); + runnerB.dispose(); + }); + + it("keeps command executor patterns synced after random pattern and theme commands", () => { + const { runner } = createHarness(["--pattern", "waves"]); + runner.start(); + const targetPatternIndex = 2; + const initialState = getRunnerState(runner); + expect(initialState.patterns.length).toBeGreaterThan(targetPatternIndex); + const random = vi.spyOn(Math, "random").mockReturnValue((targetPatternIndex + 0.1) / initialState.patterns.length); + + try { + runner.handleInput("r"); + + const state = getRunnerState(runner); + expect(state.currentPatternIndex).toBe(targetPatternIndex); + expect(state.engine?.getPattern()).toBe(state.patterns[targetPatternIndex]); + expect(state.commandExecutor?.patterns).toBe(state.patterns); + } finally { + runner.dispose(); + random.mockRestore(); + } + }); +}); diff --git a/website/src/lib/ascii-splash-runner.ts b/website/src/lib/ascii-splash-runner.ts new file mode 100644 index 0000000..716f2fe --- /dev/null +++ b/website/src/lib/ascii-splash-runner.ts @@ -0,0 +1,1056 @@ +/* + * Browser adapter for ascii-splash@0.3.0 in the MouseTerm website playground. + * + * This file is not the upstream CLI entrypoint. It imports upstream internals + * from ascii-splash/dist through the website's `ascii-splash-internal` Vite + * alias, then replaces the Node terminal-kit boundary with a FakePtyAdapter + * runner that speaks xterm byte streams. + * + * Upstream pieces kept: AnimationEngine, Buffer, CommandBuffer, CommandParser, + * CommandExecutor, themes, defaults, UI overlay classes, TransitionManager, and + * all pattern classes. + * + * Local changes/wrapping: + * - BrowserTerminalRenderer writes ANSI output through FakePtyAdapter.sendOutput. + * - Keyboard bytes and SGR mouse sequences are decoded from writePty input. + * - Alt-screen, cursor, mouse-reporting, resize, start, and cleanup lifecycle + * are handled for xterm.js inside MouseTerm. + * - UI overlays/transitions are instantiated per runner instead of using + * upstream singleton getters so multiple panes can run independently. + * - Config persistence is intentionally omitted; upstream commands that need a + * config loader report that it is unavailable. + */ +import { AnimationEngine } from "ascii-splash-internal/engine/AnimationEngine.js"; +import { CommandBuffer } from "ascii-splash-internal/engine/CommandBuffer.js"; +import { CommandExecutor } from "ascii-splash-internal/engine/CommandExecutor.js"; +import { CommandParser } from "ascii-splash-internal/engine/CommandParser.js"; +import { defaultConfig, qualityPresets } from "ascii-splash-internal/config/defaults.js"; +import { getNextThemeName, getTheme, THEME_NAMES, THEMES } from "ascii-splash-internal/config/themes.js"; +import { TransitionManager } from "ascii-splash-internal/renderer/TransitionManager.js"; +import { Buffer as SplashBuffer } from "ascii-splash-internal/renderer/Buffer.js"; +import { HelpOverlay } from "ascii-splash-internal/ui/HelpOverlay.js"; +import { StatusBar } from "ascii-splash-internal/ui/StatusBar.js"; +import { ToastManager } from "ascii-splash-internal/ui/ToastManager.js"; +import { AquariumPattern } from "ascii-splash-internal/patterns/AquariumPattern.js"; +import { CampfirePattern } from "ascii-splash-internal/patterns/CampfirePattern.js"; +import { DNAPattern } from "ascii-splash-internal/patterns/DNAPattern.js"; +import { FireworksPattern } from "ascii-splash-internal/patterns/FireworksPattern.js"; +import { LavaLampPattern } from "ascii-splash-internal/patterns/LavaLampPattern.js"; +import { LifePattern } from "ascii-splash-internal/patterns/LifePattern.js"; +import { LightningPattern } from "ascii-splash-internal/patterns/LightningPattern.js"; +import { MatrixPattern } from "ascii-splash-internal/patterns/MatrixPattern.js"; +import { MazePattern } from "ascii-splash-internal/patterns/MazePattern.js"; +import { MetaballPattern } from "ascii-splash-internal/patterns/MetaballPattern.js"; +import { NightSkyPattern } from "ascii-splash-internal/patterns/NightSkyPattern.js"; +import { OceanBeachPattern } from "ascii-splash-internal/patterns/OceanBeachPattern.js"; +import { ParticlePattern } from "ascii-splash-internal/patterns/ParticlePattern.js"; +import { PlasmaPattern } from "ascii-splash-internal/patterns/PlasmaPattern.js"; +import { QuicksilverPattern } from "ascii-splash-internal/patterns/QuicksilverPattern.js"; +import { RainPattern } from "ascii-splash-internal/patterns/RainPattern.js"; +import { SmokePattern } from "ascii-splash-internal/patterns/SmokePattern.js"; +import { SnowfallParkPattern } from "ascii-splash-internal/patterns/SnowfallParkPattern.js"; +import { SnowPattern } from "ascii-splash-internal/patterns/SnowPattern.js"; +import { SpiralPattern } from "ascii-splash-internal/patterns/SpiralPattern.js"; +import { StarfieldPattern } from "ascii-splash-internal/patterns/StarfieldPattern.js"; +import { TunnelPattern } from "ascii-splash-internal/patterns/TunnelPattern.js"; +import { WavePattern } from "ascii-splash-internal/patterns/WavePattern.js"; +import type { Cell, Color, Pattern, Point, Size, Theme } from "ascii-splash-internal/types/index.js"; +import type { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; +import type { InteractiveProgram } from "./tutorial-shell"; + +type QualityPreset = "low" | "medium" | "high"; + +interface AsciiSplashRunnerOptions { + adapter: FakePtyAdapter; + terminalId: string; + args: string[]; + onExit: () => void; +} + +interface ParsedOptions { + pattern?: string; + quality: QualityPreset; + fps?: number; + theme: string; + mouseEnabled: boolean; + help?: boolean; + version?: boolean; + error?: string; +} + +interface SplashConfig { + defaultPattern?: string; + quality?: QualityPreset; + fps?: number; + theme?: string; + mouseEnabled?: boolean; + patterns?: typeof defaultConfig.patterns; +} + +interface KeyInput { + name: string; + data: { isCharacter: boolean; codepoint?: number }; +} + +const VERSION = "0.3.0"; +const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"; +const MOUSE_DISABLE = "\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l"; +const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l"; +const LEAVE_ALT_SCREEN = "\x1b[2J\x1b[H\x1b[?25h\x1b[?1049l"; + +const PATTERN_NAMES = [ + "waves", + "starfield", + "matrix", + "rain", + "quicksilver", + "particles", + "spiral", + "plasma", + "tunnel", + "lightning", + "fireworks", + "maze", + "life", + "dna", + "lavalamp", + "smoke", + "snow", + "oceanbeach", + "campfire", + "nightsky", + "aquarium", + "snowfallpark", + "metaball", +] as const; + +const PATTERN_DISPLAY_NAMES: Record = { + waves: "Waves", + starfield: "Starfield", + matrix: "Matrix", + rain: "Rain", + quicksilver: "Quicksilver", + particles: "Particles", + spiral: "Spiral", + plasma: "Plasma", + tunnel: "Tunnel", + lightning: "Lightning", + fireworks: "Fireworks", + maze: "Maze", + life: "Life", + dna: "DNA", + lavalamp: "Lava Lamp", + smoke: "Smoke", + snow: "Snow", + oceanbeach: "Ocean Beach", + campfire: "Campfire", + nightsky: "Night Sky", + aquarium: "Aquarium", + snowfallpark: "Snowfall Park", + metaball: "Metaball", +}; + +const ARROW_KEY_NAMES: Record = { A: "UP", B: "DOWN", C: "RIGHT", D: "LEFT" }; + +const OCEANBEACH_PATTERN_INDEX = PATTERN_NAMES.indexOf("oceanbeach"); +const PRESET_COUNT = 6; +const PATTERN_BUFFER_TIMEOUT_MS = 5000; +const PATTERN_SWITCH_GUARD_MS = 16; + +const HELP_TEXT = [ + "Usage: ascii-splash [options]", + "", + "Options:", + " -p, --pattern Starting pattern", + " -q, --quality Quality preset: low, medium, high", + " -f, --fps Custom FPS from 10 to 60", + " -t, --theme Theme: ocean, matrix, starlight, fire, monochrome", + " --no-mouse Disable mouse interaction", + " -h, --help Show help", + " -V, --version Show version", +].join("\r\n"); + +function color(code: number): Color { + return { r: code, g: code, b: code }; +} + +function normalizeSize(size: Size): Size { + return { + width: Math.max(1, Math.floor(size.width)), + height: Math.max(2, Math.floor(size.height)), + }; +} + +function parseOptionValue(args: string[], index: number, raw: string): { value?: string; nextIndex: number; error?: string } { + const eq = raw.indexOf("="); + if (eq >= 0) return { value: raw.slice(eq + 1), nextIndex: index }; + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return { nextIndex: index, error: `Missing value for ${raw}` }; + } + return { value, nextIndex: index + 1 }; +} + +function parseArgs(args: string[]): ParsedOptions { + const parsed: ParsedOptions = { + quality: "medium", + theme: "ocean", + mouseEnabled: true, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help" || arg === "-h") { + parsed.help = true; + } else if (arg === "--version" || arg === "-V") { + parsed.version = true; + } else if (arg === "--no-mouse") { + parsed.mouseEnabled = false; + } else if (arg === "--pattern" || arg === "-p" || arg.startsWith("--pattern=")) { + const result = parseOptionValue(args, i, arg); + if (result.error) return { ...parsed, error: result.error }; + i = result.nextIndex; + parsed.pattern = result.value?.toLowerCase(); + } else if (arg === "--quality" || arg === "-q" || arg.startsWith("--quality=")) { + const result = parseOptionValue(args, i, arg); + if (result.error) return { ...parsed, error: result.error }; + i = result.nextIndex; + parsed.quality = result.value?.toLowerCase() as QualityPreset; + } else if (arg === "--fps" || arg === "-f" || arg.startsWith("--fps=")) { + const result = parseOptionValue(args, i, arg); + if (result.error) return { ...parsed, error: result.error }; + i = result.nextIndex; + parsed.fps = Number(result.value); + } else if (arg === "--theme" || arg === "-t" || arg.startsWith("--theme=")) { + const result = parseOptionValue(args, i, arg); + if (result.error) return { ...parsed, error: result.error }; + i = result.nextIndex; + parsed.theme = result.value?.toLowerCase() ?? parsed.theme; + } else { + return { ...parsed, error: `Unknown option: ${arg}` }; + } + } + + if (parsed.pattern && !PATTERN_NAMES.includes(parsed.pattern as (typeof PATTERN_NAMES)[number])) { + return { ...parsed, error: `Invalid pattern: ${parsed.pattern}` }; + } + if (!["low", "medium", "high"].includes(parsed.quality)) { + return { ...parsed, error: `Invalid quality: ${parsed.quality}` }; + } + if (parsed.fps !== undefined && (!Number.isFinite(parsed.fps) || parsed.fps < 10 || parsed.fps > 60)) { + return { ...parsed, error: `FPS must be a number between 10 and 60` }; + } + if (!THEME_NAMES.includes(parsed.theme)) { + return { ...parsed, error: `Invalid theme: ${parsed.theme}` }; + } + return parsed; +} + +function createPatternsFromConfig(config: SplashConfig, theme: Theme): Pattern[] { + return [ + new WavePattern(theme, { + layers: config.patterns?.waves?.layers, + amplitude: config.patterns?.waves?.amplitude, + speed: config.patterns?.waves?.speed, + frequency: config.patterns?.waves?.frequency, + }), + new StarfieldPattern(theme, { + starCount: config.patterns?.starfield?.starCount, + speed: config.patterns?.starfield?.speed, + }), + new MatrixPattern(theme, { + density: config.patterns?.matrix?.columnDensity, + speed: config.patterns?.matrix?.speed, + }), + new RainPattern(theme, { + density: config.patterns?.rain?.dropCount ? config.patterns.rain.dropCount / 500 : undefined, + speed: config.patterns?.rain?.speed, + }), + new QuicksilverPattern(theme, { + speed: config.patterns?.quicksilver?.speed, + flowIntensity: config.patterns?.quicksilver?.viscosity, + noiseScale: 0.05, + }), + new ParticlePattern(theme, { + particleCount: config.patterns?.particles?.particleCount, + speed: config.patterns?.particles?.speed, + gravity: config.patterns?.particles?.gravity, + mouseForce: config.patterns?.particles?.mouseForce, + spawnRate: config.patterns?.particles?.spawnRate, + }), + new SpiralPattern(theme, { + armCount: config.patterns?.spiral?.armCount, + particleCount: config.patterns?.spiral?.particleCount, + spiralTightness: config.patterns?.spiral?.spiralTightness, + rotationSpeed: config.patterns?.spiral?.rotationSpeed, + particleSpeed: config.patterns?.spiral?.particleSpeed, + trailLength: config.patterns?.spiral?.trailLength, + direction: config.patterns?.spiral?.direction, + pulseEffect: config.patterns?.spiral?.pulseEffect, + }), + new PlasmaPattern(theme, { + frequency: config.patterns?.plasma?.frequency, + speed: config.patterns?.plasma?.speed, + complexity: config.patterns?.plasma?.complexity, + }), + new TunnelPattern(theme, { + shape: config.patterns?.tunnel?.shape, + ringCount: config.patterns?.tunnel?.ringCount, + speed: config.patterns?.tunnel?.speed, + particleCount: config.patterns?.tunnel?.particleCount, + speedLineCount: config.patterns?.tunnel?.speedLineCount, + turbulence: config.patterns?.tunnel?.turbulence, + glowIntensity: config.patterns?.tunnel?.glowIntensity, + chromatic: config.patterns?.tunnel?.chromatic, + rotationSpeed: config.patterns?.tunnel?.rotationSpeed, + radius: config.patterns?.tunnel?.radius, + }), + new LightningPattern(theme, { + branchProbability: config.patterns?.lightning?.branchProbability, + fadeTime: config.patterns?.lightning?.fadeTime, + strikeInterval: config.patterns?.lightning?.strikeInterval, + mainPathJaggedness: config.patterns?.lightning?.mainPathJaggedness, + branchSpread: config.patterns?.lightning?.branchSpread, + }), + new FireworksPattern(theme, { + burstSize: config.patterns?.fireworks?.burstSize, + launchSpeed: config.patterns?.fireworks?.launchSpeed, + gravity: config.patterns?.fireworks?.gravity, + fadeRate: config.patterns?.fireworks?.fadeRate, + spawnInterval: config.patterns?.fireworks?.spawnInterval, + trailLength: config.patterns?.fireworks?.trailLength, + }), + new MazePattern(theme, { + algorithm: config.patterns?.maze?.algorithm, + cellSize: config.patterns?.maze?.cellSize, + generationSpeed: config.patterns?.maze?.generationSpeed, + wallChar: config.patterns?.maze?.wallChar, + pathChar: config.patterns?.maze?.pathChar, + animateGeneration: config.patterns?.maze?.animateGeneration, + }), + new LifePattern(theme, { + cellSize: config.patterns?.life?.cellSize, + updateSpeed: config.patterns?.life?.updateSpeed, + wrapEdges: config.patterns?.life?.wrapEdges, + aliveChar: config.patterns?.life?.aliveChar, + deadChar: config.patterns?.life?.deadChar, + randomDensity: config.patterns?.life?.randomDensity, + initialPattern: config.patterns?.life?.initialPattern, + }), + new DNAPattern(theme, { + rotationSpeed: config.patterns?.dna?.rotationSpeed, + helixRadius: config.patterns?.dna?.helixRadius, + basePairDensity: config.patterns?.dna?.basePairSpacing ? 1 / config.patterns.dna.basePairSpacing : undefined, + twistRate: config.patterns?.dna?.twistRate, + showLabels: true, + }), + new LavaLampPattern(theme, { + blobCount: config.patterns?.lavaLamp?.blobCount, + minRadius: config.patterns?.lavaLamp?.minRadius, + maxRadius: config.patterns?.lavaLamp?.maxRadius, + riseSpeed: config.patterns?.lavaLamp?.riseSpeed, + driftSpeed: config.patterns?.lavaLamp?.driftSpeed, + threshold: config.patterns?.lavaLamp?.threshold, + mouseForce: config.patterns?.lavaLamp?.mouseForce, + turbulence: config.patterns?.lavaLamp?.turbulence, + gravity: config.patterns?.lavaLamp?.gravity, + }), + new SmokePattern(theme, { + plumeCount: config.patterns?.smoke?.plumeCount, + particleCount: config.patterns?.smoke?.particleCount, + riseSpeed: config.patterns?.smoke?.riseSpeed, + dissipationRate: config.patterns?.smoke?.dissipationRate, + turbulence: config.patterns?.smoke?.turbulence, + spread: config.patterns?.smoke?.spread, + windStrength: config.patterns?.smoke?.windStrength, + mouseBlowForce: config.patterns?.smoke?.mouseBlowForce, + }), + new SnowPattern(theme, { + particleCount: config.patterns?.snow?.particleCount, + fallSpeed: config.patterns?.snow?.fallSpeed, + windStrength: config.patterns?.snow?.windStrength, + turbulence: config.patterns?.snow?.turbulence, + rotationSpeed: config.patterns?.snow?.rotationSpeed, + particleType: config.patterns?.snow?.particleType, + accumulation: config.patterns?.snow?.accumulation, + mouseWindForce: config.patterns?.snow?.mouseWindForce, + }), + new OceanBeachPattern(theme, {}), + new CampfirePattern(theme, {}), + new NightSkyPattern(theme, {}), + new AquariumPattern(theme, {}), + new SnowfallParkPattern(theme, {}), + new MetaballPattern(theme, {}), + ]; +} + +function setCell(buffer: Cell[][], x: number, y: number, char: string, colorValue: Color): void { + if (y >= 0 && y < buffer.length && x >= 0 && x < buffer[y].length) { + buffer[y][x] = { char, color: colorValue }; + } +} + +function drawText(buffer: Cell[][], x: number, y: number, text: string, colorValue: Color): void { + for (let i = 0; i < text.length; i++) { + setCell(buffer, x + i, y, text[i], colorValue); + } +} + +function clearRow(buffer: Cell[][], y: number, bg = color(20)): void { + if (y < 0 || y >= buffer.length) return; + for (let x = 0; x < buffer[y].length; x++) { + buffer[y][x] = { char: " ", color: bg }; + } +} + +class BrowserTerminalRenderer { + private buffer: SplashBuffer; + private size: Size; + private adapter: FakePtyAdapter; + private terminalId: string; + private mouseEnabled: boolean; + private unsubscribeResize: (() => void) | null = null; + + constructor(options: { adapter: FakePtyAdapter; terminalId: string; mouseEnabled: boolean }) { + this.adapter = options.adapter; + this.terminalId = options.terminalId; + this.mouseEnabled = options.mouseEnabled; + const initialSize = this.adapter.getPtySize(this.terminalId); + this.size = normalizeSize({ width: initialSize.cols, height: initialSize.rows }); + this.buffer = new SplashBuffer(this.size); + } + + start(): void { + this.write(ENTER_ALT_SCREEN); + if (this.mouseEnabled) this.write(MOUSE_ENABLE); + this.unsubscribeResize = this.adapter.onPtyResize((detail) => { + if (detail.id !== this.terminalId) return; + this.handleResize(detail.cols, detail.rows); + }); + } + + handleResize(width: number, height: number): void { + this.size = normalizeSize({ width, height }); + this.buffer.resize(this.size); + this.write("\x1b[2J\x1b[H"); + } + + getSize(): Size { + return this.size; + } + + getBuffer(): SplashBuffer { + return this.buffer; + } + + clear(): void { + this.buffer.clear(); + } + + clearScreen(): void { + this.write("\x1b[2J\x1b[H"); + this.buffer.clear(); + this.buffer.clearAllOverlays(); + this.buffer.swap(); + } + + render(): number { + const changes = this.buffer.getChanges(); + if (changes.length === 0) { + this.buffer.swap(); + return 0; + } + + let output = ""; + for (const change of changes) { + output += `\x1b[${change.y + 1};${change.x + 1}H`; + if (change.cell.color) { + const r = Math.max(0, Math.min(255, change.cell.color.r)); + const g = Math.max(0, Math.min(255, change.cell.color.g)); + const b = Math.max(0, Math.min(255, change.cell.color.b)); + output += `\x1b[38;2;${r};${g};${b}m`; + } else { + output += "\x1b[39m"; + } + output += change.cell.char; + } + output += "\x1b[0m"; + this.write(output); + this.buffer.swap(); + return changes.length; + } + + cleanup(): void { + this.unsubscribeResize?.(); + this.unsubscribeResize = null; + if (this.mouseEnabled) this.write(MOUSE_DISABLE); + this.write(LEAVE_ALT_SCREEN); + } + + private write(data: string): void { + this.adapter.sendOutput(this.terminalId, data); + } +} + +export class AsciiSplashRunner implements InteractiveProgram { + private adapter: FakePtyAdapter; + private terminalId: string; + private args: string[]; + private onExit: () => void; + private renderer: BrowserTerminalRenderer | null = null; + private engine: AnimationEngine | null = null; + private commandExecutor: CommandExecutor | null = null; + private commandBuffer = new CommandBuffer(); + private commandParser = new CommandParser(); + private helpOverlay = new HelpOverlay(); + private statusBar = new StatusBar(); + private toastManager = new ToastManager(); + private transitionManager = new TransitionManager(); + private patterns: Pattern[] = []; + private currentPatternIndex = 0; + private currentPresetIndex = 1; + private currentThemeIndex = 0; + private currentTheme: Theme = getTheme("ocean"); + private patternBuffer = ""; + private patternBufferActive = false; + private patternBufferTimeout: ReturnType | null = null; + private debugMode = false; + private isPatternSwitching = false; + private disposed = false; + private config: SplashConfig = defaultConfig; + + constructor(options: AsciiSplashRunnerOptions) { + this.adapter = options.adapter; + this.terminalId = options.terminalId; + this.args = options.args; + this.onExit = options.onExit; + } + + start(): void { + const parsed = parseArgs(this.args); + if (parsed.error) { + this.adapter.sendOutput(this.terminalId, `ascii-splash: ${parsed.error}\r\n${HELP_TEXT}\r\n`); + this.finishSoon(); + return; + } + if (parsed.help) { + this.adapter.sendOutput(this.terminalId, `${HELP_TEXT}\r\n`); + this.finishSoon(); + return; + } + if (parsed.version) { + this.adapter.sendOutput(this.terminalId, `${VERSION}\r\n`); + this.finishSoon(); + return; + } + + this.config = { + ...defaultConfig, + defaultPattern: parsed.pattern ?? defaultConfig.defaultPattern, + quality: parsed.quality, + fps: parsed.fps, + theme: parsed.theme, + mouseEnabled: parsed.mouseEnabled, + patterns: defaultConfig.patterns, + }; + this.currentTheme = getTheme(parsed.theme); + this.currentThemeIndex = THEME_NAMES.indexOf(this.currentTheme.name); + this.patterns = createPatternsFromConfig(this.config, this.currentTheme); + this.currentPatternIndex = Math.max(0, PATTERN_NAMES.indexOf((this.config.defaultPattern ?? "waves") as (typeof PATTERN_NAMES)[number])); + + this.renderer = new BrowserTerminalRenderer({ + adapter: this.adapter, + terminalId: this.terminalId, + mouseEnabled: parsed.mouseEnabled, + }); + this.renderer.start(); + + const initialFps = parsed.fps ?? qualityPresets[parsed.quality]; + this.engine = new AnimationEngine(this.renderer, this.patterns[this.currentPatternIndex], initialFps); + this.commandExecutor = new CommandExecutor( + this.engine, + this.patterns, + Object.values(THEMES), + this.currentPatternIndex, + this.currentThemeIndex, + undefined, + ); + + this.commandExecutor.setThemeChangeCallback((themeIndex: number) => { + const themeName = THEME_NAMES[themeIndex] ?? "ocean"; + this.currentTheme = getTheme(themeName); + this.currentThemeIndex = themeIndex; + this.syncCurrentPatternIndexFromEngine(); + this.rebuildPatterns(); + const nextPattern = this.patterns[this.currentPatternIndex] ?? this.patterns[0]; + this.engine?.setPattern(nextPattern); + this.commandExecutor?.updateState(this.currentPatternIndex, this.currentThemeIndex); + this.statusBar.update({ themeName: this.currentTheme.displayName }); + }); + + this.statusBar.update({ + patternName: this.getCurrentPatternDisplayName(), + presetNumber: this.currentPresetIndex, + themeName: this.currentTheme.displayName, + fps: initialFps, + shuffleMode: "off", + paused: false, + }); + this.transitionManager.setDefaultConfig({ type: "crossfade", duration: 300 }); + + this.engine.setBeforeTerminalRenderCallback(() => this.renderBufferOverlays()); + this.toastManager.info("ascii-splash - Press ? for help | q to quit", 1500); + this.engine.start(); + } + + handleInput(data: string): void { + if (this.disposed) return; + let index = 0; + while (index < data.length) { + if (data[index] === "\x1b" && data[index + 1] === "[") { + const remaining = data.slice(index); + const mouse = remaining.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/); + if (mouse) { + this.handleMouse(Number(mouse[1]), Number(mouse[2]) - 1, Number(mouse[3]) - 1, mouse[4]); + index += mouse[0].length; + continue; + } + const arrow = remaining.match(/^\x1b\[([ABCD])/); + if (arrow) { + this.handleKey({ name: ARROW_KEY_NAMES[arrow[1]], data: { isCharacter: false } }); + index += arrow[0].length; + continue; + } + } + + this.handleKey(decodeKey(data[index])); + index++; + } + } + + dispose(): void { + this.cleanup(false); + } + + private handleKey(input: KeyInput): void { + const { helpOverlay, statusBar, toastManager } = this; + + if (helpOverlay.isVisible()) { + if (input.name === "ESCAPE" || input.name === "?") { + helpOverlay.hide(); + } else if (input.name === "TAB" || input.name === "RIGHT") { + helpOverlay.nextTab(); + } else if (input.name === "LEFT") { + helpOverlay.prevTab(); + } + return; + } + + if (this.commandBuffer.isActive()) { + if (input.name === "ESCAPE") { + this.commandBuffer.cancel(); + } else if (input.name === "ENTER") { + const cmdString = this.commandBuffer.execute(); + if (cmdString) { + const parsed = this.commandParser.parse(cmdString); + const result = parsed && this.commandExecutor + ? this.commandExecutor.execute(parsed) + : { success: false, message: "Invalid command" }; + this.showCommandResult(result.message, result.success); + this.syncStateFromEngine(); + } + } else if (input.name === "BACKSPACE") { + this.commandBuffer.backspace(); + } else if (input.name === "UP") { + this.commandBuffer.previousCommand(); + } else if (input.name === "DOWN") { + this.commandBuffer.nextCommand(); + } else if (input.name === "LEFT") { + this.commandBuffer.moveCursorLeft(); + } else if (input.name === "RIGHT") { + this.commandBuffer.moveCursorRight(); + } else if (input.data.isCharacter && input.data.codepoint !== undefined) { + const char = String.fromCodePoint(input.data.codepoint); + if (/^[nNbB]$/.test(char)) { + this.commandBuffer.cancel(); + } else { + this.commandBuffer.addChar(char); + return; + } + } + return; + } + + if (this.patternBufferActive) { + if (input.name === "ESCAPE") { + this.cancelPatternBuffer(); + } else if (input.name === "ENTER") { + this.executePatternBuffer(); + } else if (input.name === "BACKSPACE") { + this.patternBuffer = this.patternBuffer.slice(0, -1); + } else if (input.data.isCharacter && input.data.codepoint !== undefined) { + const char = String.fromCodePoint(input.data.codepoint); + if (/[0-9a-zA-Z.]/.test(char)) { + this.patternBuffer += char; + this.resetPatternBufferTimeout(); + } + } + return; + } + + if (input.name === "CTRL_C" || input.name === "q" || input.name === "ESCAPE") { + this.cleanup(true); + } else if (input.name === "c") { + this.commandBuffer.activate(); + } else if (input.name === "SPACE") { + this.engine?.pause(); + statusBar.update({ paused: this.engine?.isPaused() ?? false }); + } else if (/^[1-9]$/.test(input.name)) { + this.switchPattern(Number(input.name) - 1); + } else if (input.name === "o") { + this.switchPattern(OCEANBEACH_PATTERN_INDEX); + } else if (input.name === "n") { + this.switchPattern((this.currentPatternIndex + 1) % this.patterns.length); + } else if (input.name === "b") { + this.switchPattern(this.currentPatternIndex === 0 ? this.patterns.length - 1 : this.currentPatternIndex - 1); + } else if (input.name === "p") { + this.activatePatternBuffer(); + } else if (input.name === ".") { + this.cyclePreset(1); + } else if (input.name === ",") { + this.cyclePreset(-1); + } else if (input.name === "+" || input.name === "=") { + this.setFps(Math.min(60, (this.engine?.getFps() ?? 30) + 5)); + } else if (input.name === "-" || input.name === "_") { + this.setFps(Math.max(10, (this.engine?.getFps() ?? 30) - 5)); + } else if (input.name === "?") { + helpOverlay.toggle(); + } else if (input.name === "d") { + this.debugMode = !this.debugMode; + } else if (input.name === "t") { + this.cycleTheme(); + } else if (input.name === "r") { + const parsed = this.commandParser.parse("0**"); + if (parsed && this.commandExecutor) { + const result = this.commandExecutor.execute(parsed); + this.showCommandResult(result.message, result.success); + this.syncStateFromEngine(); + } + } else if (input.name === "s") { + const parsed = this.commandParser.parse("0s"); + if (parsed && this.commandExecutor) { + const result = this.commandExecutor.execute(parsed); + this.showCommandResult(result.message, result.success); + } + } else if (input.name === "[") { + if (this.config.quality === "high") this.setQuality("medium"); + else if (this.config.quality === "medium") this.setQuality("low"); + } else if (input.name === "]") { + if (this.config.quality === "low") this.setQuality("medium"); + else if (this.config.quality === "medium") this.setQuality("high"); + } + + if (toastManager.hasToasts()) { + statusBar.update({ patternName: this.getCurrentPatternDisplayName() }); + } + } + + private handleMouse(code: number, x: number, y: number, final: string): void { + const pattern = this.patterns[this.currentPatternIndex]; + const pos: Point = { x, y }; + const isMotion = (code & 32) === 32; + const button = code & 3; + if (final === "M" && isMotion && pattern.onMouseMove) { + pattern.onMouseMove(pos); + } else if (final === "M" && button === 0 && pattern.onMouseClick) { + pattern.onMouseClick(pos); + } + } + + private switchPattern(index: number): void { + if (!this.engine || index < 0 || index >= this.patterns.length || index === this.currentPatternIndex) return; + this.isPatternSwitching = true; + const oldPattern = this.patterns[this.currentPatternIndex]; + this.currentPatternIndex = index; + this.currentPresetIndex = 1; + const newPattern = this.patterns[this.currentPatternIndex]; + this.transitionManager.start(oldPattern, newPattern, this.renderer?.getSize() ?? { width: 80, height: 30 }); + this.engine.setPattern(newPattern); + this.commandExecutor?.updateState(this.currentPatternIndex, this.currentThemeIndex); + this.statusBar.update({ + patternName: this.getCurrentPatternDisplayName(), + presetNumber: this.currentPresetIndex, + }); + this.toastManager.info(`Pattern: ${this.getCurrentPatternDisplayName()}`, 2000); + setTimeout(() => { + this.isPatternSwitching = false; + }, PATTERN_SWITCH_GUARD_MS); + } + + private cyclePreset(direction: 1 | -1): void { + const currentPattern = this.patterns[this.currentPatternIndex]; + if (!currentPattern.applyPreset) return; + const nextPreset = direction === 1 + ? (this.currentPresetIndex % PRESET_COUNT) + 1 + : this.currentPresetIndex === 1 ? PRESET_COUNT : this.currentPresetIndex - 1; + if (!currentPattern.applyPreset(nextPreset)) return; + this.currentPresetIndex = nextPreset; + this.statusBar.update({ presetNumber: nextPreset }); + this.toastManager.info(`${this.getCurrentPatternDisplayName()} - Preset ${nextPreset}`, 1500); + } + + private setFps(fps: number): void { + this.engine?.setFps(fps); + this.statusBar.update({ fps }); + this.toastManager.info(`Speed: ${fps} FPS`, 1500); + } + + private setQuality(quality: QualityPreset): void { + this.config = { ...this.config, quality }; + this.syncCurrentPatternIndexFromEngine(); + this.rebuildPatterns(); + this.setFps(qualityPresets[quality]); + this.engine?.setPattern(this.patterns[this.currentPatternIndex]); + this.commandExecutor?.updateState(this.currentPatternIndex, this.currentThemeIndex); + this.toastManager.info(`Quality: ${quality.toUpperCase()} (${qualityPresets[quality]} FPS)`, 1500); + } + + private cycleTheme(): void { + const nextThemeName = getNextThemeName(this.currentTheme.name); + this.currentTheme = getTheme(nextThemeName); + this.currentThemeIndex = THEME_NAMES.indexOf(this.currentTheme.name); + this.syncCurrentPatternIndexFromEngine(); + this.rebuildPatterns(); + this.engine?.setPattern(this.patterns[this.currentPatternIndex]); + this.commandExecutor?.updateState(this.currentPatternIndex, this.currentThemeIndex); + this.statusBar.update({ themeName: this.currentTheme.displayName }); + this.toastManager.info(`Theme: ${this.currentTheme.displayName}`, 1500); + } + + private activatePatternBuffer(): void { + this.patternBuffer = ""; + this.patternBufferActive = true; + this.resetPatternBufferTimeout(); + } + + private cancelPatternBuffer(): void { + this.patternBufferActive = false; + this.patternBuffer = ""; + if (this.patternBufferTimeout) { + clearTimeout(this.patternBufferTimeout); + this.patternBufferTimeout = null; + } + } + + private resetPatternBufferTimeout(): void { + if (this.patternBufferTimeout) clearTimeout(this.patternBufferTimeout); + this.patternBufferTimeout = setTimeout(() => { + this.patternBufferActive = false; + this.patternBuffer = ""; + this.patternBufferTimeout = null; + }, PATTERN_BUFFER_TIMEOUT_MS); + } + + private executePatternBuffer(): void { + const input = this.patternBuffer.trim(); + this.cancelPatternBuffer(); + if (!input) { + this.switchPattern(this.currentPatternIndex === 0 ? this.patterns.length - 1 : this.currentPatternIndex - 1); + return; + } + + if (input.includes(".")) { + const [patternPart, presetPart] = input.split("."); + const patternNum = Number(patternPart); + const presetNum = Number(presetPart); + if (Number.isInteger(patternNum) && Number.isInteger(presetNum) && patternNum >= 1 && patternNum <= this.patterns.length) { + this.switchPattern(patternNum - 1); + const pattern = this.patterns[patternNum - 1]; + if (pattern.applyPreset?.(presetNum)) { + this.currentPresetIndex = presetNum; + this.statusBar.update({ presetNumber: presetNum }); + this.toastManager.info(`${this.getCurrentPatternDisplayName()} - Preset ${presetNum}`, 1500); + } else { + this.toastManager.error(`Invalid preset: ${presetNum}`, 1500); + } + return; + } + } + + const patternNum = Number(input); + if (Number.isInteger(patternNum) && patternNum >= 1 && patternNum <= this.patterns.length) { + this.switchPattern(patternNum - 1); + return; + } + + const lowerInput = input.toLowerCase(); + const exactIndex = PATTERN_NAMES.indexOf(lowerInput as (typeof PATTERN_NAMES)[number]); + if (exactIndex >= 0) { + this.switchPattern(exactIndex); + return; + } + const partialIndex = PATTERN_NAMES.findIndex((name) => name.startsWith(lowerInput)); + if (partialIndex >= 0) { + this.switchPattern(partialIndex); + return; + } + this.toastManager.error(`Unknown pattern: ${input}`, 1500); + } + + private showCommandResult(message: string, success: boolean): void { + const { toastManager } = this; + if (success) toastManager.success(message); + else toastManager.error(message); + + const shuffleInfo = this.commandExecutor?.getShuffleInfo() ?? ""; + this.statusBar.update({ + shuffleMode: shuffleInfo ? (shuffleInfo.includes("ALL") ? "all" : "preset") : "off", + }); + } + + private rebuildPatterns(): void { + const nextPatterns = createPatternsFromConfig(this.config, this.currentTheme); + this.patterns.splice(0, this.patterns.length, ...nextPatterns); + this.currentPatternIndex = Math.min(this.currentPatternIndex, Math.max(0, this.patterns.length - 1)); + } + + private syncCurrentPatternIndexFromEngine(): void { + const active = this.engine?.getPattern(); + const index = active ? this.patterns.indexOf(active) : -1; + if (index >= 0) this.currentPatternIndex = index; + } + + private syncStateFromEngine(): void { + this.syncCurrentPatternIndexFromEngine(); + this.statusBar.update({ + patternName: this.getCurrentPatternDisplayName(), + presetNumber: this.currentPresetIndex, + themeName: this.currentTheme.displayName, + fps: this.engine?.getFps() ?? qualityPresets[this.config.quality ?? "medium"], + }); + this.commandExecutor?.updateState(this.currentPatternIndex, this.currentThemeIndex); + } + + private renderBufferOverlays(): void { + if (!this.renderer || this.isPatternSwitching) return; + const size = this.renderer.getSize(); + const buffer = this.renderer.getBuffer(); + const cells = buffer.getBuffer(); + const now = Date.now(); + + const { transitionManager } = this; + if (transitionManager.isActive()) { + transitionManager.render(cells, now, size); + } + + const { toastManager } = this; + if (toastManager.hasToasts()) { + toastManager.update(now); + toastManager.render(cells, size); + } + + const { helpOverlay } = this; + if (helpOverlay.isVisible()) { + helpOverlay.render(cells, size); + } + + if (this.debugMode) { + this.renderDebugOverlay(cells, size); + } + + if (this.commandBuffer.isActive()) { + this.renderCommandOverlay(cells, size); + } else if (this.patternBufferActive) { + this.renderPatternOverlay(cells, size); + } else { + this.statusBar.render(cells, size); + } + } + + private renderCommandOverlay(buffer: Cell[][], size: Size): void { + const y = size.height - 1; + clearRow(buffer, y, color(20)); + const labelColor = { r: 100, g: 220, b: 255 }; + const textColor = { r: 120, g: 255, b: 150 }; + drawText(buffer, 0, y, "COMMAND: ", labelColor); + const cmd = this.commandBuffer.getBuffer(); + const cursor = this.commandBuffer.getCursorPos(); + drawText(buffer, 9, y, cmd.slice(0, cursor), textColor); + drawText(buffer, 9 + cursor, y, "_", { r: 255, g: 255, b: 255 }); + drawText(buffer, 10 + cursor, y, cmd.slice(cursor), textColor); + } + + private renderPatternOverlay(buffer: Cell[][], size: Size): void { + const y = size.height - 1; + clearRow(buffer, y, color(20)); + drawText(buffer, 0, y, "PATTERN: ", { r: 255, g: 220, b: 100 }); + drawText(buffer, 9, y, this.patternBuffer, { r: 120, g: 255, b: 150 }); + drawText(buffer, 9 + this.patternBuffer.length, y, "_", { r: 255, g: 255, b: 255 }); + } + + private renderDebugOverlay(buffer: Cell[][], size: Size): void { + if (!this.engine) return; + const metrics = this.engine.getPerformanceMonitor().getMetrics(); + const stats = this.engine.getPerformanceMonitor().getStats(); + const currentPattern = this.patterns[this.currentPatternIndex]; + const lines = [ + "PERFORMANCE DEBUG", + "-----------------", + `Pattern: ${currentPattern.name}`, + `Theme: ${this.currentTheme.displayName}`, + `Quality: ${(this.config.quality ?? "medium").toUpperCase()}`, + `FPS: ${metrics.fps.toFixed(1)} / ${metrics.targetFps}`, + `Frame: ${metrics.frameTime.toFixed(2)}ms`, + `Changed: ${metrics.changedCells} / ${size.width * size.height}`, + `Dropped: ${stats.totalDroppedFrames}`, + ]; + + const patternMetrics = currentPattern.getMetrics?.(); + if (patternMetrics) { + lines.push("Pattern Metrics:"); + for (const [key, value] of Object.entries(patternMetrics).slice(0, 8)) { + lines.push(` ${key}: ${value}`); + } + } + + lines.slice(0, Math.max(0, size.height - 2)).forEach((line, index) => { + drawText(buffer, 1, index, line.slice(0, Math.max(0, size.width - 2)), index === 0 ? { r: 255, g: 220, b: 100 } : { r: 220, g: 220, b: 220 }); + }); + } + + private getCurrentPatternDisplayName(): string { + const name = PATTERN_NAMES[this.currentPatternIndex] ?? this.patterns[this.currentPatternIndex]?.name ?? "waves"; + return PATTERN_DISPLAY_NAMES[name] ?? name; + } + + private finishSoon(): void { + queueMicrotask(() => { + if (!this.disposed) this.onExit(); + }); + } + + private cleanup(notifyExit: boolean): void { + if (this.disposed) return; + this.disposed = true; + if (this.patternBufferTimeout) { + clearTimeout(this.patternBufferTimeout); + this.patternBufferTimeout = null; + } + this.commandExecutor?.cleanup(); + this.engine?.stop(); + this.renderer?.cleanup(); + this.engine = null; + this.renderer = null; + if (notifyExit) this.onExit(); + } +} + +function decodeKey(ch: string): KeyInput { + if (ch === "\x03") return { name: "CTRL_C", data: { isCharacter: false } }; + if (ch === "\x1b") return { name: "ESCAPE", data: { isCharacter: false } }; + if (ch === "\r" || ch === "\n") return { name: "ENTER", data: { isCharacter: false } }; + if (ch === "\x7f" || ch === "\b") return { name: "BACKSPACE", data: { isCharacter: false } }; + if (ch === "\t") return { name: "TAB", data: { isCharacter: false } }; + if (ch === " ") return { name: "SPACE", data: { isCharacter: true, codepoint: 32 } }; + return { name: ch, data: { isCharacter: ch >= " ", codepoint: ch.codePointAt(0) } }; +} diff --git a/website/src/lib/playground-shells.test.ts b/website/src/lib/playground-shells.test.ts new file mode 100644 index 0000000..cc20bec --- /dev/null +++ b/website/src/lib/playground-shells.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; +import { PlaygroundShellRegistry } from "./playground-shells"; +import type { InteractiveProgram } from "./tutorial-shell"; + +function createProgram(): InteractiveProgram { + return { + start: vi.fn(), + handleInput: vi.fn(), + dispose: vi.fn(), + }; +} + +describe("PlaygroundShellRegistry", () => { + it("attaches a shell to each terminal id", () => { + const adapter = new FakePtyAdapter(); + const output: Record = { one: [], two: [] }; + adapter.onPtyData((detail) => output[detail.id]?.push(detail.data)); + adapter.spawnPty("one"); + adapter.spawnPty("two"); + + const registry = new PlaygroundShellRegistry(adapter, () => createProgram()); + registry.ensureShell("one"); + registry.ensureShell("two"); + + adapter.writePty("one", "\r"); + adapter.writePty("two", "\r"); + + expect(output.one.join("")).toContain("user"); + expect(output.two.join("")).toContain("user"); + expect(output.one.join("")).toContain("$ "); + expect(output.two.join("")).toContain("$ "); + }); + + it("starts interactive programs against the active terminal id", () => { + const adapter = new FakePtyAdapter(); + const program = createProgram(); + const startProgram = vi.fn(() => program); + adapter.spawnPty("two"); + + const registry = new PlaygroundShellRegistry(adapter, startProgram); + registry.ensureShell("two"); + adapter.writePty("two", "ascii-splash --no-mouse\r"); + + expect(startProgram).toHaveBeenCalledWith("two", ["--no-mouse"], expect.any(Function)); + expect(program.start).toHaveBeenCalledTimes(1); + }); + + it("clears the adapter input handler when disposing a shell", () => { + const adapter = new FakePtyAdapter(); + const output: string[] = []; + adapter.onPtyData((detail) => output.push(detail.data)); + adapter.spawnPty("one"); + + const registry = new PlaygroundShellRegistry(adapter, () => createProgram()); + registry.ensureShell("one"); + registry.disposeShell("one"); + + adapter.writePty("one", "raw"); + + expect(output).toEqual(["raw"]); + }); + + it("disposes shells when their PTY exits", () => { + const adapter = new FakePtyAdapter(); + const program = createProgram(); + adapter.spawnPty("one"); + + const registry = new PlaygroundShellRegistry(adapter, () => program); + registry.ensureShell("one"); + adapter.writePty("one", "ascii-splash\r"); + + adapter.killPty("one"); + + expect(program.dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/website/src/lib/playground-shells.ts b/website/src/lib/playground-shells.ts new file mode 100644 index 0000000..0affeb6 --- /dev/null +++ b/website/src/lib/playground-shells.ts @@ -0,0 +1,49 @@ +import type { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; +import { TutorialShell, type InteractiveProgram } from "./tutorial-shell"; + +export type StartPlaygroundProgram = ( + terminalId: string, + args: string[], + onExit: () => void, +) => InteractiveProgram; + +export class PlaygroundShellRegistry { + private adapter: FakePtyAdapter; + private startProgram: StartPlaygroundProgram; + private shells = new Map(); + private handlePtyExit = (detail: { id: string }) => { + this.disposeShell(detail.id); + }; + + constructor(adapter: FakePtyAdapter, startProgram: StartPlaygroundProgram) { + this.adapter = adapter; + this.startProgram = startProgram; + this.adapter.onPtyExit(this.handlePtyExit); + } + + ensureShell(id: string): TutorialShell { + const existing = this.shells.get(id); + if (existing) return existing; + + const shell = new TutorialShell( + (data) => this.adapter.sendOutput(id, data), + (args, onExit) => this.startProgram(id, args, onExit), + ); + this.shells.set(id, shell); + this.adapter.setInputHandler(id, (data) => shell.handleInput(data)); + return shell; + } + + disposeShell(id: string): void { + this.shells.get(id)?.dispose(); + this.shells.delete(id); + this.adapter.clearInputHandler(id); + } + + disposeAll(): void { + this.adapter.offPtyExit(this.handlePtyExit); + for (const id of this.shells.keys()) { + this.disposeShell(id); + } + } +} diff --git a/website/src/lib/tutorial-shell.test.ts b/website/src/lib/tutorial-shell.test.ts new file mode 100644 index 0000000..0474f39 --- /dev/null +++ b/website/src/lib/tutorial-shell.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { TutorialShell, type InteractiveProgram } from "./tutorial-shell"; + +function createHarness() { + const output: string[] = []; + let exitProgram: (() => void) | null = null; + const program: InteractiveProgram = { + start: vi.fn(), + handleInput: vi.fn(), + dispose: vi.fn(), + }; + const startAsciiSplash = vi.fn((args: string[], onExit: () => void) => { + exitProgram = onExit; + return program; + }); + const shell = new TutorialShell((data) => output.push(data), startAsciiSplash); + return { output, program, shell, startAsciiSplash, exitProgram: () => exitProgram?.() }; +} + +describe("TutorialShell ascii-splash integration", () => { + it("launches ascii-splash and delegates input while it is active", () => { + const { output, program, shell, startAsciiSplash, exitProgram } = createHarness(); + + shell.handleInput("ascii-splash --no-mouse\r"); + shell.handleInput("q"); + + expect(startAsciiSplash).toHaveBeenCalledWith(["--no-mouse"], expect.any(Function)); + expect(program.start).toHaveBeenCalledTimes(1); + expect(program.handleInput).toHaveBeenCalledWith("q"); + + exitProgram(); + expect(output.join("")).toContain("$ "); + }); + + it("disposes the active program with the shell", () => { + const { program, shell } = createHarness(); + + shell.handleInput("splash\r"); + shell.dispose(); + + expect(program.dispose).toHaveBeenCalledTimes(1); + }); + + it("recalls the previous command on up arrow instead of echoing the escape sequence", () => { + const { output, shell } = createHarness(); + shell.handleInput("bogus\r"); + output.length = 0; + + shell.handleInput("\x1b[A"); + + const data = output.join(""); + expect(data).toContain("bogus"); + expect(data).not.toContain("[A"); + }); + + it("executes a command recalled from history", () => { + const { output, shell } = createHarness(); + shell.handleInput("bogus\r"); + output.length = 0; + + shell.handleInput("\x1b[A\r"); + + expect(output.join("")).toContain("Unknown command"); + }); + + it("restores the current draft when moving down past the newest history entry", () => { + const { output, shell } = createHarness(); + shell.handleInput("bogus\r"); + output.length = 0; + + shell.handleInput("draft"); + output.length = 0; + shell.handleInput("\x1b[A"); + shell.handleInput("\x1b[B"); + + const data = output.join(""); + expect(data).toContain("bogus"); + expect(data).toContain("draft"); + expect(data).not.toContain("[A"); + expect(data).not.toContain("[B"); + }); +}); diff --git a/website/src/lib/tutorial-shell.ts b/website/src/lib/tutorial-shell.ts index 90f514e..32745c7 100644 --- a/website/src/lib/tutorial-shell.ts +++ b/website/src/lib/tutorial-shell.ts @@ -1,11 +1,3 @@ -/** - * Tutorial shell — handles the `tut` command in the playground's fake terminal. - * - * Provides line editing (echo, backspace) and command parsing. - * Routes `tut`, `tut status`, `tut reset` to tutorial logic. - */ - -// ANSI helpers const ESC = '\x1b['; const RESET = `${ESC}0m`; const BOLD = `${ESC}1m`; @@ -13,6 +5,7 @@ const DIM = `${ESC}2m`; const fg = (code: number) => `${ESC}${code}m`; const PROMPT = `${fg(32)}user${RESET}@${fg(36)}mouseterm${RESET}:${BOLD}${fg(34)}~${RESET}$ `; +const CLEAR_LINE = `${ESC}2K`; const STORAGE_PREFIX = 'mouseterm-tutorial-step-'; const TOTAL_STEPS = 6; @@ -65,50 +58,146 @@ const STEPS: TutorialStep[] = [ export type SendOutput = (data: string) => void; +export interface InteractiveProgram { + start(): void; + handleInput(data: string): void; + dispose(): void; +} + +export type StartInteractiveProgram = (args: string[], onExit: () => void) => InteractiveProgram; + export class TutorialShell { private lineBuffer = ''; + private history: string[] = []; + private historyIndex: number | null = null; + private historyDraft = ''; private sendOutput: SendOutput; + private startAsciiSplash?: StartInteractiveProgram; + private activeProgram: InteractiveProgram | null = null; - constructor(sendOutput: SendOutput) { + constructor(sendOutput: SendOutput, startAsciiSplash?: StartInteractiveProgram) { this.sendOutput = sendOutput; + this.startAsciiSplash = startAsciiSplash; + } + + dispose(): void { + this.activeProgram?.dispose(); + this.activeProgram = null; } - /** Handle a keystroke from the user. */ handleInput(data: string): void { - for (const ch of data) { + if (this.activeProgram) { + this.activeProgram.handleInput(data); + return; + } + + for (let index = 0; index < data.length; index++) { + const ch = data[index]; + if (ch === '\x1b') { + const remaining = data.slice(index); + const csi = remaining.match(/^\x1b\[([0-?]*)([ -/]*)([@-~])/); + if (csi) { + this.handleControlSequence(csi[3]); + index += csi[0].length - 1; + continue; + } + const ss3 = remaining.match(/^\x1bO(.)/); + if (ss3) { + this.handleControlSequence(ss3[1]); + index += ss3[0].length - 1; + continue; + } + // Lone escape byte: drop it so partial sequences don't echo. + continue; + } + if (ch === '\r' || ch === '\n') { this.sendOutput('\r\n'); - this.processCommand(this.lineBuffer.trim()); + const command = this.lineBuffer.trim(); + this.pushHistory(command); + this.processCommand(command); this.lineBuffer = ''; + this.historyIndex = null; + this.historyDraft = ''; } else if (ch === '\x7f' || ch === '\b') { - // Backspace if (this.lineBuffer.length > 0) { this.lineBuffer = this.lineBuffer.slice(0, -1); - // Move cursor back, overwrite with space, move back again + this.historyIndex = null; this.sendOutput('\b \b'); } } else if (ch >= ' ') { - // Printable character this.lineBuffer += ch; + this.historyIndex = null; this.sendOutput(ch); } } } + private handleControlSequence(finalByte: string): void { + if (finalByte === 'A') { + this.recallHistory(-1); + } else if (finalByte === 'B') { + this.recallHistory(1); + } + } + + private pushHistory(command: string): void { + if (!command) return; + if (this.history[this.history.length - 1] === command) return; + this.history.push(command); + } + + private recallHistory(direction: -1 | 1): void { + if (this.history.length === 0) return; + if (this.historyIndex === null) { + if (direction === 1) return; + this.historyDraft = this.lineBuffer; + this.historyIndex = this.history.length - 1; + } else { + this.historyIndex += direction; + if (this.historyIndex < 0) { + this.historyIndex = 0; + } else if (this.historyIndex >= this.history.length) { + this.historyIndex = null; + this.lineBuffer = this.historyDraft; + this.redrawPromptLine(); + return; + } + } + + this.lineBuffer = this.history[this.historyIndex]; + this.redrawPromptLine(); + } + + private redrawPromptLine(): void { + this.sendOutput(`\r${CLEAR_LINE}${PROMPT}${this.lineBuffer}`); + } + private processCommand(cmd: string): void { if (cmd === '') { this.sendOutput(PROMPT); return; } + const [argv0, ...args] = cmd.split(/\s+/); if (cmd === 'tut') { this.showCurrentStep(); } else if (cmd === 'tut status') { this.showStatus(); } else if (cmd === 'tut reset') { this.resetProgress(); + } else if (argv0 === 'ascii-splash' || argv0 === 'splash') { + if (this.startAsciiSplash) { + this.activeProgram = this.startAsciiSplash(args, () => { + this.activeProgram = null; + this.sendOutput(PROMPT); + }); + this.activeProgram.start(); + return; + } + this.sendOutput(`${fg(90)}ascii-splash is not available in this environment.${RESET}\r\n`); } else { - this.sendOutput(`${fg(90)}Unknown command. Type ${fg(36)}tut${fg(90)} to start the tutorial.${RESET}\r\n`); + this.sendOutput(`${fg(90)}Unknown command. Type ${fg(36)}tut${fg(90)} or ${fg(36)}ascii-splash${fg(90)}.${RESET}\r\n`); } this.sendOutput(PROMPT); diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index 89fbabe..cee3807 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import SiteHeader from "../components/SiteHeader"; import { ThemePicker } from "mouseterm-lib/components/ThemePicker"; -import { TutorialShell } from "../lib/tutorial-shell"; +import { PlaygroundShellRegistry } from "../lib/playground-shells"; import { TutorialDetector } from "../lib/tutorial-detection"; export { Playground as Component }; @@ -13,14 +13,16 @@ const PANE_LS = "tut-ls"; type FakePtyAdapter = import("mouseterm-lib/lib/platform/fake-adapter").FakePtyAdapter; type WallEvent = import("mouseterm-lib/components/Wall").WallEvent; +type DockviewDisposable = { dispose: () => void }; function Playground() { const [WallModule, setWallModule] = useState<{ Wall: React.ComponentType; } | null>(null); const adapterRef = useRef(null); - const shellRef = useRef(null); + const shellRegistryRef = useRef(null); const detectorRef = useRef(null); + const dockviewDisposablesRef = useRef([]); useEffect(() => { async function loadWall() { @@ -28,6 +30,7 @@ function Playground() { const registry = await import("mouseterm-lib/lib/terminal-registry"); const wall = await import("mouseterm-lib/components/Wall"); const scenarios = await import("mouseterm-lib/lib/platform/fake-scenarios"); + const asciiSplash = await import("../lib/ascii-splash-runner"); await import("mouseterm-lib/index.css"); @@ -35,18 +38,28 @@ function Playground() { registry.initAlertStateReceiver(); adapterRef.current = adapter; - // Assign scenarios to panes before Wall mounts them + adapter.setDefaultScenario(scenarios.SCENARIO_SHELL_PROMPT); adapter.setScenario(PANE_NPM, scenarios.SCENARIO_LONG_RUNNING); adapter.setScenario(PANE_LS, scenarios.SCENARIO_LS_OUTPUT); adapter.setScenario(PANE_MAIN, scenarios.SCENARIO_TUTORIAL_MOTD); - // Wire up the tutorial shell - const shell = new TutorialShell((data) => adapter.sendOutput(PANE_MAIN, data)); - shellRef.current = shell; - adapter.setInputHandler(PANE_MAIN, (data) => shell.handleInput(data)); - - // Wire up step detection - const detector = new TutorialDetector(shell); + // Named scenarios print demo output first; the shell takes over once + // scenario playback ends. + const shellRegistry = new PlaygroundShellRegistry( + adapter, + (terminalId, args, onExit) => new asciiSplash.AsciiSplashRunner({ + adapter, + terminalId, + args, + onExit, + }), + ); + shellRegistryRef.current = shellRegistry; + const mainShell = shellRegistry.ensureShell(PANE_MAIN); + shellRegistry.ensureShell(PANE_NPM); + shellRegistry.ensureShell(PANE_LS); + + const detector = new TutorialDetector(mainShell); detectorRef.current = detector; setWallModule({ Wall: wall.Wall }); @@ -54,11 +67,25 @@ function Playground() { loadWall(); return () => { + for (const disposable of dockviewDisposablesRef.current) { + disposable.dispose(); + } + dockviewDisposablesRef.current = []; detectorRef.current?.dispose(); + shellRegistryRef.current?.disposeAll(); + shellRegistryRef.current = null; }; }, []); const handleApiReady = useCallback((api: any) => { + const shellRegistry = shellRegistryRef.current; + shellRegistry?.ensureShell(PANE_MAIN); + + const addDisposable = api.onDidAddPanel((panel: { id?: string } | undefined) => { + if (panel?.id) shellRegistryRef.current?.ensureShell(panel.id); + }); + dockviewDisposablesRef.current.push(addDisposable); + api.addPanel({ id: PANE_NPM, component: "terminal", @@ -77,7 +104,6 @@ function Playground() { const mainPanel = api.getPanel(PANE_MAIN); if (mainPanel) mainPanel.api.setActive(); - // Attach step detection to the API detectorRef.current?.attach(api); }, []); diff --git a/website/tsconfig.json b/website/tsconfig.json index 109f0ac..0d3e696 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -11,6 +11,11 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "ascii-splash-internal/*": ["node_modules/ascii-splash/dist/*"], + "mouseterm-lib/*": ["../lib/src/*"] + }, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, diff --git a/website/vite.config.ts b/website/vite.config.ts index 5a47744..ea31e16 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ resolve: { alias: { "mouseterm-lib": path.resolve(__dirname, "../lib/src"), + "ascii-splash-internal": path.resolve( + __dirname, + "node_modules/ascii-splash/dist", + ), "@standalone-latest": path.resolve( __dirname, "public/standalone-latest.json",