diff --git a/AGENTS.md b/AGENTS.md index ea9d7bd..aacf49e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ The primary job of a spec is to be an accurate reference for the current state o - **`docs/specs/layout.md`** — Tiling layout, pane/door containers, dockview configuration, modes (passthrough/command), keyboard shortcuts, selection overlay, spatial navigation, minimize/reattach, inline rename, session lifecycle, session persistence, and theming. Read this when touching: `Wall.tsx`, `Baseboard.tsx`, `Door.tsx`, `TerminalPane.tsx`, `spatial-nav.ts`, `layout-snapshot.ts`, `terminal-registry.ts`, `session-save.ts`, `session-restore.ts`, `reconnect.ts`, `index.css`, `theme.css`, or any keyboard/navigation/mode behavior. - **`docs/specs/alert.md`** — Activity monitoring state machine, alert trigger/clearing rules, attention model, TODO lifecycle (soft/hard), bell button visual states and interaction, door alert indicators, and hardening (a11y, motion, i18n, overflow). Read this when touching: `activity-monitor.ts`, `alert-manager.ts`, the alert bell or TODO pill in `Wall.tsx` (TerminalPaneHeader), alert indicators in `Door.tsx`, or the `a`/`t` keyboard shortcuts. Layout.md defers to this spec for all alert/TODO behavior. - **`docs/specs/vscode.md`** — VS Code extension architecture: hosting modes (WebviewView + WebviewPanel), PTY lifecycle and buffering, message protocol between webview and extension host, session persistence flow, reconnection protocol, theme integration, CSP, build pipeline, and invariants (save-before-kill ordering, PTY ownership, alert state merging). Read this when touching: `extension.ts`, `webview-view-provider.ts`, `message-router.ts`, `message-types.ts`, `pty-manager.ts`, `pty-host.js`, `session-state.ts`, `webview-html.ts`, `vscode-adapter.ts`, or `pty-core.js`. -- **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane initial layout, `tut` command and TutorialShell, 6-step progressive tutorial with detection logic, theme picker, FakePtyAdapter extensions, and Wall event hooks. Read this when touching: `website/src/pages/Playground.tsx`, `website/src/lib/tutorial-shell.ts`, `website/src/lib/tutorial-detection.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), or the `onApiReady`/`onEvent`/`initialPaneIds` props on Wall. +- **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane layout, interactive `tut` TUI runner with three sections (keyboard navigation, alerts/TODOs, copy/paste), per-item detection wired to `WallEvent` / activity store / mouse-selection store, single-key `mouseterm-tut-v3` localStorage scheme, theme picker, and FakePtyAdapter extensions (`sendOutput`, `pumpActivity`, `setInputHandler`). Read this when touching: `website/src/pages/Playground.tsx`, `website/src/lib/tut-runner.ts`, `website/src/lib/tut-detector.ts`, `website/src/lib/tutorial-state.ts`, `website/src/lib/tut-items.ts`, `website/src/lib/tutorial-shell.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), the `WallEvent` union, or the `onApiReady`/`onEvent`/`initialPaneIds` props on Wall. - **`docs/specs/theme.md`** — Theme system: two-layer CSS variable strategy, theme data model, conversion pipeline, bundled themes, localStorage store, shared ThemePicker component, standalone AppBar picker, runtime OpenVSX installer. Read this when touching: `lib/src/lib/themes/`, `lib/src/components/ThemePicker.tsx`, `lib/src/theme.css`, `lib/scripts/bundle-themes.mjs`, `standalone/src/AppBar.tsx` (theme picker), `standalone/src/main.tsx` (theme restore), or `website/src/components/SiteHeader.tsx` (themeAware mode). - **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned text selection, copy (Raw / Rewrapped), bracketed paste, smart URL/path extension, mouse-reporting override UI (icon + banner), and the state matrix for which layer owns mouse events. Read this when touching: `lib/src/lib/mouse-selection.ts`, `lib/src/lib/mouse-mode-observer.ts`, `lib/src/lib/clipboard.ts`, `lib/src/lib/rewrap.ts`, `lib/src/lib/selection-text.ts`, `lib/src/lib/smart-token.ts`, `lib/src/components/SelectionOverlay.tsx`, `lib/src/components/SelectionPopup.tsx`, the mouse icon / override banner / Cmd+C-V handling in `lib/src/components/Wall.tsx`, or the parser hooks + mouse listeners in `lib/src/lib/terminal-registry.ts`. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index d81abd4..5edfba6 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -66,7 +66,7 @@ The content area is a tiling layout of panes, powered by dockview. Each pane occ - `group.model.onWillDrop`: `event.position === 'center'` → intercepted and converted to a **swap** - All other positions and kinds are allowed — these create splits -**Center drop = swap.** Dropping a pane onto the center of another swaps their session content (same as `Cmd+Arrow`). The overlay is allowed so the user sees a valid drop target, but `group.model.onWillDrop` intercepts it, calls `swapTerminals()` + swaps titles, then `preventDefault()` to block the merge. +**Center drop = swap.** Dropping a pane onto the center of another swaps their session content (same as `Cmd/Ctrl+Arrow`). The overlay is allowed so the user sees a valid drop target, but `group.model.onWillDrop` intercepts it, calls `swapTerminals()` + swaps titles, then `preventDefault()` to block the merge. ### Pane header @@ -129,6 +129,8 @@ Extreme case: a single door with a very long title, with more doors on both side ## Modes +Wall starts in `command` mode by default. Embedders may pass `initialMode="passthrough"` when the first pane is an already-running interactive surface that should receive keyboard input immediately. + ### Passthrough mode - All keyboard input routes to the active session's xterm.js instance - Only the mode-exit gesture (LCmd → RCmd) is intercepted @@ -162,7 +164,7 @@ All handled in a single capture-phase `keydown` listener on `window`. Every hand | `"` | Horizontal split — new pane to the right | — | | `%` | Vertical split — new pane below | — | | Arrow keys | Spatial navigation between panes | Left/Right between doors, Up to panes | -| `Cmd+Arrow` | Swap session content with neighbor | — | +| `Cmd/Ctrl+Arrow` | Swap session content with neighbor | — | | `Enter` | Enter passthrough mode | Restore session + enter passthrough | | `,` | Inline rename | — | | `x` | Kill with confirmation | Restore session + kill confirmation | @@ -210,7 +212,7 @@ A breadcrumb tracks the last navigation direction and origin pane. Pressing the Down from the bottom-most pane navigates to the first door in the baseboard. Up from a door navigates to the last pane. Left/Right navigates between doors. -### Cmd+Arrow swap +### Cmd/Ctrl+Arrow swap Swaps session **content** between two panes — the layout shape is unchanged. Uses `swapTerminals()` from terminal-registry which swaps registry entries and reattaches DOM elements to each other's containers. Also swaps dockview panel titles. Selection follows the moved session. Uses the same back-navigation breadcrumb as arrow keys. diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index f54b407..c71a542 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -1,153 +1,102 @@ # Playground Tutorial -At the `/playground` route on the website. **Status: Implemented** (Epics 14, 15, 16). +At the `/playground` route on the website. Interactive TUI: each item starts pending, the first incomplete item is marked as active, and completed items become green checks when MouseTerm detects the corresponding action. -## Layout - -- `SiteHeader` at top (with Playground as active nav item). On `/playground`, the header renders the **Theme:** dropdown as an optional header control; other routes do not render it. -- Below the header: MouseTerm `Wall` embedded fullscreen using `FakePtyAdapter`. The page-level `
` is a flex container so Wall's `flex-1 min-h-0` root receives a real height. -- The playground header uses the active `--vscode-*` theme variables for its background, border, text, and banner colors so theme changes affect the header as well as Wall. - -### Implementation - -- `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. - -## Initial State - -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. -- **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. - -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). - -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. -- **`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. +## Architecture -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: +Three browser-side pieces in `website/src/lib/`, mirroring the pattern in `website/src/lib/ascii-splash-runner.ts` (xterm alt-screen + `FakePtyAdapter` boundary, no Node `terminal-kit` package): -- 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. +- **`tut-runner.ts`** (`TutRunner`) — alt-screen TUI. Subscribes to `TutorialState` and re-renders whenever progress changes. Routes input bytes via `FakePtyAdapter.writePty(id, …)`. +- **`tut-detector.ts`** (`TutDetector`) — wires app events to `TutorialState.markComplete(id)`. Subscribes to `DockviewApi.onDidActivePanelChange`, the `WallEvent` stream, the `subscribeToActivity` store from `mouseterm-lib/lib/terminal-registry`, and the `subscribeToMouseSelection` store from `mouseterm-lib/lib/mouse-selection`. +- **`tutorial-state.ts`** (`TutorialState`) — single in-memory progress store, persisted as a JSON array of completed item ids under the `mouseterm-tut-v3` localStorage key. +- **`tut-items.ts`** — section + item definitions (titles, hints) shared by runner and detector. Item ids are stable; they are the localStorage key suffixes. -Supported CLI options in the playground runner: - -- `--pattern` / `-p` -- `--quality` / `-q` -- `--fps` / `-f` -- `--theme` / `-t` -- `--no-mouse` -- `--help` / `-h` -- `--version` / `-V` - -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 - -`SCENARIO_TUTORIAL_MOTD` (in `lib/src/lib/platform/fake-scenarios.ts`) shows a styled MOTD above the prompt: - -``` - Welcome to MouseTerm. - Type tut to start the interactive tutorial. -``` - -## Tutorial Steps - -Steps are revealed **one at a time** — completing one reveals the next. Each step has a brief contextual prompt explaining *why* you'd do this, not just the mechanic. +## Layout -Progress is stored in localStorage so the user can leave and return. Show progress as `Step N/6` when displaying each step. +- `SiteHeader` at top with the `Theme:` dropdown control on `/playground` (other routes do not render it). Header is `themeAware` so `--vscode-*` variables drive its background, border, text, and banner colors. +- `
` is a flex container so Wall's `flex-1 min-h-0` root gets a real height. +- `Wall` runs `FakePtyAdapter` with `initialMode="passthrough"` and three initial panes: + - **`tut-main`** (left, ~50%) — auto-launches `TutRunner` on mount via `mainShell.runCommand("tut")`. + - **`tut-target`** (right-top, ~25%) — `SCENARIO_SHELL_PROMPT`. Used as the demo pane for keyboard-nav and alert sections. + - **`tut-boxed`** (right-bottom, ~25%) — `SCENARIO_BOXED_PARAGRAPH`. The boxed paragraph for Copy Rewrapped vs Copy Raw. +- The two right-side panes are added in `onApiReady` with `position: { referencePanel, direction }` after Wall creates the initial main pane. -### Detection +Every playground pane gets a `TutorialShell` input handler through `PlaygroundShellRegistry`. Newly split or spawned fake terminals use `SCENARIO_SHELL_PROMPT` by default. The shell dispatches by command name to a `startProgram` factory provided by the page; the factory wires `tut` → `TutRunner` and `ascii-splash` / `splash` → `AsciiSplashRunner`. -Implemented in `website/src/lib/tutorial-detection.ts` (`TutorialDetector` class). Two event sources: +## Tutorial Sections -1. **DockviewApi events** — `onDidAddPanel`, `onDidLayoutChange`, `onDidActivePanelChange`. Subscribed in `TutorialDetector.attach(api)`. -2. **WallEvent callbacks** — `modeChange`, `zoomChange`, `minimizeChange`, `split`. Routed via `Wall`'s `onEvent` prop (added in `lib/src/components/Wall.tsx`). +The runner shows a top-level menu first. Selecting a section drills into its item list. Each section shows `[N/M complete]` next to its title. Inside a section, items render as one of: -### Phase 1: See Everything at Once +- `✓` (green) — complete +- `●` (yellow active marker) — first incomplete item, with hint text shown below. This marker is intentionally static so runner re-renders do not feed the activity monitor. +- `·` (dim) — later incomplete items -**Step 1 — Split a pane** -> You're juggling multiple tasks. Split this terminal so you can watch two things side by side. -> -> *Drag the split button in the tab header, or drag the tab itself to a drop zone.* +Esc / `q` / Ctrl+C pops back one screen (section → menu → exit). Exiting the runner returns the pane to the shell prompt; running `tut` re-enters. -Detection: `onDidAddPanel` fires on DockviewApi (panel count increases beyond initial count). +### Section 1 — Keyboard navigation (7 items) -**Step 2 — Resize your panes** -> One task needs more room. Drag the divider between panes to give it space. -> -> *Drag the gap between two panes.* +| ID | Title | Detection | +|---|---|---| +| `kb-mode` | Enter command mode | `WallEvent.modeChange` to `'command'` (the modifier dual-tap is in the hint) | +| `kb-split-h` | Add a horizontal divider with `-` (or `"`) | `WallEvent.split { source: 'keyboard', direction: 'vertical' }` | +| `kb-arrows` | Move between panes with arrow keys | `onDidActivePanelChange` ≥ 2 distinct panels while in command mode | +| `kb-split-v` | Add a vertical divider with `\|` (or `%`) | `WallEvent.split { source: 'keyboard', direction: 'horizontal' }` | +| `kb-min` | Minimize a pane | `WallEvent.minimizeChange { count > 0 }` | +| `kb-kill` | Kill a pane | `WallEvent.kill` (added to the `WallEvent` union; emitted from `acceptKill` in `Wall.tsx`) | +| `kb-move` | Move a pane with Cmd/Ctrl + arrow | `WallEvent.move` (added to the `WallEvent` union; emitted from `handle-pane-shortcuts.ts` after `swapTerminals`) | -Detection: Captures a `ResizeSnapshot` (serialized grid structure with branch ratios from `api.toJSON()`). On `onDidLayoutChange`, compares current ratios against baseline — triggers when any branch ratio shifts by >= `RESIZE_RATIO_DELTA` (0.08). Baseline resets after splits to avoid false positives. +Prose under the section: "tmux shortcuts also work — `% " d x`." -### Phase 2: Focus and Background +Note: `-` produces a `direction: 'vertical'` split (panes stack top/bottom = horizontal divider); `|` produces `direction: 'horizontal'` (panes side by side = vertical divider). The detector maps event direction → user-facing item accordingly. -**Step 3 — Zoom in, then zoom back out** -> One terminal needs your full attention. Zoom in to focus, then zoom back out when you're done. -> -> *Double-click a tab header to zoom. Double-click again to unzoom.* +### Section 2 — Alert and TODO (6 items) -Detection: Watches `WallEvent.zoomChange` — requires both a `zoomed: true` then `zoomed: false` event (unzoom after zoom). +The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, todo)` transitions. -**Step 4 — Minimize a pane, then bring it back** -> That task is running in the background — you don't need to watch it. Send it to the baseboard, then click its door when you want it back. -> -> *Click the minimize button in the tab header. Click the door in the baseboard to reattach.* +| ID | Title | Detection | +|---|---|---| +| `al-enable` | Enable alerts on a pane (click bell or `a`) | status transitions away from `ALERT_DISABLED` | +| `al-busy` | Watch the bell tilt while a task runs | status enters `BUSY` or `MIGHT_BE_BUSY` | +| `al-ring` | Bell rings on completion | status enters `ALERT_RINGING` | +| `al-todo-auto` | TODO appears when you dismiss the ringing alert | `todo` transitions `false → true` while previous status was `ALERT_RINGING` | +| `al-todo-clear` | Press passthrough Enter to clear the TODO | `todo` transitions `true → false` | +| `al-todo-manual` | Manually add a TODO (`t` or right-click) | `todo` transitions `false → true` while previous status was NOT `ALERT_RINGING` | -Detection: Watches `WallEvent.minimizeChange` — requires `count > 0` (minimize) then `count === 0` (reattach back to zero). +The detector remembers the most recent pane whose alert was enabled. The Alert section view shows a runner-local instruction: "Press `s` here to start a fake busy task." `s` is **not** a real MouseTerm shortcut; it is intercepted by `TutRunner` only while the Alert section is open. When pressed, the runner does two things: -### Phase 3: Keyboard Power +1. Resolves that pane to its current PTY session id, then calls `adapter.pumpActivity(sessionId, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the same alert-enabled session with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. The session id is resolved at trigger time so `Cmd/Ctrl+Arrow` swaps do not leave the tutorial pumping an old pane id. If no alert-enabled pane is known, the runner falls back to `PANE_TARGET`. `BUSY_DEMO_DURATION_MS` is `cfg.alert.userAttention + 250` so silence begins after the attention idle window has expired, with a small scheduler-jitter guard; otherwise the "user is looking at this pane" check inside `ActivityMonitor.startNeedsAttentionConfirmTimer` would suppress the ring rather than let it fire. +2. Animates a countdown in-place where the "Press s…" hint was: `⠋ Fake task will finish in N seconds.` ticking down to 1, then a static `✓ Fake task finished. Press s to start another one.` once the activity stops. Detection is purely timing-based via the existing `ActivityMonitor`, so no shell integration is required. -**Step 5 — Enter command mode and navigate** -> Navigate between panes without touching the mouse. -> -> *Press Escape to enter command mode. Use arrow keys to move between panes.* +### Section 3 — Copy paste (4 items) -Detection: Watches `WallEvent.modeChange` for transition to `'command'`, then tracks `onDidActivePanelChange` — requires focus on >= 2 different panels while in command mode. +The detector subscribes to `subscribeToMouseSelection()` and tracks per-id transitions on `selection`, `copyFlash`, and `override`. -**Step 6 — Split using keyboard shortcuts** -> Split a pane without leaving the keyboard. -> -> *In command mode, press " to split top/bottom or % to split left/right.* +| ID | Title | Detection | +|---|---|---| +| `cp-select` | Drag-select text in any pane | `selection` transitions `null → non-null` | +| `cp-raw` | Click Copy Raw | `copyFlash` transitions to `'raw'` (set by `flashCopy()` after the popup button fires) | +| `cp-rewrap` | Click Copy Rewrapped on the boxed paragraph | `copyFlash` transitions to `'rewrapped'` | +| `cp-override` | Run `ascii-splash`, then click its cursor icon | `override` transitions `'off' → 'temporary' \| 'permanent'` | -Detection: Watches `WallEvent.split` with `source: 'keyboard'` while in command mode. +Prose: +- "Some programs trap the mouse — the cursor icon lets you override." +- "`ascii-splash` redraws every frame, so it cancels selections: looks cool, undragable." -## Completion +The Copy Rewrapped step uses `SCENARIO_BOXED_PARAGRAPH` (in `lib/src/lib/platform/fake-scenarios.ts`). Frame-only and frame-flanking box-drawing runs are stripped by `lib/src/lib/rewrap.ts` so Rewrapped joins the wrapped paragraph; clipboard contents visibly differ from Raw. -When all 6 steps are done, `TutorialShell.announceCompletion()` prints the completion message: +## Lib changes added for this tutorial -``` -You've got it. MouseTerm keeps everything visible and nothing in your way. +- **`WallEvent.kill`** and **`WallEvent.move`** — new discriminants on the `WallEvent` union (`lib/src/components/wall/wall-types.ts`). `kill` fires from `acceptKill` in `Wall.tsx`. `move` fires from `handle-pane-shortcuts.ts` after the Cmd/Ctrl-Arrow swap, via a new `fireEvent` callback added to `WallKeyboardCtx`. +- **`FakePtyAdapter.pumpActivity(id, durationMs, intervalMs)`** — drives the alert-manager for a fixed duration with no data output. The runner uses this so the bell on the demo pane tilts/rings while the visible "task running" animation lives entirely inside the tutorial pane. +- **`FakePtyAdapter.sendOutput(id, data)`** — pushes data through the data handlers as if the PTY produced it, also driving `alertManager.onData()`. Used by `TutRunner` and `AsciiSplashRunner` so browser-side echoes still feed the activity monitor. +- **`SCENARIO_BOXED_PARAGRAPH`** — boxed multi-line prose, used by `tut-boxed`. -Ready to try the real thing? - → Download MouseTerm: mouseterm.com/#download +`SCENARIO_TUTORIAL_MOTD` was removed — the runner now owns the main pane's screen. -Or keep exploring — this sandbox is yours. -``` +## Storage -The sandbox stays fully functional after completion. Running `tut` shows "Tutorial complete" instead of a step. `tut reset` restarts from step 1. +- Completion: `localStorage["mouseterm-tut-v3"] = JSON.stringify([...completedItemIds])`. Removed on `TutorialState.reset()`. Unknown ids in a stored payload are filtered out on load, so renaming an id is a one-way reset for that item. +- Legacy keys `mouseterm-tutorial-step-N` and `mouseterm-tut-v2-*` from previous designs are not read; new playground sessions get a fresh start. ## Theme Picker @@ -164,68 +113,38 @@ Each theme is defined as a map of `--vscode-*` CSS variable overrides. `applyThe The picker restores the persisted active theme on mount. The playground header is `themeAware`, so the same active theme also affects the site header chrome while the picker remains hidden on non-playground routes. -## Technical Notes - -- 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`. 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 +The Playground is the primary dogfood surface for the features in `docs/specs/mouse-and-clipboard.md`. The new tutorial layout (`tut-main` running the runner, `tut-target` shell, `tut-boxed` boxed paragraph) plus the user-launched `ascii-splash` pane covers most of the spec; one notable gap remains. Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable. | Spec § | Feature | Status | Why | |---|---|---|---| -| §1 | Mouse icon visible when program requests reporting | ✅ | Run `ascii-splash`; the runner emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h` unless `--no-mouse` is used. | +| §1 | Mouse icon visible when program requests reporting | ✅ | Run `ascii-splash`; the runner emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h`. | | §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.3 | "Press e to select the full URL/path" hint | ❌ | No qualifying tokens in the live scenarios. | | §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 | ✅ | `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. | +| §4.1.2 | Copy Rewrapped (box-strip + paragraph unwrap) | ✅ | `SCENARIO_BOXED_PARAGRAPH` provides a boxed paragraph in `tut-boxed`. | | §4.2 | Cmd+C / Cmd+Shift+C | ✅ | Any selection. | | §4.3 | Esc / click-outside dismiss | ✅ | Any selection popup. | | §5 | Smart-extension (URL / abs path / rel path / Windows path / error location) | ❌ | No matching tokens in the scenarios. | | §5.3 | Press `e` to extend | ❌ | Blocked on §5 coverage. | -| §8.2 | Cmd+V / Cmd+Shift+V / Ctrl+V / Ctrl+Shift+V paste | ⚠️ | The shortcut fires and writes to the fake PTY, but `TutorialShell.handleInput` (`website/src/lib/tutorial-shell.ts:77-96`) echoes characters one by one and does not interpret bracketed-paste markers. | +| §8.2 | Cmd+V / Cmd+Shift+V / Ctrl+V / Ctrl+Shift+V paste | ⚠️ | The shortcut fires and writes to the fake PTY, but `TutorialShell.handleInput` echoes characters one by one and does not interpret bracketed-paste markers. | | §8.5 | Bracketed paste wraps `\e[200~ … \e[201~` | ❌ | No scenario emits `\x1b[?2004h`, so `getMouseSelectionState(id).bracketedPaste` stays `false` and `doPaste` sends the raw text. | `§3.6` auto-scroll and `§8.7` right-click paste are deferred in the implementation itself — not Playground gaps. -### 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. Together with `ascii-splash`, these close the remaining content-shape gaps. - -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: - - ``` - ✗ src/components/wall/TerminalPaneHeader.tsx:157:7 — unused import - ✗ ../sibling/util.rs:42 — panic here - see https://en.wikipedia.org/wiki/Foo_(bar) - docs: /usr/local/share/doc/mouseterm/README - cwd: ~/projects/mouseterm - windows: C:\Users\me\work.log - ``` - - Dragging across any of them shows "Press e to select the full URL/path" and `e` extends. +### Follow-up scenarios -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. +Two scenarios from the previous spec's remediation plan remain useful: -**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. +1. **`SCENARIO_BRACKETED_PASTE_TUI`** — closes §8.5. Emits `\x1b[?2004h` and an idle ANSI-framed view; pastes into it would be wrapped `\x1b[200~ … \x1b[201~`. +2. **`SCENARIO_SMART_TOKENS`** — closes §3.3 extension hint and §5.1–§5.3. Prints one of each detectable shape from `lib/src/lib/smart-token.ts`'s `PATTERNS`. -**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`. +These can be added without changing the tutorial's three sections — they would expand the `tut-boxed` neighbor or replace it depending on layout decisions at the time. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index d41f3c1..08f2557 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -87,6 +87,7 @@ const tabComponents = { terminal: TerminalPaneHeader }; export function Wall({ initialPaneIds, + initialMode = 'command', restoredLayout, initialDoors, onApiReady, @@ -94,6 +95,7 @@ export function Wall({ baseboardNotice, }: { initialPaneIds?: string[]; + initialMode?: WallMode; restoredLayout?: unknown; initialDoors?: PersistedDoor[]; onApiReady?: (api: DockviewApi) => void; @@ -147,7 +149,7 @@ export function Wall({ }, []); // We own these — dockview is just for spatial layout and DnD - const [mode, setMode] = useState('command'); + const [mode, setMode] = useState(initialMode); const [selectedId, setSelectedId] = useState(null); const [selectedType, setSelectedType] = useState('pane'); @@ -185,6 +187,13 @@ export function Wall({ if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); }, []); + // --- External event notifications --- + const onEventRef = useRef(onEvent); + onEventRef.current = onEvent; + const fireEvent = useCallback((event: WallEvent) => { + onEventRef.current?.(event); + }, []); + // Confirm runs orchestrateKill concurrently with the letter flash so the // pane fade begins while the flash is still playing. const rejectKill = useCallback(() => { @@ -198,12 +207,9 @@ export function Wall({ if (!ck || ck.exit) return; setConfirmKill({ ...ck, exit: 'confirm' }); onExit(); + fireEvent({ type: 'kill', id: ck.id }); confirmTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_CONFIRM_MS); - }, []); - - // --- External event notifications --- - const onEventRef = useRef(onEvent); - onEventRef.current = onEvent; + }, [fireEvent]); useEffect(() => { onEventRef.current?.({ type: 'modeChange', mode }); }, [mode]); useEffect(() => { onEventRef.current?.({ type: 'zoomChange', zoomed }); }, [zoomed]); @@ -555,6 +561,7 @@ export function Wall({ setConfirmKill, setRenamingPaneId, setSelectedId, + fireEvent, }); // --- Render --- diff --git a/lib/src/components/wall/keyboard/handle-pane-navigation.ts b/lib/src/components/wall/keyboard/handle-pane-navigation.ts index 12645cd..7b66656 100644 --- a/lib/src/components/wall/keyboard/handle-pane-navigation.ts +++ b/lib/src/components/wall/keyboard/handle-pane-navigation.ts @@ -10,7 +10,7 @@ export function handlePaneNavigation( ctx: WallKeyboardCtx, navHistory: NavHistoryRef, ): boolean { - if (!isArrowKey(e.key) || e.metaKey) { + if (!isArrowKey(e.key) || e.metaKey || e.ctrlKey) { return false; } e.preventDefault(); diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts index 984ba40..62ab782 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts @@ -53,7 +53,7 @@ export function handlePaneShortcuts( return true; } - if (isArrowKey(e.key) && e.metaKey) { + if (isArrowKey(e.key) && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); if (!sid) return true; @@ -70,6 +70,7 @@ export function handlePaneShortcuts( swapTerminals(sid, targetId); swapPanelTitles(api, sid, targetId); + ctx.fireEvent({ type: 'move', fromId: sid, toId: targetId }); navHistory.current = { direction: dir, fromId: sid }; ctx.selectPane(targetId); diff --git a/lib/src/components/wall/keyboard/types.ts b/lib/src/components/wall/keyboard/types.ts index 25c935d..4653b77 100644 --- a/lib/src/components/wall/keyboard/types.ts +++ b/lib/src/components/wall/keyboard/types.ts @@ -1,7 +1,7 @@ import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { DockviewApi } from 'dockview-react'; import type { ConfirmKill } from '../../KillConfirm'; -import type { DooredItem, WallMode, WallSelectionKind } from '../wall-types'; +import type { DooredItem, WallEvent, WallMode, WallSelectionKind } from '../wall-types'; import type { WallActions } from '../wall-context'; /** Refs + callbacks shared by every keyboard branch. Bundled to avoid 25-arg @@ -30,6 +30,7 @@ export interface WallKeyboardCtx { setConfirmKill: Dispatch>; setRenamingPaneId: Dispatch>; setSelectedId: Dispatch>; + fireEvent: (event: WallEvent) => void; } /** Per-press dual-tap state — left-Meta then right-Meta within 500ms exits diff --git a/lib/src/components/wall/wall-types.ts b/lib/src/components/wall/wall-types.ts index 5e8fe6a..8097c6f 100644 --- a/lib/src/components/wall/wall-types.ts +++ b/lib/src/components/wall/wall-types.ts @@ -14,6 +14,8 @@ export type WallEvent = | { type: 'zoomChange'; zoomed: boolean } | { type: 'minimizeChange'; count: number } | { type: 'split'; direction: 'horizontal' | 'vertical'; source: 'keyboard' | 'mouse' } - | { type: 'selectionChange'; id: string | null; kind: WallSelectionKind }; + | { type: 'selectionChange'; id: string | null; kind: WallSelectionKind } + | { type: 'kill'; id: string } + | { type: 'move'; fromId: string; toId: string }; export type SpawnDirection = 'left' | 'top' | 'top-left'; diff --git a/lib/src/lib/ansi.ts b/lib/src/lib/ansi.ts new file mode 100644 index 0000000..ffd60f7 --- /dev/null +++ b/lib/src/lib/ansi.ts @@ -0,0 +1,26 @@ +// Shared ANSI escape sequences for browser-side TUI runners and fake +// scenarios. Anything that emits raw ANSI to xterm.js should import from +// here rather than rolling its own ESC = "\x1b[" copy. + +export const ESC = "\x1b["; +export const RESET = `${ESC}0m`; +export const BOLD = `${ESC}1m`; +export const DIM = `${ESC}2m`; +export const ITALIC = `${ESC}3m`; +export const FG_DEFAULT = `${ESC}39m`; +export const CLEAR_LINE = `${ESC}2K`; +export const CLEAR_SCREEN = `${ESC}2J`; +export const CURSOR_HOME = `${ESC}H`; + +// Standard 16-color foregrounds. `fg(36)` etc. for arbitrary codes. +export const fg = (code: number): string => `${ESC}${code}m`; + +// Alt-screen toggles paired with full clear + cursor visibility flips. +// Use these for full-screen TUIs (tut, ascii-splash) so exiting restores +// whatever was on screen before. +export const ENTER_ALT_SCREEN = `${ESC}?1049h${CLEAR_SCREEN}${CURSOR_HOME}${ESC}?25l`; +export const LEAVE_ALT_SCREEN = `${CLEAR_SCREEN}${CURSOR_HOME}${ESC}?25h${ESC}?1049l`; + +// Stylized `user@mouseterm:~$ ` prompt used by the playground shell and +// by canned scenarios so they look the same. +export const PROMPT = `${fg(32)}user${RESET}@${fg(36)}mouseterm${RESET}:${BOLD}${fg(34)}~${RESET}$ `; diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 9b18dc4..513c10e 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -5,6 +5,10 @@ export interface FakeScenario { name: string; chunks: { delay: number; data: string }[]; exitCode?: number; + /** Set to true when the final chunk leaves the pty at a shell prompt. + * The playground shell registry consults this to avoid printing a + * duplicate prompt on first user input. */ + endsWithPrompt?: boolean; } export interface FakePtySize { @@ -23,6 +27,7 @@ export class FakePtyAdapter implements PlatformAdapter { 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 spawnHandlers = new Set<(detail: { id: string }) => void>(); private terminals = new Set(); private terminalSizes = new Map(); private activeTimers = new Map[]>(); @@ -72,6 +77,7 @@ export class FakePtyAdapter implements PlatformAdapter { this.dataHandlers.clear(); this.exitHandlers.clear(); this.resizeHandlers.clear(); + this.spawnHandlers.clear(); this.inputHandlers.clear(); this.alertManager.dispose(); this.alertManager = new AlertManager(); @@ -92,12 +98,19 @@ export class FakePtyAdapter implements PlatformAdapter { cols: options?.cols ?? DEFAULT_PTY_SIZE.cols, rows: options?.rows ?? DEFAULT_PTY_SIZE.rows, }); - const scenario = this.scenarioMap.get(id) ?? this.defaultScenario; + for (const handler of this.spawnHandlers) { + handler({ id }); + } + const scenario = this.resolveScenario(id); if (scenario) { this.playScenario(id, scenario); } } + private resolveScenario(id: string): FakeScenario | null { + return this.scenarioMap.get(id) ?? this.defaultScenario; + } + writePty(id: string, data: string): void { if (!this.terminals.has(id)) return; // Only echo if no scenario is actively playing @@ -162,6 +175,16 @@ export class FakePtyAdapter implements PlatformAdapter { return this.terminalSizes.get(id) ?? DEFAULT_PTY_SIZE; } + hasPty(id: string): boolean { + return this.terminals.has(id); + } + + /** True when the scenario assigned to `id` (or the default scenario, if + * no per-id scenario is set) leaves the pty at a shell prompt. */ + scenarioEndsWithPrompt(id: string): boolean { + return this.resolveScenario(id)?.endsWithPrompt === true; + } + async readClipboardFilePaths(): Promise { return null; } async readClipboardImageAsFilePath(): Promise { return null; } @@ -176,6 +199,14 @@ export class FakePtyAdapter implements PlatformAdapter { this.resizeHandlers.delete(handler); }; } + /** Fires synchronously inside `spawnPty(id)` after the pty is registered + * but before its scenario starts playing. Returns an unsubscribe fn. */ + onPtySpawn(handler: (detail: { id: string }) => void): () => void { + this.spawnHandlers.add(handler); + return () => { + this.spawnHandlers.delete(handler); + }; + } onRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {} offRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {} notifySessionFlushComplete(_requestId: string): void {} @@ -209,14 +240,62 @@ export class FakePtyAdapter implements PlatformAdapter { this.inputHandlers.delete(id); } - /** Send data to a terminal's output (as if the PTY produced it). */ - sendOutput(id: string, data: string): void { + /** + * Send data to a terminal's output (as if the PTY produced it). Drives + * the alert-manager's activity feed the same way real PTY data does in + * the Tauri/VSCode adapters — without this, browser-side echo (e.g. + * TutorialShell's per-character echo, AsciiSplashRunner frames) never + * reaches the activity monitor and the bell can never tilt or ring. + * + * Pass `{ skipActivity: true }` for writes that are pure UI chrome and + * shouldn't count as a "task is active" signal — e.g. a tutorial TUI + * re-rendering its menu on state change. Without the opt-out, every + * runner frame would tilt the bell on whichever pane hosts the runner. + */ + sendOutput(id: string, data: string, options: { skipActivity?: boolean } = {}): void { if (!this.terminals.has(id)) return; + if (!options.skipActivity) this.alertManager.onData(id); for (const handler of this.dataHandlers) { handler({ id, data }); } } + /** + * Drive the alert-manager's activity monitor for a fixed duration with + * no data output — useful for animating a fake "task running" state on + * a pane while the visual feedback lives elsewhere. Calls + * `alertManager.onData(id)` immediately, then again every `intervalMs` + * until `durationMs` elapses, after which silence resumes and the bell + * transitions naturally to MIGHT_NEED_ATTENTION → ALERT_RINGING. + * Returns a dispose handle that cancels remaining ticks. + */ + pumpActivity(id: string, durationMs: number, intervalMs = 1000): () => void { + if (!this.terminals.has(id)) return () => {}; + let cancelled = false; + let interval: ReturnType | null = null; + let stop: ReturnType | null = null; + const cancel = () => { + if (cancelled) return; + cancelled = true; + if (interval !== null) clearInterval(interval); + if (stop !== null) clearTimeout(stop); + }; + this.alertManager.onData(id); + const tick = () => { + if (cancelled) return; + // Pty may have been killed mid-duration. Stop pumping rather than + // feeding the activity monitor for a terminal that no longer exists. + if (!this.terminals.has(id)) { + cancel(); + return; + } + this.alertManager.onData(id); + }; + interval = setInterval(tick, intervalMs); + stop = setTimeout(cancel, durationMs); + return cancel; + } + private playScenario(id: string, scenario: FakeScenario): void { const timers: ReturnType[] = []; this.activeTimers.set(id, timers); diff --git a/lib/src/lib/platform/fake-scenarios.ts b/lib/src/lib/platform/fake-scenarios.ts index f042e7a..a7e0835 100644 --- a/lib/src/lib/platform/fake-scenarios.ts +++ b/lib/src/lib/platform/fake-scenarios.ts @@ -1,3 +1,4 @@ +import { BOLD, PROMPT, RESET, fg } from '../ansi'; import type { FakeScenario } from './fake-adapter'; // --- Helpers for building scenarios --- @@ -59,33 +60,13 @@ export function flattenScenario(scenario: FakeScenario): FakeScenario { }; } -// ANSI helpers -const ESC = '\x1b['; -const RESET = `${ESC}0m`; -const BOLD = `${ESC}1m`; -const fg = (code: number) => `${ESC}${code}m`; - -const PROMPT = `${fg(32)}user${RESET}@${fg(36)}mouseterm${RESET}:${BOLD}${fg(34)}~${RESET}$ `; - // --- Scenarios --- /** Simple shell prompt — waits 500ms then shows prompt. Stays alive for interaction. */ export const SCENARIO_SHELL_PROMPT: FakeScenario = { name: 'shell-prompt', chunks: [instant(PROMPT, 500)], -}; - -/** Tutorial MOTD — welcome message above the prompt, styled with dim text. */ -export const SCENARIO_TUTORIAL_MOTD: FakeScenario = { - name: 'tutorial-motd', - chunks: [ - instant( - `\r\n${fg(90)} Welcome to MouseTerm.${RESET}\r\n` + - `${fg(90)} Type ${fg(36)}tut${fg(90)} to start the interactive tutorial.${RESET}\r\n\r\n`, - 300, - ), - instant(PROMPT, 200), - ], + endsWithPrompt: true, }; /** Types `ls -la` then shows colorized directory listing. */ @@ -112,6 +93,7 @@ export const SCENARIO_LS_OUTPUT: FakeScenario = { ), instant(PROMPT, 200), ], + endsWithPrompt: true, }; /** Demonstrates all 16 ANSI colors with labels. */ @@ -138,6 +120,7 @@ export const SCENARIO_ANSI_COLORS: FakeScenario = { instant(`\r\n`, 100), instant(PROMPT, 200), ], + endsWithPrompt: true, }; /** Shows a long-running process with progress dots. */ @@ -153,6 +136,36 @@ export const SCENARIO_LONG_RUNNING: FakeScenario = { instant(`\r\nadded 847 packages in 5.2s\r\n\r\n`, 100), instant(PROMPT, 200), ], + endsWithPrompt: true, +}; + +/** + * Boxed paragraph for Copy Rewrapped vs Copy Raw demonstration. The frame + * is pure box-drawing characters so `rewrap.ts` strips them; the text + * inside wraps across lines so Rewrapped joins them with single spaces. + */ +export const SCENARIO_BOXED_PARAGRAPH: FakeScenario = { + name: 'boxed-paragraph', + chunks: [ + instant( + [ + '', + `${fg(36)}┌─────────────────────────────────────────┐${RESET}`, + `${fg(36)}│${RESET} ${BOLD}Release notes — v1.4.0${RESET} ${fg(36)}│${RESET}`, + `${fg(36)}├─────────────────────────────────────────┤${RESET}`, + `${fg(36)}│${RESET} MouseTerm now keeps a tab visible ${fg(36)}│${RESET}`, + `${fg(36)}│${RESET} even while a long-running command is ${fg(36)}│${RESET}`, + `${fg(36)}│${RESET} hidden in the baseboard, so background ${fg(36)}│${RESET}`, + `${fg(36)}│${RESET} work never gets lost. ${fg(36)}│${RESET}`, + `${fg(36)}│${RESET} ${fg(36)}│${RESET}`, + `${fg(36)}│${RESET} Drag-select the paragraph above and ${fg(36)}│${RESET}`, + `${fg(36)}│${RESET} try Copy Raw vs Copy Rewrapped. ${fg(36)}│${RESET}`, + `${fg(36)}└─────────────────────────────────────────┘${RESET}`, + '', + ].join('\r\n'), + 400, + ), + ], }; /** Rapid output burst — tests xterm.js scroll performance. */ diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index b94aec1..4da2477 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -165,6 +165,7 @@ function setupTerminalEntry(id: string): TerminalEntry { } registry.set(id, entry); + notifyActivityListeners(); startThemeObserver(); return entry; } diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap new file mode 100644 index 0000000..18764ac --- /dev/null +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -0,0 +1,89 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TutRunner snapshots > renders Alert and TODO with all items incomplete 1`] = ` +" + Alert and TODO 0/6 complete + Esc to go back + + ● Enable alerts on a pane + Click the bell on the pane you want to use, or press a in command mode + with that pane selected. + · Watch the bell tilt while a task runs + · Bell rings when the task completes + · TODO tag appears when you dismiss the ringing alert + · Press Enter inside the pane to clear the TODO + · Manually add a TODO + + Press s here to start a fake busy task. +" +`; + +exports[`TutRunner snapshots > renders Copy paste with all items incomplete 1`] = ` +" + Copy paste 0/4 complete + Esc to go back + + ● Drag-select some text + The paragraph below is a good example — "Some terminal programs..." + · Copy-paste it somewhere else with "Copy Raw" + · Copy-paste it somewhere else with "Copy Rewrapped" + · Run ascii-splash, then click its cursor icon + + Some terminal programs trap the cursor, and some do not. This tutorial pane + does not trap the cursor, so MouseTerm does not show a cursor icon. The + ascii-splash program traps the cursor — that is how it is able to respond to + mouse movement. lazygit is an excellent and popular program which traps the + cursor. +" +`; + +exports[`TutRunner snapshots > renders Keyboard navigation with all items complete 1`] = ` +" + Keyboard navigation 7/7 complete + Esc to go back + + ✓ Enter command mode + ✓ Add a horizontal divider + ✓ Move between panes with arrow keys + ✓ Add a vertical divider + ✓ Minimize a pane + ✓ Kill a pane + ✓ Move a pane with Cmd/Ctrl + arrow + + tmux shortcuts also work — % " d x. + + Section complete. Press Esc to go back. +" +`; + +exports[`TutRunner snapshots > renders Keyboard navigation with all items incomplete 1`] = ` +" + Keyboard navigation 0/7 complete + Esc to go back + + ● Enter command mode + Press LShift then RShift quickly (or LCmd then RCmd on Mac). + · Add a horizontal divider + · Move between panes with arrow keys + · Add a vertical divider + · Minimize a pane + · Kill a pane + · Move a pane with Cmd/Ctrl + arrow + + tmux shortcuts also work — % " d x. +" +`; + +exports[`TutRunner snapshots > renders the top-level menu 1`] = ` +" + MouseTerm Playground Tutorial + 0/17 complete · Esc/q to exit · Enter to open · ↑↓ to navigate + + ❯ Keyboard navigation [0/7 complete] + Alert and TODO [0/6 complete] + Copy paste [0/4 complete] + + Reset progress + +" +`; diff --git a/website/src/lib/ascii-splash-runner.ts b/website/src/lib/ascii-splash-runner.ts index 716f2fe..4299c44 100644 --- a/website/src/lib/ascii-splash-runner.ts +++ b/website/src/lib/ascii-splash-runner.ts @@ -55,6 +55,7 @@ import { StarfieldPattern } from "ascii-splash-internal/patterns/StarfieldPatter 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 { ENTER_ALT_SCREEN, LEAVE_ALT_SCREEN } from "mouseterm-lib/lib/ansi"; import type { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; import type { InteractiveProgram } from "./tutorial-shell"; @@ -95,8 +96,6 @@ interface KeyInput { 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", diff --git a/website/src/lib/playground-shells.test.ts b/website/src/lib/playground-shells.test.ts index cc20bec..61e691d 100644 --- a/website/src/lib/playground-shells.test.ts +++ b/website/src/lib/playground-shells.test.ts @@ -32,6 +32,32 @@ describe("PlaygroundShellRegistry", () => { expect(output.two.join("")).toContain("$ "); }); + it("does not print a duplicate prompt when the scenario already provided one", () => { + vi.useFakeTimers(); + try { + const adapter = new FakePtyAdapter(); + const output: string[] = []; + adapter.onPtyData((detail) => output.push(detail.data)); + adapter.setScenario("one", { + name: "with-prompt", + chunks: [{ delay: 0, data: "PROMPT " }], + endsWithPrompt: true, + }); + adapter.spawnPty("one"); + vi.runAllTimers(); + output.length = 0; + + const registry = new PlaygroundShellRegistry(adapter, () => createProgram()); + registry.ensureShell("one"); + + adapter.writePty("one", "x"); + + expect(output.join("")).toBe("x"); + } finally { + vi.useRealTimers(); + } + }); + it("starts interactive programs against the active terminal id", () => { const adapter = new FakePtyAdapter(); const program = createProgram(); @@ -42,7 +68,12 @@ describe("PlaygroundShellRegistry", () => { registry.ensureShell("two"); adapter.writePty("two", "ascii-splash --no-mouse\r"); - expect(startProgram).toHaveBeenCalledWith("two", ["--no-mouse"], expect.any(Function)); + expect(startProgram).toHaveBeenCalledWith( + "two", + "ascii-splash", + ["--no-mouse"], + expect.any(Function), + ); expect(program.start).toHaveBeenCalledTimes(1); }); diff --git a/website/src/lib/playground-shells.ts b/website/src/lib/playground-shells.ts index 0affeb6..6708b76 100644 --- a/website/src/lib/playground-shells.ts +++ b/website/src/lib/playground-shells.ts @@ -3,9 +3,10 @@ import { TutorialShell, type InteractiveProgram } from "./tutorial-shell"; export type StartPlaygroundProgram = ( terminalId: string, + name: string, args: string[], onExit: () => void, -) => InteractiveProgram; +) => InteractiveProgram | null; export class PlaygroundShellRegistry { private adapter: FakePtyAdapter; @@ -27,7 +28,8 @@ export class PlaygroundShellRegistry { const shell = new TutorialShell( (data) => this.adapter.sendOutput(id, data), - (args, onExit) => this.startProgram(id, args, onExit), + (name, args, onExit) => this.startProgram(id, name, args, onExit), + { promptShown: this.adapter.scenarioEndsWithPrompt(id) }, ); this.shells.set(id, shell); this.adapter.setInputHandler(id, (data) => shell.handleInput(data)); @@ -42,7 +44,7 @@ export class PlaygroundShellRegistry { disposeAll(): void { this.adapter.offPtyExit(this.handlePtyExit); - for (const id of this.shells.keys()) { + for (const id of [...this.shells.keys()]) { this.disposeShell(id); } } diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts new file mode 100644 index 0000000..c8a01d7 --- /dev/null +++ b/website/src/lib/tut-detector.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_MOUSE_SELECTION_STATE, type MouseSelectionState } from "mouseterm-lib/lib/mouse-selection"; +import type { ActivityState } from "mouseterm-lib/lib/terminal-registry"; +import { TutDetector } from "./tut-detector"; +import { TutorialState } from "./tutorial-state"; + +function makeDetectorHarness() { + let activePanelListener: ((panel: { id?: string } | undefined) => void) | null = null; + let activityListener: (() => void) | null = null; + let mouseListener: (() => void) | null = null; + let activitySnapshot = new Map(); + let mouseSnapshot = new Map(); + const onAlertDemoPaneChange = vi.fn(); + + const state = new TutorialState(); + const detector = new TutDetector( + state, + { + getActivitySnapshot: () => activitySnapshot, + subscribeToActivity: (listener) => { + activityListener = listener; + return () => { + activityListener = null; + }; + }, + }, + { + getMouseSelectionSnapshot: () => mouseSnapshot, + subscribeToMouseSelection: (listener) => { + mouseListener = listener; + return () => { + mouseListener = null; + }; + }, + }, + { onAlertDemoPaneChange }, + ); + + detector.attach({ + onDidActivePanelChange: (listener: (panel: { id?: string } | undefined) => void) => { + activePanelListener = listener; + return { dispose: vi.fn() }; + }, + }); + + return { + state, + detector, + setActivitySnapshot: (snapshot: Map) => { + activitySnapshot = snapshot; + activityListener?.(); + }, + setMouseSnapshot: (snapshot: Map) => { + mouseSnapshot = snapshot; + mouseListener?.(); + }, + activePanelChange: (id: string) => activePanelListener?.({ id }), + onAlertDemoPaneChange, + }; +} + +describe("TutDetector", () => { + it("credits the first user text selection even when the pane has no prior mouse state", () => { + const { state, setMouseSnapshot } = makeDetectorHarness(); + + setMouseSnapshot(new Map([ + ["pane-a", { + ...DEFAULT_MOUSE_SELECTION_STATE, + selection: { + startRow: 0, + startCol: 0, + endRow: 0, + endCol: 4, + shape: "linewise", + dragging: true, + startedInScrollback: false, + }, + }], + ])); + + expect(state.isComplete("cp-select")).toBe(true); + }); + + it("credits arrow navigation after the first move away from the command-mode origin pane", () => { + const { state, detector, activePanelChange } = makeDetectorHarness(); + + detector.handleWallEvent({ type: "selectionChange", id: "pane-a", kind: "pane" }); + detector.handleWallEvent({ type: "modeChange", mode: "passthrough" }); + detector.handleWallEvent({ type: "modeChange", mode: "command" }); + activePanelChange("pane-b"); + + expect(state.isComplete("kb-arrows")).toBe(true); + }); + + it("does not credit kb-arrows for the focus change that follows a Cmd/Ctrl+Arrow swap", () => { + const { state, detector, activePanelChange } = makeDetectorHarness(); + + detector.handleWallEvent({ type: "selectionChange", id: "pane-a", kind: "pane" }); + detector.handleWallEvent({ type: "modeChange", mode: "passthrough" }); + detector.handleWallEvent({ type: "modeChange", mode: "command" }); + detector.handleWallEvent({ type: "move", fromId: "pane-a", toId: "pane-b" }); + activePanelChange("pane-b"); + + expect(state.isComplete("kb-move")).toBe(true); + expect(state.isComplete("kb-arrows")).toBe(false); + + // A subsequent plain arrow nav to a third pane should still credit kb-arrows. + activePanelChange("pane-c"); + expect(state.isComplete("kb-arrows")).toBe(true); + }); + + it("does not credit al-busy or al-ring when a pane is already in that status at first observation", () => { + const { state, setActivitySnapshot } = makeDetectorHarness(); + + setActivitySnapshot(new Map([ + ["pane-a", { status: "BUSY", todo: false }], + ["pane-b", { status: "ALERT_RINGING", todo: false }], + ])); + + expect(state.isComplete("al-busy")).toBe(false); + expect(state.isComplete("al-ring")).toBe(false); + }); + + it("credits al-busy and al-ring on a true status transition", () => { + const { state, setActivitySnapshot } = makeDetectorHarness(); + + setActivitySnapshot(new Map([ + ["pane-a", { status: "NOTHING_TO_SHOW", todo: false }], + ])); + setActivitySnapshot(new Map([ + ["pane-a", { status: "BUSY", todo: false }], + ])); + expect(state.isComplete("al-busy")).toBe(true); + + setActivitySnapshot(new Map([ + ["pane-a", { status: "ALERT_RINGING", todo: false }], + ])); + expect(state.isComplete("al-ring")).toBe(true); + }); + + it("credits al-enable after a newly-created pane is first observed disabled", () => { + const { state, setActivitySnapshot } = makeDetectorHarness(); + + setActivitySnapshot(new Map([ + ["pane-a", { status: "ALERT_DISABLED", todo: false }], + ])); + setActivitySnapshot(new Map([ + ["pane-a", { status: "NOTHING_TO_SHOW", todo: false }], + ])); + + expect(state.isComplete("al-enable")).toBe(true); + }); + + it("tracks the pane whose alert was enabled for the busy demo", () => { + const { onAlertDemoPaneChange, setActivitySnapshot } = makeDetectorHarness(); + + setActivitySnapshot(new Map([ + ["pane-a", { status: "ALERT_DISABLED", todo: false }], + ["pane-b", { status: "ALERT_DISABLED", todo: false }], + ])); + setActivitySnapshot(new Map([ + ["pane-a", { status: "ALERT_DISABLED", todo: false }], + ["pane-b", { status: "NOTHING_TO_SHOW", todo: false }], + ])); + + expect(onAlertDemoPaneChange).toHaveBeenLastCalledWith("pane-b"); + + setActivitySnapshot(new Map([ + ["pane-a", { status: "ALERT_DISABLED", todo: false }], + ["pane-b", { status: "ALERT_DISABLED", todo: false }], + ])); + + expect(onAlertDemoPaneChange).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts new file mode 100644 index 0000000..84a3460 --- /dev/null +++ b/website/src/lib/tut-detector.ts @@ -0,0 +1,246 @@ +import { DEFAULT_MOUSE_SELECTION_STATE } from "mouseterm-lib/lib/mouse-selection"; +import type { TutorialState } from "./tutorial-state"; + +interface DockviewApi { + activePanel?: { id?: string } | null; + onDidActivePanelChange: ( + listener: (panel: { id?: string } | undefined) => void, + ) => { dispose: () => void }; +} +type WallEvent = import("mouseterm-lib/components/Wall").WallEvent; +type WallMode = import("mouseterm-lib/components/Wall").WallMode; +type ActivityState = import("mouseterm-lib/lib/terminal-registry").ActivityState; +type MouseSelectionState = import("mouseterm-lib/lib/mouse-selection").MouseSelectionState; + +interface ActivityStoreModule { + subscribeToActivity: (listener: () => void) => () => void; + getActivitySnapshot: () => Map; +} + +interface MouseSelectionModule { + subscribeToMouseSelection: (listener: () => void) => () => void; + getMouseSelectionSnapshot: () => Map; +} + +interface TutDetectorOptions { + onAlertDemoPaneChange?: (id: string | null) => void; +} + +export class TutDetector { + private state: TutorialState; + private activityStore: ActivityStoreModule; + private mouseStore: MouseSelectionModule; + private onAlertDemoPaneChange?: (id: string | null) => void; + private api: DockviewApi | null = null; + private currentMode: WallMode = "command"; + private currentPaneId: string | null = null; + private commandModePanels = new Set(); + private alertEnabledPaneIds = new Set(); + private preferredAlertPaneId: string | null = null; + private pendingMoveTargetId: string | null = null; + private prevActivity = new Map(); + private prevMouse = new Map(); + private disposables: (() => void)[] = []; + + constructor( + state: TutorialState, + activityStore: ActivityStoreModule, + mouseStore: MouseSelectionModule, + options: TutDetectorOptions = {}, + ) { + this.state = state; + this.activityStore = activityStore; + this.mouseStore = mouseStore; + this.onAlertDemoPaneChange = options.onAlertDemoPaneChange; + } + + attach(api: DockviewApi): void { + if (this.api) { + throw new Error("TutDetector.attach called twice"); + } + this.api = api; + // Seed previous-state maps so the very first listener fire isn't + // mis-read as a transition from "nothing". + for (const [id, s] of this.activityStore.getActivitySnapshot()) { + this.prevActivity.set(id, { ...s }); + if (s.status !== "ALERT_DISABLED") { + this.alertEnabledPaneIds.add(id); + this.preferredAlertPaneId ??= id; + } + } + this.emitAlertDemoPaneChange(); + for (const [id, s] of this.mouseStore.getMouseSelectionSnapshot()) { + this.prevMouse.set(id, { ...s }); + } + + const activeUnsub = api.onDidActivePanelChange((panel: { id?: string } | undefined) => { + if (!panel?.id) return; + if (this.currentMode !== "command") return; + // Cmd/Ctrl+Arrow fires `move` then re-selects the swap target, which + // would otherwise grow commandModePanels to 2 and credit `kb-arrows` + // even though the user never pressed a bare arrow key. Consume the + // expected focus-change so only true arrow navigation counts. + if (this.pendingMoveTargetId === panel.id) { + this.pendingMoveTargetId = null; + return; + } + this.commandModePanels.add(panel.id); + if (this.commandModePanels.size >= 2) { + this.state.markComplete("kb-arrows"); + } + }); + this.disposables.push(() => activeUnsub.dispose()); + + this.disposables.push( + this.activityStore.subscribeToActivity(() => this.processActivity()), + ); + this.disposables.push( + this.mouseStore.subscribeToMouseSelection(() => this.processMouse()), + ); + } + + handleWallEvent(event: WallEvent): void { + switch (event.type) { + case "modeChange": + // The achievement is *re-entering* command mode via dual-tap, not + // the initial mount default. Only mark complete on a true + // passthrough → command transition. + if (event.mode === "command" && this.currentMode === "passthrough") { + this.state.markComplete("kb-mode"); + this.commandModePanels.clear(); + // Prefer dockview's active panel — that is the source pane the + // arrow-nav handler navigates *from*, and what onDidActivePanelChange + // fires against. Falling back to the wall's selection covers the + // edge case where the api is missing (e.g. unit tests). + const activePaneId = this.api?.activePanel?.id ?? this.currentPaneId; + if (activePaneId) this.commandModePanels.add(activePaneId); + } + this.currentMode = event.mode; + break; + case "split": + if (event.source !== "keyboard") break; + // `-` / `"` produces a "vertical" split (panes stack top/bottom), + // i.e. a horizontal divider. `|` / `%` produces "horizontal" (panes + // side by side), i.e. a vertical divider. + if (event.direction === "vertical") this.state.markComplete("kb-split-h"); + if (event.direction === "horizontal") this.state.markComplete("kb-split-v"); + break; + case "minimizeChange": + if (event.count > 0) this.state.markComplete("kb-min"); + break; + case "kill": + this.state.markComplete("kb-kill"); + break; + case "move": + this.state.markComplete("kb-move"); + this.pendingMoveTargetId = event.toId; + break; + case "selectionChange": + if (event.kind === "pane") { + this.currentPaneId = event.id; + } + break; + } + } + + private processActivity(): void { + const snapshot = this.activityStore.getActivitySnapshot(); + for (const [id, current] of snapshot) { + const prev = this.prevActivity.get(id); + // First time we see an id (e.g. a pane added after attach()), record + // its state without firing any transitions — we have no "before" to + // compare against, so treating undefined as a transition from + // ALERT_DISABLED / todo=false would falsely credit work the user + // didn't do (e.g. al-todo-manual when restored state has todo=true). + if (!prev) { + this.prevActivity.set(id, { ...current }); + continue; + } + + if (prev.status === "ALERT_DISABLED" && current.status !== "ALERT_DISABLED") { + this.state.markComplete("al-enable"); + this.alertEnabledPaneIds.add(id); + this.preferredAlertPaneId = id; + this.emitAlertDemoPaneChange(); + } else if (prev.status !== "ALERT_DISABLED" && current.status === "ALERT_DISABLED") { + this.alertEnabledPaneIds.delete(id); + if (this.preferredAlertPaneId === id) { + this.preferredAlertPaneId = this.alertEnabledPaneIds.values().next().value ?? null; + this.emitAlertDemoPaneChange(); + } + } + + // Gate al-busy / al-ring on a true status transition. Without the + // prev.status check, a pane already in BUSY or ALERT_RINGING at the + // moment its first activity event fires (e.g. restored state, or a + // pane spawned after attach() that arrives mid-task) would credit + // the user for work they did not do this session. + if ( + prev.status !== current.status && + (current.status === "BUSY" || current.status === "MIGHT_BE_BUSY") + ) { + this.state.markComplete("al-busy"); + } + if (prev.status !== "ALERT_RINGING" && current.status === "ALERT_RINGING") { + this.state.markComplete("al-ring"); + } + + if (!prev.todo && current.todo) { + if (prev.status === "ALERT_RINGING") { + this.state.markComplete("al-todo-auto"); + } else { + this.state.markComplete("al-todo-manual"); + } + } + if (prev.todo && !current.todo) { + this.state.markComplete("al-todo-clear"); + } + + this.prevActivity.set(id, { ...current }); + } + for (const id of this.prevActivity.keys()) { + if (!snapshot.has(id)) { + this.prevActivity.delete(id); + this.alertEnabledPaneIds.delete(id); + if (this.preferredAlertPaneId === id) { + this.preferredAlertPaneId = this.alertEnabledPaneIds.values().next().value ?? null; + this.emitAlertDemoPaneChange(); + } + } + } + } + + private processMouse(): void { + const snapshot = this.mouseStore.getMouseSelectionSnapshot(); + for (const [id, current] of snapshot) { + const prev = this.prevMouse.get(id) ?? DEFAULT_MOUSE_SELECTION_STATE; + + if (current.copyFlash && current.copyFlash !== prev.copyFlash) { + if (current.copyFlash === "raw") this.state.markComplete("cp-raw"); + if (current.copyFlash === "rewrapped") this.state.markComplete("cp-rewrap"); + } + + if (!prev.selection && current.selection) { + this.state.markComplete("cp-select"); + } + + if (prev.override === "off" && current.override !== "off") { + this.state.markComplete("cp-override"); + } + + this.prevMouse.set(id, { ...current }); + } + for (const id of this.prevMouse.keys()) { + if (!snapshot.has(id)) this.prevMouse.delete(id); + } + } + + dispose(): void { + for (const fn of this.disposables) fn(); + this.disposables = []; + } + + private emitAlertDemoPaneChange(): void { + this.onAlertDemoPaneChange?.(this.preferredAlertPaneId); + } +} diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts new file mode 100644 index 0000000..92b7109 --- /dev/null +++ b/website/src/lib/tut-items.ts @@ -0,0 +1,157 @@ +import { cfg } from "mouseterm-lib/cfg"; + +const USER_ATTENTION_SECS = Math.round(cfg.alert.userAttention / 1000); + +// Item ids are the persistence key — keep them stable across releases. +export const ITEM_IDS = [ + "kb-mode", + "kb-split-h", + "kb-arrows", + "kb-split-v", + "kb-min", + "kb-kill", + "kb-move", + "al-enable", + "al-busy", + "al-ring", + "al-todo-auto", + "al-todo-clear", + "al-todo-manual", + "cp-select", + "cp-raw", + "cp-rewrap", + "cp-override", +] as const; + +export type ItemId = (typeof ITEM_IDS)[number]; + +export interface Item { + id: ItemId; + title: string; + hint?: string; +} + +export interface Section { + id: string; + title: string; + items: Item[]; + prose?: string[]; +} + +export const SECTIONS: readonly Section[] = [ + { + id: 'keyboard', + title: 'Keyboard navigation', + items: [ + { + id: 'kb-mode', + title: 'Enter command mode', + hint: 'Press `LShift` then `RShift` quickly (or `LCmd` then `RCmd` on Mac).', + }, + { + id: 'kb-split-h', + title: 'Add a horizontal divider', + hint: 'In command mode, press `-` to split top/bottom.', + }, + { + id: 'kb-arrows', + title: 'Move between panes with arrow keys', + hint: 'Use `arrow keys` in command mode.', + }, + { + id: 'kb-split-v', + title: 'Add a vertical divider', + hint: 'In command mode, press `|` (`Shift+\\`) to split left/right.', + }, + { + id: 'kb-min', + title: 'Minimize a pane', + hint: 'Press `m`. Click the door in the baseboard to bring it back.', + }, + { + id: 'kb-kill', + title: 'Kill a pane', + hint: 'Press `k`, then type the random letter to confirm.', + }, + { + id: 'kb-move', + title: 'Move a pane with `Cmd/Ctrl + arrow`', + hint: 'Swap the selected pane with its neighbor.', + }, + ], + prose: ['tmux shortcuts also work — `%` `"` `d` `x`.'], + }, + { + id: 'alert', + title: 'Alert and TODO', + items: [ + { + id: 'al-enable', + title: 'Enable alerts on a pane', + hint: 'Click the bell on the pane you want to use, or press `a` in command mode with that pane selected.', + }, + { + id: 'al-busy', + title: 'Watch the bell tilt while a task runs', + hint: 'Press `s` here to start a fake busy task on that alert-enabled pane.', + }, + { + id: 'al-ring', + title: 'Bell rings when the task completes', + hint: + `Don't type! If you type, MouseTerm will think you are paying attention to this task and the bell will not ring. The bell only rings if (a) the pane is not selected or (b) you have not interacted with the pane for the past ${USER_ATTENTION_SECS} seconds.`, + }, + { + id: 'al-todo-auto', + title: 'TODO tag appears when you dismiss the ringing alert', + hint: 'Click the bell or interact with the pane to dismiss.', + }, + { + id: 'al-todo-clear', + title: 'Press `Enter` inside the pane to clear the TODO', + }, + { + id: 'al-todo-manual', + title: 'Manually add a TODO', + hint: 'Press `t` in command mode, or right-click the bell.', + }, + ], + }, + { + id: 'copy', + title: 'Copy paste', + items: [ + { + id: 'cp-select', + title: 'Drag-select some text', + hint: 'The paragraph below is a good example — "Some terminal programs..."', + }, + { + id: 'cp-raw', + title: 'Copy-paste it somewhere else with "Copy Raw"', + hint: 'When you paste, notice how it keeps all the line-breaks. Gross!', + }, + { + id: 'cp-rewrap', + title: 'Copy-paste it somewhere else with "Copy Rewrapped"', + hint: + 'When you paste, notice how the line-breaks were removed, and the text rewraps neatly wherever you paste it?', + }, + { + id: 'cp-override', + title: 'Run `ascii-splash`, then click its cursor icon', + hint: + 'Use the demo pane prompt: run `ascii-splash`, click the cursor icon in its header, then drag-select. The animation cancels copying, but the same override works on real programs like `lazygit`.', + }, + ], + prose: [ + 'Some terminal programs trap the cursor, and some do not. This tutorial pane does not trap the cursor, so MouseTerm does not show a cursor icon. The `ascii-splash` program traps the cursor — that is how it is able to respond to mouse movement. `lazygit` is an excellent and popular program which traps the cursor.', + ], + }, +]; + +export const ALL_ITEM_IDS: readonly ItemId[] = ITEM_IDS; + +export function itemSection(id: ItemId): Section | undefined { + return SECTIONS.find((s) => s.items.some((i) => i.id === id)); +} diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts new file mode 100644 index 0000000..6004193 --- /dev/null +++ b/website/src/lib/tut-runner.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; +import { SECTIONS, type ItemId } from "./tut-items"; +import { TutRunner } from "./tut-runner"; +import { TutorialState } from "./tutorial-state"; + +const FRAME_RESET = "\x1b[H\x1b[2J"; + +function mountRunner(completedIds: ItemId[] = []) { + const adapter = new FakePtyAdapter(); + const id = "test-pane"; + adapter.spawnPty(id); + + const frames: string[] = []; + let exitCount = 0; + adapter.onPtyData(({ data }) => frames.push(data)); + + const state = new TutorialState(); + for (const itemId of completedIds) state.markComplete(itemId); + + const runner = new TutRunner({ + adapter, + terminalId: id, + state, + onExit: () => { + exitCount += 1; + }, + }); + adapter.setInputHandler(id, (data) => runner.handleInput(data)); + runner.start(); + + return { + state, + sendKeys: (data: string) => adapter.writePty(id, data), + lastFrame: () => { + const all = frames.join(""); + const i = all.lastIndexOf(FRAME_RESET); + return i >= 0 ? all.slice(i) : all; + }, + exitCount: () => exitCount, + dispose: () => runner.dispose(), + }; +} + +describe("TutRunner snapshots", () => { + it("renders the top-level menu", () => { + const { lastFrame, dispose } = mountRunner(); + expect(lastFrame()).toMatchSnapshot(); + dispose(); + }); + + it("renders Keyboard navigation with all items incomplete", () => { + const { sendKeys, lastFrame, dispose } = mountRunner(); + sendKeys("\r"); + expect(lastFrame()).toMatchSnapshot(); + dispose(); + }); + + it("renders Alert and TODO with all items incomplete", () => { + const { sendKeys, lastFrame, dispose } = mountRunner(); + sendKeys("\x1b[B\r"); + expect(lastFrame()).toMatchSnapshot(); + dispose(); + }); + + it("renders Copy paste with all items incomplete", () => { + const { sendKeys, lastFrame, dispose } = mountRunner(); + sendKeys("\x1b[B\x1b[B\r"); + expect(lastFrame()).toMatchSnapshot(); + dispose(); + }); + + it("renders Keyboard navigation with all items complete", () => { + const allKeyboardIds = SECTIONS[0].items.map((i) => i.id); + const { sendKeys, lastFrame, dispose } = mountRunner(allKeyboardIds); + sendKeys("\r"); + expect(lastFrame()).toMatchSnapshot(); + dispose(); + }); + + it("backs out of a section with q before exiting from the menu", () => { + const { sendKeys, lastFrame, exitCount, dispose } = mountRunner(); + sendKeys("\r"); + + sendKeys("q"); + expect(lastFrame()).toContain("MouseTerm Playground Tutorial"); + expect(exitCount()).toBe(0); + + sendKeys("q"); + expect(exitCount()).toBe(1); + dispose(); + }); +}); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts new file mode 100644 index 0000000..ea8f7b4 --- /dev/null +++ b/website/src/lib/tut-runner.ts @@ -0,0 +1,501 @@ +import { + BOLD, + CLEAR_SCREEN, + CURSOR_HOME, + DIM, + ENTER_ALT_SCREEN, + FG_DEFAULT, + ITALIC, + LEAVE_ALT_SCREEN, + RESET, + fg, +} from "mouseterm-lib/lib/ansi"; +import { cfg } from "mouseterm-lib/cfg"; +import type { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; +import type { InteractiveProgram } from "./tutorial-shell"; +import { SECTIONS, type Item } from "./tut-items"; +import type { TutorialState } from "./tutorial-state"; + +/** + * The fake busy task must outlast the user-attention idle window so that, + * by the time the activity monitor's silence threshold fires, attention + * has expired and the bell rings instead of being suppressed by the + * "user is looking at this pane" check. The 250ms margin is a safety + * guard against scheduler jitter — the exact value doesn't matter as + * long as it's larger than realistic clock skew. + */ +export const BUSY_DEMO_DURATION_MS = cfg.alert.userAttention + 250; + +/** + * Interval between fake-busy ticks. Must stay safely below + * cfg.alert.busyCandidateGap so consecutive onData calls register as + * continuous activity rather than separate bursts. Half the gap gives + * comfortable margin against scheduler jitter; deriving from cfg means + * tuning the gap won't silently break the demo. + */ +export const BUSY_DEMO_INTERVAL_MS = Math.floor(cfg.alert.busyCandidateGap / 2); + +// Replace `` `KEY` `` markers with a cyan span. Uses default-foreground +// (39m) to close the span so the highlight composes cleanly with +// surrounding bold/italic/dim — only the color is touched. +function highlightKeys(line: string): string { + return line.replace(/`([^`]+)`/g, `${fg(36)}$1${FG_DEFAULT}`); +} + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_INTERVAL_MS = 100; + +/** + * Static "your turn" pointer for the active section item. Animating this + * would feed the activity monitor on whichever pane the runner lives on, + * so the bell on a pane that hosts the runner could never reach the + * RINGING state — the activity-monitor only rings after a stretch of + * silence. A static glyph keeps the pane quiet between user actions. + */ +const ACTIVE_ITEM_GLYPH = "●"; + +interface TutRunnerOptions { + adapter: FakePtyAdapter; + terminalId: string; + state: TutorialState; + onExit: () => void; + /** Called when the user presses `s` inside the Alert section. */ + onTriggerBusyDemo?: () => void; +} + +type Screen = "menu" | "section" | "reset"; + +const RESET_CONFIRM_WORD = "reset"; + +export class TutRunner implements InteractiveProgram { + private adapter: FakePtyAdapter; + private terminalId: string; + private state: TutorialState; + private onExit: () => void; + private onTriggerBusyDemo?: () => void; + + private screen: Screen = "menu"; + private menuIndex = 0; + private sectionId: string | null = null; + private resetBuffer = ""; + private resetMismatch = false; + private spinnerFrame = 0; + private spinnerTimer: ReturnType | null = null; + private stateUnsub: (() => void) | null = null; + private resizeUnsub: (() => void) | null = null; + private busyDemoStart: number | null = null; + private disposed = false; + + constructor(options: TutRunnerOptions) { + this.adapter = options.adapter; + this.terminalId = options.terminalId; + this.state = options.state; + this.onExit = options.onExit; + this.onTriggerBusyDemo = options.onTriggerBusyDemo; + } + + start(): void { + this.write(ENTER_ALT_SCREEN); + this.stateUnsub = this.state.subscribe(() => this.render()); + this.resizeUnsub = this.adapter.onPtyResize((d) => { + if (d.id === this.terminalId) this.render(); + }); + this.render(); + } + + private startSpinnerTicks(): void { + if (this.spinnerTimer) return; + this.spinnerTimer = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; + this.render(); + if ( + this.busyDemoStart === null || + Date.now() - this.busyDemoStart >= BUSY_DEMO_DURATION_MS + ) { + this.stopSpinnerTicks(); + } + }, SPINNER_INTERVAL_MS); + } + + private stopSpinnerTicks(): void { + if (!this.spinnerTimer) return; + clearInterval(this.spinnerTimer); + this.spinnerTimer = null; + } + + handleInput(data: string): void { + if (this.disposed) return; + let i = 0; + while (i < data.length) { + const ch = data[i]; + if (ch === "\x03") { + this.exit(); + return; + } + if (ch === "\x1b") { + const tail = data.slice(i); + const csi = tail.match(/^\x1b\[([ABCD])/); + if (csi) { + this.handleArrow(csi[1]); + i += csi[0].length; + continue; + } + // Bare Esc — back / exit + this.handleEscape(); + i += 1; + continue; + } + if (ch === "\r" || ch === "\n") { + this.handleEnter(); + i += 1; + continue; + } + if (this.screen === "reset") { + if (ch === "\x7f" || ch === "\b") { + if (this.resetBuffer.length > 0) { + this.resetBuffer = this.resetBuffer.slice(0, -1); + this.resetMismatch = false; + this.render(); + } + } else if (ch >= " " && this.resetBuffer.length < 32) { + this.resetBuffer += ch; + this.resetMismatch = false; + this.render(); + } + i += 1; + continue; + } + if (ch === "q" || ch === "Q") { + this.handleEscape(); + return; + } + if ( + this.screen === "section" && + this.sectionId === "alert" && + (ch === "s" || ch === "S") + ) { + // Ignore presses while the demo is still running — otherwise each + // press starts a fresh pumpActivity interval that stacks on top of + // the previous one until they all expire. + if (!this.busyDemoInProgress()) this.startBusyDemo(); + i += 1; + continue; + } + i += 1; + } + } + + dispose(): void { + this.cleanup(false); + } + + // --- Input --- + + private menuLength(): number { + // SECTIONS + the trailing "Reset progress" entry + return SECTIONS.length + 1; + } + + private handleArrow(letter: string): void { + if (this.screen !== "menu") return; + const len = this.menuLength(); + if (letter === "A") { + this.menuIndex = (this.menuIndex - 1 + len) % len; + } else if (letter === "B") { + this.menuIndex = (this.menuIndex + 1) % len; + } else { + return; + } + this.render(); + } + + private handleEnter(): void { + if (this.screen === "menu") { + if (this.menuIndex === SECTIONS.length) { + this.screen = "reset"; + this.resetBuffer = ""; + this.resetMismatch = false; + this.render(); + return; + } + const section = SECTIONS[this.menuIndex]; + if (!section) return; + this.sectionId = section.id; + this.screen = "section"; + // Resume the spinner if we're entering Alert while a demo started + // earlier is still running. Otherwise the countdown line would + // render with a frozen spinner glyph (timer was stopped on Esc out). + if (section.id === "alert" && this.busyDemoInProgress()) { + this.startSpinnerTicks(); + } + this.render(); + return; + } + if (this.screen === "reset") { + if (this.resetBuffer.trim().toLowerCase() === RESET_CONFIRM_WORD) { + this.state.reset(); + this.resetBuffer = ""; + this.resetMismatch = false; + this.screen = "menu"; + this.render(); + } else { + this.resetBuffer = ""; + this.resetMismatch = true; + this.render(); + } + } + } + + private handleEscape(): void { + if (this.screen === "section") { + // The spinner only animates the Alert section's "fake task running" + // line, so it has nothing to draw outside that section — stop it + // here rather than letting it re-render the menu every 100ms until + // the demo's natural duration elapses. + this.stopSpinnerTicks(); + this.sectionId = null; + this.screen = "menu"; + this.render(); + return; + } + if (this.screen === "reset") { + this.resetBuffer = ""; + this.resetMismatch = false; + this.screen = "menu"; + this.render(); + return; + } + this.exit(); + } + + private exit(): void { + if (this.disposed) return; + this.cleanup(true); + } + + private busyDemoInProgress(): boolean { + if (this.busyDemoStart === null) return false; + return Date.now() - this.busyDemoStart < BUSY_DEMO_DURATION_MS; + } + + private startBusyDemo(): void { + this.busyDemoStart = Date.now(); + this.onTriggerBusyDemo?.(); + this.startSpinnerTicks(); + this.render(); + } + + // --- Render --- + + private render(): void { + if (this.disposed) return; + const lines = + this.screen === "menu" + ? this.renderMenu() + : this.screen === "reset" + ? this.renderReset() + : this.renderSection(); + let out = `${CURSOR_HOME}${CLEAR_SCREEN}`; + for (const line of lines) { + out += `${highlightKeys(line)}\r\n`; + } + this.write(out); + } + + private renderMenu(): string[] { + const total = this.state.totalProgress(); + const lines: string[] = []; + lines.push(""); + lines.push(` ${BOLD}MouseTerm Playground Tutorial${RESET}`); + lines.push( + ` ${DIM}${total.done}/${total.total} complete · \`Esc\`/\`q\` to exit · \`Enter\` to open · \`↑↓\` to navigate${RESET}`, + ); + lines.push(""); + SECTIONS.forEach((section, index) => { + const { done, total: t } = this.state.sectionProgress(section.id); + const marker = index === this.menuIndex ? `${fg(36)}❯${RESET}` : " "; + const label = index === this.menuIndex + ? `${BOLD}${section.title}${RESET}` + : section.title; + const progress = + done === t + ? `${fg(32)}[${done}/${t} complete]${RESET}` + : `${DIM}[${done}/${t} complete]${RESET}`; + lines.push(` ${marker} ${label} ${progress}`); + }); + + const resetIndex = SECTIONS.length; + const resetMarker = this.menuIndex === resetIndex ? `${fg(36)}❯${RESET}` : " "; + const resetLabel = + this.menuIndex === resetIndex + ? `${BOLD}Reset progress${RESET}` + : `${DIM}Reset progress${RESET}`; + lines.push(""); + lines.push(` ${resetMarker} ${resetLabel}`); + lines.push(""); + return lines; + } + + private renderReset(): string[] { + const lines: string[] = []; + lines.push(""); + lines.push(` ${BOLD}Reset progress${RESET}`); + lines.push(` ${DIM}\`Esc\` to cancel${RESET}`); + lines.push(""); + lines.push( + ` This will clear all checkmarks across every section.`, + ); + lines.push( + ` ${DIM}Type \`reset\` and press \`Enter\` to confirm.${RESET}`, + ); + lines.push(""); + lines.push(` ${fg(36)}>${RESET} ${this.resetBuffer}${fg(33)}_${RESET}`); + if (this.resetMismatch) { + lines.push(""); + lines.push(` ${fg(31)}That didn't match. Type "reset" exactly.${RESET}`); + } + return lines; + } + + private renderSection(): string[] { + const section = SECTIONS.find((s) => s.id === this.sectionId); + if (!section) { + throw new Error(`renderSection: unknown sectionId ${this.sectionId}`); + } + const { done, total } = this.state.sectionProgress(section.id); + const lines: string[] = []; + lines.push(""); + lines.push(` ${BOLD}${section.title}${RESET} ${DIM}${done}/${total} complete${RESET}`); + lines.push(` ${DIM}\`Esc\` to go back${RESET}`); + lines.push(""); + + const activeIndex = section.items.findIndex((i) => !this.state.isComplete(i.id)); + section.items.forEach((item, index) => { + lines.push(...this.renderItem(item, index, activeIndex)); + }); + + if (section.prose && section.prose.length > 0) { + lines.push(""); + const indent = " "; + for (const p of section.prose) { + for (const wrapped of this.wrapText(p, indent.length)) { + lines.push(`${indent}${DIM}${wrapped}${RESET}`); + } + } + } + + if (section.id === "alert") { + lines.push(""); + lines.push(...this.renderBusyDemoLines()); + } + + if (done === total) { + lines.push(""); + lines.push( + ` ${fg(32)}Section complete.${RESET} ${DIM}Press \`Esc\` to go back.${RESET}`, + ); + } + + return lines; + } + + private renderBusyDemoLines(): string[] { + const idleHint = ` ${DIM}Press \`s\` here to start a fake busy task.${RESET}`; + if (this.busyDemoStart === null) return [idleHint]; + const elapsed = Date.now() - this.busyDemoStart; + if (elapsed < BUSY_DEMO_DURATION_MS) { + const spinner = SPINNER_FRAMES[this.spinnerFrame]; + const secsLeft = Math.max(1, Math.ceil((BUSY_DEMO_DURATION_MS - elapsed) / 1_000)); + return [ + ` ${fg(33)}${spinner}${RESET} Fake task will finish in ${BOLD}${secsLeft}${RESET} seconds.`, + ]; + } + return [ + ` ${fg(32)}✓${RESET} Fake task finished. ${DIM}Press \`s\` to start another one.${RESET}`, + ]; + } + + private renderItem(item: Item, index: number, activeIndex: number): string[] { + const complete = this.state.isComplete(item.id); + const isActive = !complete && index === activeIndex; + let mark: string; + if (complete) { + mark = `${fg(32)}✓${RESET}`; + } else if (isActive) { + mark = `${fg(33)}${ACTIVE_ITEM_GLYPH}${RESET}`; + } else { + mark = `${DIM}·${RESET}`; + } + const title = complete + ? `${DIM}${item.title}${RESET}` + : isActive + ? `${BOLD}${item.title}${RESET}` + : item.title; + const lines = [` ${mark} ${title}`]; + if (isActive && item.hint) { + const indent = " "; + for (const wrapped of this.wrapText(item.hint, indent.length)) { + lines.push(`${indent}${ITALIC}${wrapped}${RESET}`); + } + } + return lines; + } + + /** + * Word-wrap text to fit the current pty width, leaving `indentCols` + * columns for the leading indent the caller will prefix. Backtick + * markers expand into zero-width ANSI at frame time via + * `highlightKeys`, so they don't count against the visible width. + */ + private wrapText(text: string, indentCols: number): string[] { + const { cols } = this.adapter.getPtySize(this.terminalId); + const max = Math.max(20, cols - indentCols); + const visibleLen = (s: string) => { + let n = 0; + for (const ch of s) if (ch !== "`") n++; + return n; + }; + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let line = ""; + let lineVis = 0; + for (const word of words) { + const wv = visibleLen(word); + if (line === "") { + line = word; + lineVis = wv; + } else if (lineVis + 1 + wv <= max) { + line += " " + word; + lineVis += 1 + wv; + } else { + lines.push(line); + line = word; + lineVis = wv; + } + } + if (line) lines.push(line); + return lines; + } + + // --- Internals --- + + private cleanup(notifyExit: boolean): void { + if (this.disposed) return; + this.disposed = true; + this.stopSpinnerTicks(); + this.busyDemoStart = null; + this.stateUnsub?.(); + this.stateUnsub = null; + this.resizeUnsub?.(); + this.resizeUnsub = null; + this.write(LEAVE_ALT_SCREEN); + if (notifyExit) this.onExit(); + } + + private write(data: string): void { + // Runner frames are UI chrome, not task output — skip the activity + // tick so enabling alerts on the runner pane doesn't tilt the bell + // every time the menu re-renders. + this.adapter.sendOutput(this.terminalId, data, { skipActivity: true }); + } +} diff --git a/website/src/lib/tutorial-detection.ts b/website/src/lib/tutorial-detection.ts deleted file mode 100644 index d594b66..0000000 --- a/website/src/lib/tutorial-detection.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Tutorial step detection — watches Dockview and Wall events - * to detect when users complete each tutorial step. - */ - -import type { TutorialShell } from './tutorial-shell'; - -type DockviewApi = any; -type WallEvent = import('mouseterm-lib/components/Wall').WallEvent; -type WallMode = import('mouseterm-lib/components/Wall').WallMode; - -const RESIZE_RATIO_DELTA = 0.08; - -interface SerializedGridLeaf { - type: 'leaf'; - data?: { id?: string }; - size?: number; -} - -interface SerializedGridBranch { - type: 'branch'; - data: SerializedGridNode[]; - size?: number; -} - -type SerializedGridNode = SerializedGridLeaf | SerializedGridBranch; - -interface ResizeSnapshot { - branchRatios: Map; - structureSignature: string; -} - -export class TutorialDetector { - private shell: TutorialShell; - private api: DockviewApi | null = null; - private disposables: (() => void)[] = []; - - // Tracking state - private initialPanelCount = 0; - private currentMode: WallMode = 'command'; - private hasZoomed = false; - private hasMinimized = false; - private focusedPanelIds = new Set(); - private pendingResizeBaselineReset = false; - private resizeBaseline: ResizeSnapshot | null = null; - - constructor(shell: TutorialShell) { - this.shell = shell; - } - - /** Connect to the DockviewApi and start detecting. */ - attach(api: DockviewApi): void { - this.api = api; - this.initialPanelCount = api.totalPanels; - this.resizeBaseline = this.captureResizeSnapshot(); - - // Step 1: Split detection — panel count increases - const addDisposable = api.onDidAddPanel(() => { - if (api.totalPanels > this.initialPanelCount) { - if (!this.shell.isStepComplete(1)) { - // Adding a panel changes the layout, but it shouldn't count as a resize. - this.pendingResizeBaselineReset = true; - } - this.shell.markStepComplete(0); // Step 1 - } - }); - this.disposables.push(() => addDisposable.dispose()); - - // Step 2: Resize detection — watch layout changes for ratio shifts - const layoutDisposable = api.onDidLayoutChange(() => { - if (!this.shell.isStepComplete(1) && this.shell.isStepComplete(0)) { - if (this.pendingResizeBaselineReset) { - this.resizeBaseline = this.captureResizeSnapshot(); - this.pendingResizeBaselineReset = false; - return; - } - this.checkResize(); - } - }); - this.disposables.push(() => layoutDisposable.dispose()); - - // Step 5: Track panel focus changes in command mode - const activePanelDisposable = api.onDidActivePanelChange((panel: any) => { - if (panel && this.currentMode === 'command') { - this.focusedPanelIds.add(panel.id); - if (this.focusedPanelIds.size >= 2 && this.shell.isStepComplete(3)) { - this.shell.markStepComplete(4); // Step 5 - } - } - }); - this.disposables.push(() => activePanelDisposable.dispose()); - } - - /** Handle Wall state change events. */ - handleWallEvent(event: WallEvent): void { - switch (event.type) { - case 'modeChange': - if (event.mode === 'command' && this.currentMode !== 'command') { - // Reset focus tracking when entering command mode - this.focusedPanelIds.clear(); - // Add the currently active panel - if (this.api) { - const activePanel = this.api.activePanel; - if (activePanel) this.focusedPanelIds.add(activePanel.id); - } - } - this.currentMode = event.mode; - break; - - case 'zoomChange': - if (event.zoomed) { - this.hasZoomed = true; - } else if (this.hasZoomed && this.shell.isStepComplete(1)) { - // Unzoomed after having zoomed — Step 3 complete - this.shell.markStepComplete(2); - this.hasZoomed = false; - } - break; - - case 'minimizeChange': - if (event.count > 0) { - this.hasMinimized = true; - } else if (this.hasMinimized && this.shell.isStepComplete(2)) { - // Reattached (count back to 0 after minimize) — Step 4 complete - this.shell.markStepComplete(3); - this.hasMinimized = false; - } - break; - - case 'split': - if (event.source === 'keyboard' && this.currentMode === 'command' && this.shell.isStepComplete(4)) { - this.shell.markStepComplete(5); // Step 6 - } - break; - } - } - - private checkResize(): void { - const snapshot = this.captureResizeSnapshot(); - if (!snapshot) return; - - if (!this.resizeBaseline) { - this.resizeBaseline = snapshot; - return; - } - - if ( - snapshot.structureSignature !== this.resizeBaseline.structureSignature - || snapshot.branchRatios.size !== this.resizeBaseline.branchRatios.size - ) { - this.resizeBaseline = snapshot; - return; - } - - for (const [path, ratios] of snapshot.branchRatios) { - const baselineRatios = this.resizeBaseline.branchRatios.get(path); - if (!baselineRatios || baselineRatios.length !== ratios.length) { - this.resizeBaseline = snapshot; - return; - } - - for (let i = 0; i < ratios.length; i++) { - if (Math.abs(ratios[i] - baselineRatios[i]) >= RESIZE_RATIO_DELTA) { - this.shell.markStepComplete(1); // Step 2 - return; - } - } - } - } - - private captureResizeSnapshot(): ResizeSnapshot | null { - const root = this.api?.toJSON?.()?.grid?.root as SerializedGridNode | undefined; - if (!root) return null; - - const branchRatios = new Map(); - const structureSignature = this.collectResizeSnapshot(root, 'root', branchRatios); - return { branchRatios, structureSignature }; - } - - private collectResizeSnapshot( - node: SerializedGridNode, - path: string, - branchRatios: Map, - ): string { - if (node.type === 'leaf') { - return `leaf:${node.data?.id ?? path}`; - } - - const children = node.data; - const totalSize = children.reduce((sum, child) => sum + (child.size ?? 0), 0); - if (children.length >= 2 && totalSize > 0) { - branchRatios.set(path, children.map((child) => (child.size ?? 0) / totalSize)); - } - - const childSignatures = children.map((child, index) => - this.collectResizeSnapshot(child, `${path}.${index}`, branchRatios), - ); - return `branch(${childSignatures.join(',')})`; - } - - dispose(): void { - for (const dispose of this.disposables) { - dispose(); - } - this.disposables = []; - } -} diff --git a/website/src/lib/tutorial-shell.test.ts b/website/src/lib/tutorial-shell.test.ts index 0474f39..3b22ed8 100644 --- a/website/src/lib/tutorial-shell.test.ts +++ b/website/src/lib/tutorial-shell.test.ts @@ -9,22 +9,35 @@ function createHarness() { 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?.() }; + const startProgram = vi.fn( + (name: string, _args: string[], onExit: () => void) => { + if (name !== "ascii-splash" && name !== "splash") return null; + exitProgram = onExit; + return program; + }, + ); + const shell = new TutorialShell((data) => output.push(data), startProgram); + return { + output, + program, + shell, + startProgram, + 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(); +describe("TutorialShell program dispatch", () => { + it("launches the program named by the first token and delegates input", () => { + const { output, program, shell, startProgram, exitProgram } = createHarness(); shell.handleInput("ascii-splash --no-mouse\r"); shell.handleInput("q"); - expect(startAsciiSplash).toHaveBeenCalledWith(["--no-mouse"], expect.any(Function)); + expect(startProgram).toHaveBeenCalledWith( + "ascii-splash", + ["--no-mouse"], + expect.any(Function), + ); expect(program.start).toHaveBeenCalledTimes(1); expect(program.handleInput).toHaveBeenCalledWith("q"); @@ -41,6 +54,25 @@ describe("TutorialShell ascii-splash integration", () => { expect(program.dispose).toHaveBeenCalledTimes(1); }); + it("auto-launches via runCommand without parsing input", () => { + const { program, shell, startProgram } = createHarness(); + + shell.runCommand("ascii-splash"); + + expect(startProgram).toHaveBeenCalledWith( + "ascii-splash", + [], + expect.any(Function), + ); + expect(program.start).toHaveBeenCalledTimes(1); + }); + + it("prints an unknown-command message when startProgram returns null", () => { + const { output, shell } = createHarness(); + shell.handleInput("nope\r"); + expect(output.join("")).toContain("Unknown command"); + }); + it("recalls the previous command on up arrow instead of echoing the escape sequence", () => { const { output, shell } = createHarness(); shell.handleInput("bogus\r"); diff --git a/website/src/lib/tutorial-shell.ts b/website/src/lib/tutorial-shell.ts index 32745c7..75641c3 100644 --- a/website/src/lib/tutorial-shell.ts +++ b/website/src/lib/tutorial-shell.ts @@ -1,60 +1,4 @@ -const ESC = '\x1b['; -const RESET = `${ESC}0m`; -const BOLD = `${ESC}1m`; -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; - -interface TutorialStep { - phase: string; - title: string; - description: string; - hint: string; -} - -const STEPS: TutorialStep[] = [ - { - phase: 'See Everything at Once', - title: 'Split a pane', - description: "You're juggling multiple tasks. Split this terminal so you can watch two things side by side.", - hint: 'Drag the split button in the tab header, or drag the tab itself to a drop zone.', - }, - { - phase: 'See Everything at Once', - title: 'Resize your panes', - description: 'One task needs more room. Drag the divider between panes to give it space.', - hint: 'Drag the gap between two panes.', - }, - { - phase: 'Focus and Background', - title: 'Zoom in, then zoom back out', - description: "One terminal needs your full attention. Zoom in to focus, then zoom back out when you're done.", - hint: 'Double-click a tab header to zoom. Double-click again to unzoom.', - }, - { - phase: 'Focus and Background', - title: 'Detach a pane, then bring it back', - description: "That task is running in the background — you don't need to watch it. Send it to the baseboard, then click its door when you want it back.", - hint: 'Click the detach button in the tab header. Click the door in the baseboard to reattach.', - }, - { - phase: 'Keyboard Power', - title: 'Enter command mode and navigate', - description: 'Navigate between panes without touching the mouse.', - hint: 'Press Escape to enter command mode. Use arrow keys to move between panes.', - }, - { - phase: 'Keyboard Power', - title: 'Split using keyboard shortcuts', - description: 'Split a pane without leaving the keyboard.', - hint: 'In command mode, press " to split top/bottom or % to split left/right.', - }, -]; +import { CLEAR_LINE, PROMPT, RESET, fg } from 'mouseterm-lib/lib/ansi'; export type SendOutput = (data: string) => void; @@ -64,20 +8,40 @@ export interface InteractiveProgram { dispose(): void; } -export type StartInteractiveProgram = (args: string[], onExit: () => void) => InteractiveProgram; - +/** + * Factory for the program identified by `name`. Return null if the command + * is not recognized; the shell will print an "Unknown command" message. + */ +export type StartProgram = ( + name: string, + args: string[], + onExit: () => void, +) => InteractiveProgram | null; + +/** + * Minimal browser shell for playground panes. Provides line editing, + * command history, and dispatch to interactive programs (`tut`, + * `ascii-splash`, ...) supplied by the host. Output goes through + * `sendOutput`; input bytes arrive via `handleInput`. + */ export class TutorialShell { private lineBuffer = ''; private history: string[] = []; private historyIndex: number | null = null; private historyDraft = ''; private sendOutput: SendOutput; - private startAsciiSplash?: StartInteractiveProgram; + private startProgram: StartProgram; private activeProgram: InteractiveProgram | null = null; + private promptShown = false; - constructor(sendOutput: SendOutput, startAsciiSplash?: StartInteractiveProgram) { + constructor( + sendOutput: SendOutput, + startProgram: StartProgram, + options: { promptShown?: boolean } = {}, + ) { this.sendOutput = sendOutput; - this.startAsciiSplash = startAsciiSplash; + this.startProgram = startProgram; + this.promptShown = options.promptShown ?? false; } dispose(): void { @@ -85,11 +49,30 @@ export class TutorialShell { this.activeProgram = null; } + /** Programmatically run a command. Used to auto-launch `tut` on mount. */ + runCommand(name: string, args: string[] = []): void { + if (this.activeProgram) return; + const program = this.startProgram(name, args, () => { + this.activeProgram = null; + this.showPrompt(); + }); + if (!program) { + this.sendOutput(`${fg(90)}Unknown command: ${name}${RESET}\r\n`); + this.showPrompt(); + return; + } + this.activeProgram = program; + this.activeProgram.start(); + } + handleInput(data: string): void { if (this.activeProgram) { this.activeProgram.handleInput(data); return; } + if (!this.promptShown) { + this.showPrompt(); + } for (let index = 0; index < data.length; index++) { const ch = data[index]; @@ -107,7 +90,6 @@ export class TutorialShell { index += ss3[0].length - 1; continue; } - // Lone escape byte: drop it so partial sequences don't echo. continue; } @@ -164,7 +146,6 @@ export class TutorialShell { return; } } - this.lineBuffer = this.history[this.historyIndex]; this.redrawPromptLine(); } @@ -175,129 +156,27 @@ export class TutorialShell { private processCommand(cmd: string): void { if (cmd === '') { - this.sendOutput(PROMPT); + this.showPrompt(); 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)} or ${fg(36)}ascii-splash${fg(90)}.${RESET}\r\n`); - } - - this.sendOutput(PROMPT); - } - - private showCurrentStep(): void { - const nextStep = this.getNextIncompleteStep(); - - if (nextStep === null) { - this.showCompletion(); + const [name, ...args] = cmd.split(/\s+/); + const program = this.startProgram(name, args, () => { + this.activeProgram = null; + this.showPrompt(); + }); + if (!program) { + this.sendOutput( + `${fg(90)}Unknown command. Try ${fg(36)}tut${fg(90)} or ${fg(36)}ascii-splash${fg(90)}.${RESET}\r\n`, + ); + this.showPrompt(); return; } - - const step = STEPS[nextStep]; - const stepNum = nextStep + 1; - - this.sendOutput( - `\r\n` + - `${DIM}Step ${stepNum}/${TOTAL_STEPS} — ${step.phase}${RESET}\r\n` + - `${BOLD}${step.title}${RESET}\r\n\r\n` + - `${step.description}\r\n\r\n` + - `${DIM}${step.hint}${RESET}\r\n\r\n` - ); + this.activeProgram = program; + this.activeProgram.start(); } - private showStatus(): void { - this.sendOutput(`\r\n${BOLD}Tutorial Progress${RESET}\r\n\r\n`); - - let currentPhase = ''; - for (let i = 0; i < TOTAL_STEPS; i++) { - const step = STEPS[i]; - const done = this.isStepComplete(i); - - if (step.phase !== currentPhase) { - currentPhase = step.phase; - this.sendOutput(`${DIM}${currentPhase}${RESET}\r\n`); - } - - const marker = done ? `${fg(32)}[x]${RESET}` : `${fg(90)}[ ]${RESET}`; - const label = done ? `${fg(90)}${step.title}${RESET}` : step.title; - this.sendOutput(` ${marker} ${label}\r\n`); - } - this.sendOutput('\r\n'); - } - - private resetProgress(): void { - for (let i = 0; i < TOTAL_STEPS; i++) { - localStorage.removeItem(`${STORAGE_PREFIX}${i + 1}`); - } - this.sendOutput(`${fg(32)}Tutorial progress reset.${RESET} Type ${fg(36)}tut${RESET} to start from the beginning.\r\n`); - } - - private showCompletion(): void { - this.sendOutput( - `\r\n` + - `${fg(32)}${BOLD}You've got it.${RESET} MouseTerm keeps everything visible and nothing in your way.\r\n\r\n` + - `Ready to try the real thing?\r\n` + - ` ${fg(36)}→${RESET} Download MouseTerm: ${BOLD}mouseterm.com/#download${RESET}\r\n` + - `Or keep exploring — this sandbox is yours.\r\n\r\n` - ); - } - - // --- Progress tracking --- - - isStepComplete(stepIndex: number): boolean { - return localStorage.getItem(`${STORAGE_PREFIX}${stepIndex + 1}`) === 'true'; - } - - markStepComplete(stepIndex: number): void { - if (this.isStepComplete(stepIndex)) return; - localStorage.setItem(`${STORAGE_PREFIX}${stepIndex + 1}`, 'true'); - this.announceCompletion(stepIndex); - } - - private getNextIncompleteStep(): number | null { - for (let i = 0; i < TOTAL_STEPS; i++) { - if (!this.isStepComplete(i)) return i; - } - return null; - } - - private announceCompletion(stepIndex: number): void { - const step = STEPS[stepIndex]; - const stepNum = stepIndex + 1; - - this.sendOutput( - `\r\n${fg(32)}✓ Step ${stepNum}/${TOTAL_STEPS}: ${step.title}${RESET}\r\n` - ); - - const nextStep = this.getNextIncompleteStep(); - if (nextStep === null) { - this.showCompletion(); - } else { - const next = STEPS[nextStep]; - this.sendOutput( - `\r\n${DIM}Next — ${next.title}${RESET}\r\n` + - `${next.description}\r\n` + - `${DIM}${next.hint}${RESET}\r\n` - ); - } - this.sendOutput('\r\n' + PROMPT); + private showPrompt(): void { + this.sendOutput(PROMPT); + this.promptShown = true; } } diff --git a/website/src/lib/tutorial-state.ts b/website/src/lib/tutorial-state.ts new file mode 100644 index 0000000..a52ee54 --- /dev/null +++ b/website/src/lib/tutorial-state.ts @@ -0,0 +1,80 @@ +import { ALL_ITEM_IDS, ITEM_IDS, SECTIONS, type ItemId } from "./tut-items"; + +const STORAGE_KEY = "mouseterm-tut-v3"; +const KNOWN_IDS: ReadonlySet = new Set(ITEM_IDS); + +export class TutorialState { + private completed = new Set(); + private listeners = new Set<() => void>(); + private storage = typeof localStorage !== "undefined" ? localStorage : null; + + constructor() { + const raw = this.storage?.getItem(STORAGE_KEY); + if (!raw) return; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return; + for (const entry of parsed) { + if (typeof entry === "string" && KNOWN_IDS.has(entry as ItemId)) { + this.completed.add(entry as ItemId); + } + } + } catch { + // Corrupt payload — start fresh. + } + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + isComplete(id: ItemId): boolean { + return this.completed.has(id); + } + + markComplete(id: ItemId): boolean { + if (this.completed.has(id)) return false; + this.completed.add(id); + this.notify(); + this.persist(); + return true; + } + + reset(): void { + if (this.completed.size === 0) return; + this.completed.clear(); + this.storage?.removeItem(STORAGE_KEY); + this.notify(); + } + + sectionProgress(sectionId: string): { done: number; total: number } { + const section = SECTIONS.find((s) => s.id === sectionId); + if (!section) return { done: 0, total: 0 }; + let done = 0; + for (const item of section.items) { + if (this.completed.has(item.id)) done++; + } + return { done, total: section.items.length }; + } + + totalProgress(): { done: number; total: number } { + return { done: this.completed.size, total: ALL_ITEM_IDS.length }; + } + + private notify(): void { + for (const fn of this.listeners) fn(); + } + + private persist(): void { + if (!this.storage) return; + try { + this.storage.setItem(STORAGE_KEY, JSON.stringify([...this.completed])); + } catch { + // Quota or access errors shouldn't break in-memory progress — + // listeners already fired against the new state. + } + } +} diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index cee3807..e7a52cc 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -2,14 +2,15 @@ import { useState, useEffect, useCallback, useRef } from "react"; import SiteHeader from "../components/SiteHeader"; import { ThemePicker } from "mouseterm-lib/components/ThemePicker"; import { PlaygroundShellRegistry } from "../lib/playground-shells"; -import { TutorialDetector } from "../lib/tutorial-detection"; +import { TutorialState } from "../lib/tutorial-state"; +import { TutDetector } from "../lib/tut-detector"; +import { BUSY_DEMO_DURATION_MS, BUSY_DEMO_INTERVAL_MS, TutRunner } from "../lib/tut-runner"; export { Playground as Component }; -// Pane IDs — stable so we can assign scenarios before mount const PANE_MAIN = "tut-main"; -const PANE_NPM = "tut-npm"; -const PANE_LS = "tut-ls"; +const PANE_TARGET = "tut-target"; +const PANE_BOXED = "tut-boxed"; type FakePtyAdapter = import("mouseterm-lib/lib/platform/fake-adapter").FakePtyAdapter; type WallEvent = import("mouseterm-lib/components/Wall").WallEvent; @@ -21,59 +22,123 @@ function Playground() { } | null>(null); const adapterRef = useRef(null); const shellRegistryRef = useRef(null); - const detectorRef = useRef(null); + const detectorRef = useRef(null); + const stateRef = useRef(null); const dockviewDisposablesRef = useRef([]); + const tutorialAutoStartedRef = useRef(false); + const spawnUnsubRef = useRef<(() => void) | null>(null); + const busyDemoDisposeRef = useRef<(() => void) | null>(null); + const alertDemoPaneIdRef = useRef(null); + + const tryAutoStartTutorial = useCallback(() => { + if (tutorialAutoStartedRef.current) return; + const shellRegistry = shellRegistryRef.current; + if (!shellRegistry) return; + tutorialAutoStartedRef.current = true; + shellRegistry.ensureShell(PANE_MAIN).runCommand("tut"); + }, []); useEffect(() => { + let cancelled = false; async function loadWall() { const platform = await import("mouseterm-lib/lib/platform"); const registry = await import("mouseterm-lib/lib/terminal-registry"); + const mouseSelection = await import("mouseterm-lib/lib/mouse-selection"); 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"); + if (cancelled) return; const adapter = platform.initPlatform("fake"); registry.initAlertStateReceiver(); adapterRef.current = adapter; 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); + adapter.setScenario(PANE_TARGET, scenarios.SCENARIO_SHELL_PROMPT); + adapter.setScenario(PANE_BOXED, scenarios.SCENARIO_BOXED_PARAGRAPH); + // tut-main is owned by the TutRunner — explicitly suppress the + // default shell-prompt scenario, otherwise spawnPty queues a + // delayed `user@mouseterm:~$` write that lands underneath the + // menu and stays there until the next runner re-render clears it. + adapter.setScenario(PANE_MAIN, { name: "none", chunks: [] }); + + const tutorialState = new TutorialState(); + stateRef.current = tutorialState; + const detector = new TutDetector(tutorialState, registry, mouseSelection, { + onAlertDemoPaneChange: (id) => { + alertDemoPaneIdRef.current = id; + }, + }); + detectorRef.current = detector; - // 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, - }), + (terminalId, name, args, onExit) => { + if (name === "tut") { + return new TutRunner({ + adapter, + terminalId, + state: tutorialState, + onExit, + onTriggerBusyDemo: () => { + const paneId = alertDemoPaneIdRef.current ?? PANE_TARGET; + const sessionId = registry.resolveTerminalSessionId(paneId); + busyDemoDisposeRef.current?.(); + busyDemoDisposeRef.current = adapter.pumpActivity( + sessionId, + BUSY_DEMO_DURATION_MS, + BUSY_DEMO_INTERVAL_MS, + ); + }, + }); + } + if (name === "ascii-splash" || name === "splash") { + return new asciiSplash.AsciiSplashRunner({ + adapter, + terminalId, + args, + onExit, + }); + } + return null; + }, ); 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; + shellRegistry.ensureShell(PANE_MAIN); + shellRegistry.ensureShell(PANE_TARGET); + shellRegistry.ensureShell(PANE_BOXED); + + // Subscribe before Wall mounts so the spawn fired by TerminalPane's + // mount effect doesn't race past us. If the pty already exists by + // the time we get here, fire immediately. + spawnUnsubRef.current = adapter.onPtySpawn(({ id }) => { + if (id === PANE_MAIN) tryAutoStartTutorial(); + }); + if (adapter.hasPty(PANE_MAIN)) tryAutoStartTutorial(); setWallModule({ Wall: wall.Wall }); } loadWall(); return () => { + cancelled = true; for (const disposable of dockviewDisposablesRef.current) { disposable.dispose(); } dockviewDisposablesRef.current = []; detectorRef.current?.dispose(); + detectorRef.current = null; shellRegistryRef.current?.disposeAll(); shellRegistryRef.current = null; + stateRef.current = null; + tutorialAutoStartedRef.current = false; + alertDemoPaneIdRef.current = null; + spawnUnsubRef.current?.(); + spawnUnsubRef.current = null; + busyDemoDisposeRef.current?.(); + busyDemoDisposeRef.current = null; }; }, []); @@ -87,18 +152,18 @@ function Playground() { dockviewDisposablesRef.current.push(addDisposable); api.addPanel({ - id: PANE_NPM, + id: PANE_TARGET, component: "terminal", tabComponent: "terminal", - title: "npm install", + title: "demo", position: { referencePanel: PANE_MAIN, direction: "right" }, }); api.addPanel({ - id: PANE_LS, + id: PANE_BOXED, component: "terminal", tabComponent: "terminal", - title: "project", - position: { referencePanel: PANE_NPM, direction: "below" }, + title: "release notes", + position: { referencePanel: PANE_TARGET, direction: "below" }, }); const mainPanel = api.getPanel(PANE_MAIN); @@ -128,6 +193,7 @@ function Playground() { {WallModule ? (