From ab42b984a7d9182677a59e3647829a3ea6ff4546 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 14:27:15 -0700 Subject: [PATCH 01/54] Rewrite playground tutorial as interactive TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 6-step "run a command, get a printed instruction" tut with a full-screen alt-screen runner: each item shows a spinner while pending and becomes a green check when MouseTerm detects the action. Three sections — keyboard navigation, alerts/TODOs, copy/paste — covering 17 detectable interactions. Also fixes the existing spec's wrong "Press Escape to enter command mode" hint: command mode is entered via LShift→RShift / LMeta→RMeta dual-tap. Architecture mirrors ascii-splash-runner: TutRunner drives ANSI output through FakePtyAdapter, TutDetector wires DockviewApi events, the WallEvent stream, subscribeToActivity, and subscribeToMouseSelection to TutorialState.markComplete. Lib additions: - WallEvent.kill / WallEvent.move discriminants (additive) - FakePtyAdapter.playScenarioNow for replaying a scenario on a live pty - SCENARIO_BUSY_TASK_DEMO drives the bell BUSY → RINGING via timing alone - SCENARIO_BOXED_PARAGRAPH exercises Copy Rewrapped's frame stripping Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- docs/specs/tutorial.md | 236 +++++---------- lib/src/components/Wall.tsx | 5 + .../wall/keyboard/handle-pane-shortcuts.ts | 1 + lib/src/components/wall/keyboard/types.ts | 3 +- lib/src/components/wall/wall-types.ts | 4 +- lib/src/lib/platform/fake-adapter.ts | 15 + lib/src/lib/platform/fake-scenarios.ts | 61 +++- website/src/lib/playground-shells.test.ts | 7 +- website/src/lib/playground-shells.ts | 5 +- website/src/lib/tut-detector.ts | 167 +++++++++++ website/src/lib/tut-items.ts | 132 +++++++++ website/src/lib/tut-runner.ts | 279 ++++++++++++++++++ website/src/lib/tutorial-detection.ts | 207 ------------- website/src/lib/tutorial-shell.test.ts | 52 +++- website/src/lib/tutorial-shell.ts | 230 ++++----------- website/src/lib/tutorial-state.ts | 67 +++++ website/src/pages/Playground.tsx | 88 ++++-- 18 files changed, 963 insertions(+), 598 deletions(-) create mode 100644 website/src/lib/tut-detector.ts create mode 100644 website/src/lib/tut-items.ts create mode 100644 website/src/lib/tut-runner.ts delete mode 100644 website/src/lib/tutorial-detection.ts create mode 100644 website/src/lib/tutorial-state.ts diff --git a/AGENTS.md b/AGENTS.md index ea9d7bd..271e657 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, `mouseterm-tut-v2-` localStorage scheme, theme picker, and FakePtyAdapter extensions (`playScenarioNow`, `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/tutorial.md b/docs/specs/tutorial.md index f54b407..d1166b4 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -1,153 +1,99 @@ # 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 shows a spinner while pending, becomes a green check 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. - -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: +## Architecture -- 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. +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): -Supported CLI options in the playground runner: +- **`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 per-item to `localStorage` under the `mouseterm-tut-v2-` prefix. +- **`tut-items.ts`** — section + item definitions (titles, hints) shared by runner and detector. Item ids are stable; they are the localStorage key suffixes. -- `--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. - -Progress is stored in localStorage so the user can leave and return. Show progress as `Step N/6` when displaying each step. +## Layout -### Detection +- `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 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. -Implemented in `website/src/lib/tutorial-detection.ts` (`TutorialDetector` class). Two event sources: +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`. -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`). +## Tutorial Sections -### Phase 1: See Everything at Once +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: -**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.* +- `✓` (green) — complete +- `⠋` (yellow spinner) — first incomplete item, with hint text shown below +- `·` (dim) — later incomplete items -Detection: `onDidAddPanel` fires on DockviewApi (panel count increases beyond initial count). +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. -**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.* +### Section 1 — Keyboard navigation (7 items) -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. +| ID | Title | Detection | +|---|---|---| +| `kb-mode` | Enter command mode (LShift→RShift / LMeta→RMeta) | `WallEvent.modeChange` to `'command'` | +| `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`) | -### Phase 2: Focus and Background +Prose under the section: "tmux shortcuts also work — `% " d x`." -**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.* +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. -Detection: Watches `WallEvent.zoomChange` — requires both a `zoomed: true` then `zoomed: false` event (unzoom after zoom). +### Section 2 — Alert and TODO (6 items) -**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.* +The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, todo)` transitions. -Detection: Watches `WallEvent.minimizeChange` — requires `count > 0` (minimize) then `count === 0` (reattach back to zero). +| 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` | -### Phase 3: Keyboard Power +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, and calls `adapter.playScenarioNow(PANE_TARGET, SCENARIO_BUSY_TASK_DEMO)`. Detection is purely output-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` | Click the cursor icon on the ascii-splash pane | `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.playScenarioNow(id, scenario)`** — public method that replays a `FakeScenario` on a live pty; cancels any in-flight scenario for the same id first. Drives `alertManager.onData()` exactly like the spawn-time playback so bell state transitions fire. +- **`SCENARIO_BUSY_TASK_DEMO`** — three output bursts spaced ~1.6s apart (>1.5s = "still working") followed by silence so `T_MIGHT_NEED_ATTENTION` (2s) and `T_ALERT_RINGING_CONFIRM` (3s) elapse. Drives the bell `BUSY → MIGHT_NEED_ATTENTION → ALERT_RINGING`. +- **`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. +- Per-item completion: `localStorage["mouseterm-tut-v2-"] = "1"`. Wiped on `TutorialState.reset()`. +- Legacy keys `mouseterm-tutorial-step-N` from the previous design are not read; new playground sessions get a fresh start. ## Theme Picker @@ -164,68 +110,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..e4e7b7f 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -198,12 +198,16 @@ export function Wall({ if (!ck || ck.exit) return; setConfirmKill({ ...ck, exit: 'confirm' }); onExit(); + onEventRef.current?.({ type: 'kill', id: ck.id }); confirmTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_CONFIRM_MS); }, []); // --- External event notifications --- const onEventRef = useRef(onEvent); onEventRef.current = onEvent; + const fireEvent = useCallback((event: WallEvent) => { + onEventRef.current?.(event); + }, []); useEffect(() => { onEventRef.current?.({ type: 'modeChange', mode }); }, [mode]); useEffect(() => { onEventRef.current?.({ type: 'zoomChange', zoomed }); }, [zoomed]); @@ -555,6 +559,7 @@ export function Wall({ setConfirmKill, setRenamingPaneId, setSelectedId, + fireEvent, }); // --- Render --- diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts index 984ba40..3c329af 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts @@ -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/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 9b18dc4..ffea99c 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -217,6 +217,21 @@ export class FakePtyAdapter implements PlatformAdapter { } } + /** + * Replay a scenario against a live pty. Cancels any in-flight scenario + * for the same id first. Drives the alert-manager via `onData` exactly + * like the spawn-time playback path so bell state transitions fire. + */ + playScenarioNow(id: string, scenario: FakeScenario): void { + if (!this.terminals.has(id)) return; + const existing = this.activeTimers.get(id); + if (existing) { + existing.forEach(clearTimeout); + this.activeTimers.delete(id); + } + this.playScenario(id, scenario); + } + 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..d40b1f4 100644 --- a/lib/src/lib/platform/fake-scenarios.ts +++ b/lib/src/lib/platform/fake-scenarios.ts @@ -75,19 +75,6 @@ export const SCENARIO_SHELL_PROMPT: FakeScenario = { 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), - ], -}; - /** Types `ls -la` then shows colorized directory listing. */ export const SCENARIO_LS_OUTPUT: FakeScenario = { name: 'ls-output', @@ -155,6 +142,54 @@ export const SCENARIO_LONG_RUNNING: FakeScenario = { ], }; +/** + * Fake busy-task demo for the playground tutorial. Designed to drive the + * alert-manager through BUSY → MIGHT_NEED_ATTENTION → ALERT_RINGING with + * no shell integration: + * - Three output bursts spaced ~1.6s apart (>1.5s = "still working") + * - Then ~6s of silence so the silence thresholds elapse + */ +export const SCENARIO_BUSY_TASK_DEMO: FakeScenario = { + name: 'busy-task-demo', + chunks: [ + instant(`${fg(33)}Building project...${RESET}\r\n`, 0), + instant(` ${fg(90)}compiling src/main.ts${RESET}\r\n`, 1600), + instant(` ${fg(90)}compiling src/util.ts${RESET}\r\n`, 1600), + instant(` ${fg(90)}bundling output${RESET}\r\n`, 1600), + instant(`${fg(32)}done!${RESET}\r\n`, 200), + instant(PROMPT, 100), + ], +}; + +/** + * 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. */ export const SCENARIO_FAST_OUTPUT: FakeScenario = { name: 'fast-output', diff --git a/website/src/lib/playground-shells.test.ts b/website/src/lib/playground-shells.test.ts index cc20bec..8ef3cfb 100644 --- a/website/src/lib/playground-shells.test.ts +++ b/website/src/lib/playground-shells.test.ts @@ -42,7 +42,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..e593c9d 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,7 @@ 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), ); this.shells.set(id, shell); this.adapter.setInputHandler(id, (data) => shell.handleInput(data)); diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts new file mode 100644 index 0000000..5c69c00 --- /dev/null +++ b/website/src/lib/tut-detector.ts @@ -0,0 +1,167 @@ +/** + * Watches DockviewApi, WallEvents, the alert/activity store, and the + * mouse-selection store, and marks the matching tutorial item complete in + * `TutorialState` whenever the user performs the corresponding action. + */ + +import type { TutorialState } from "./tutorial-state"; + +type DockviewApi = any; +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; +} + +export class TutDetector { + private state: TutorialState; + private currentMode: WallMode = "command"; + private commandModePanels = new Set(); + private prevActivity = new Map(); + private prevMouse = new Map(); + private disposables: (() => void)[] = []; + private attached = false; + + constructor(state: TutorialState) { + this.state = state; + } + + attach( + api: DockviewApi, + activityStore: ActivityStoreModule, + mouseStore: MouseSelectionModule, + ): void { + if (this.attached) return; + this.attached = true; + + // Seed previous-state maps so the very first listener fire isn't + // mis-read as a transition from "nothing". + for (const [id, s] of activityStore.getActivitySnapshot()) { + this.prevActivity.set(id, { ...s }); + } + for (const [id, s] of mouseStore.getMouseSelectionSnapshot()) { + this.prevMouse.set(id, { ...s }); + } + + const activeUnsub = api.onDidActivePanelChange((panel: { id?: string } | undefined) => { + if (!panel?.id) return; + if (this.currentMode !== "command") return; + this.commandModePanels.add(panel.id); + if (this.commandModePanels.size >= 2) { + this.state.markComplete("kb-arrows"); + } + }); + this.disposables.push(() => activeUnsub.dispose()); + + this.disposables.push( + activityStore.subscribeToActivity(() => this.processActivity(activityStore)), + ); + this.disposables.push( + mouseStore.subscribeToMouseSelection(() => this.processMouse(mouseStore)), + ); + } + + 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(); + } + 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"); + break; + } + } + + private processActivity(store: ActivityStoreModule): void { + const snapshot = store.getActivitySnapshot(); + for (const [id, current] of snapshot) { + const prev = this.prevActivity.get(id); + + const wasEnabled = prev && prev.status !== "ALERT_DISABLED"; + const nowEnabled = current.status !== "ALERT_DISABLED"; + if (!wasEnabled && nowEnabled) { + this.state.markComplete("al-enable"); + } + + if (current.status === "BUSY" || current.status === "MIGHT_BE_BUSY") { + this.state.markComplete("al-busy"); + } + if (current.status === "ALERT_RINGING") { + this.state.markComplete("al-ring"); + } + + const prevTodo = prev?.todo ?? false; + if (!prevTodo && current.todo) { + if (prev?.status === "ALERT_RINGING") { + this.state.markComplete("al-todo-auto"); + } else { + this.state.markComplete("al-todo-manual"); + } + } + if (prevTodo && !current.todo) { + this.state.markComplete("al-todo-clear"); + } + + this.prevActivity.set(id, { ...current }); + } + } + + private processMouse(store: MouseSelectionModule): void { + const snapshot = store.getMouseSelectionSnapshot(); + for (const [id, current] of snapshot) { + const prev = this.prevMouse.get(id); + + 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"); + } + + const prevOverride = prev?.override ?? "off"; + if (prevOverride === "off" && current.override !== "off") { + this.state.markComplete("cp-override"); + } + + this.prevMouse.set(id, { ...current }); + } + } + + dispose(): void { + for (const fn of this.disposables) fn(); + this.disposables = []; + this.attached = false; + } +} diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts new file mode 100644 index 0000000..3ea405f --- /dev/null +++ b/website/src/lib/tut-items.ts @@ -0,0 +1,132 @@ +/** + * Section + item definitions shared by TutRunner (display) and TutDetector + * (event-to-completion mapping). Item ids are stable — they're the + * localStorage key suffixes. + */ + +export type ItemId = string; + +export interface Item { + id: ItemId; + title: string; + hint?: string; +} + +export interface Section { + id: string; + title: string; + items: Item[]; + prose?: string[]; +} + +export const SECTIONS: 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, or press a in command mode with the 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 the demo pane.', + }, + { + id: 'al-ring', + title: 'Bell rings when the task completes', + }, + { + 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 text in any pane', + }, + { + id: 'cp-raw', + title: 'Click Copy Raw', + }, + { + id: 'cp-rewrap', + title: 'Click Copy Rewrapped on the boxed paragraph', + }, + { + id: 'cp-override', + title: 'Click the cursor icon on the ascii-splash pane', + hint: 'Type ascii-splash in any pane to launch it first.', + }, + ], + prose: [ + 'Some programs trap the mouse — the cursor icon lets you override.', + 'ascii-splash redraws every frame, so it cancels selections: looks cool, undragable.', + ], + }, +]; + +export const ALL_ITEM_IDS: ItemId[] = SECTIONS.flatMap((s) => s.items.map((i) => i.id)); + +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.ts b/website/src/lib/tut-runner.ts new file mode 100644 index 0000000..2d81d57 --- /dev/null +++ b/website/src/lib/tut-runner.ts @@ -0,0 +1,279 @@ +/** + * Browser TUI for the playground tutorial. Follows the same pattern as + * `ascii-splash-runner.ts`: alt-screen on, render an ANSI-driven view from + * `TutorialState`, accept input via `FakePtyAdapter.writePty`, restore on + * exit. No `terminal-kit` package dependency. + */ + +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"; + +const ESC = "\x1b["; +const RESET = `${ESC}0m`; +const BOLD = `${ESC}1m`; +const DIM = `${ESC}2m`; +const fg = (code: number) => `${ESC}${code}m`; + +const ENTER_ALT = "\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l"; +const LEAVE_ALT = "\x1b[2J\x1b[H\x1b[?25h\x1b[?1049l"; +const HOME = "\x1b[H"; +const CLEAR = "\x1b[2J"; + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_INTERVAL_MS = 100; + +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"; + +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 spinnerFrame = 0; + private spinnerTimer: ReturnType | null = null; + private stateUnsub: (() => void) | null = null; + private resizeUnsub: (() => void) | 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); + this.stateUnsub = this.state.subscribe(() => this.render()); + this.resizeUnsub = this.adapter.onPtyResize((d) => { + if (d.id === this.terminalId) this.render(); + }); + this.spinnerTimer = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; + this.render(); + }, SPINNER_INTERVAL_MS); + this.render(); + } + + 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 (ch === "q" || ch === "Q") { + this.exit(); + return; + } + if ( + this.screen === "section" && + this.sectionId === "alert" && + (ch === "s" || ch === "S") + ) { + this.onTriggerBusyDemo?.(); + i += 1; + continue; + } + i += 1; + } + } + + dispose(): void { + this.cleanup(false); + } + + // --- Input --- + + private handleArrow(letter: string): void { + if (this.screen !== "menu") return; + if (letter === "A") { + this.menuIndex = (this.menuIndex - 1 + SECTIONS.length) % SECTIONS.length; + } else if (letter === "B") { + this.menuIndex = (this.menuIndex + 1) % SECTIONS.length; + } else { + return; + } + this.render(); + } + + private handleEnter(): void { + if (this.screen === "menu") { + const section = SECTIONS[this.menuIndex]; + if (!section) return; + this.sectionId = section.id; + this.screen = "section"; + this.render(); + } + } + + private handleEscape(): void { + if (this.screen === "section") { + this.sectionId = null; + this.screen = "menu"; + this.render(); + return; + } + this.exit(); + } + + private exit(): void { + if (this.disposed) return; + this.cleanup(true); + } + + // --- Render --- + + private render(): void { + if (this.disposed) return; + const lines = this.screen === "menu" ? this.renderMenu() : this.renderSection(); + let out = `${HOME}${CLEAR}`; + for (const line of lines) { + out += `${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}`); + }); + lines.push(""); + return lines; + } + + private renderSection(): string[] { + const section = SECTIONS.find((s) => s.id === this.sectionId); + if (!section) { + this.screen = "menu"; + return this.renderMenu(); + } + 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(""); + for (const p of section.prose) lines.push(` ${DIM}${p}${RESET}`); + } + + if (section.id === "alert") { + lines.push(""); + lines.push(` ${DIM}Press ${fg(36)}s${RESET}${DIM} here to start a fake busy task.${RESET}`); + } + + if (done === total) { + lines.push(""); + lines.push(` ${fg(32)}Section complete.${RESET}`); + } + + return lines; + } + + 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)}${SPINNER_FRAMES[this.spinnerFrame]}${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) { + lines.push(` ${DIM}${item.hint}${RESET}`); + } + return lines; + } + + // --- Internals --- + + private cleanup(notifyExit: boolean): void { + if (this.disposed) return; + this.disposed = true; + if (this.spinnerTimer) { + clearInterval(this.spinnerTimer); + this.spinnerTimer = null; + } + this.stateUnsub?.(); + this.stateUnsub = null; + this.resizeUnsub?.(); + this.resizeUnsub = null; + this.write(LEAVE_ALT); + if (notifyExit) this.onExit(); + } + + private write(data: string): void { + this.adapter.sendOutput(this.terminalId, data); + } +} + 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..3a05513 100644 --- a/website/src/lib/tutorial-shell.ts +++ b/website/src/lib/tutorial-shell.ts @@ -1,61 +1,11 @@ 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.', - }, -]; - export type SendOutput = (data: string) => void; export interface InteractiveProgram { @@ -64,20 +14,35 @@ 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) { this.sendOutput = sendOutput; - this.startAsciiSplash = startAsciiSplash; + this.startProgram = startProgram; } dispose(): void { @@ -85,11 +50,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 +91,6 @@ export class TutorialShell { index += ss3[0].length - 1; continue; } - // Lone escape byte: drop it so partial sequences don't echo. continue; } @@ -164,7 +147,6 @@ export class TutorialShell { return; } } - this.lineBuffer = this.history[this.historyIndex]; this.redrawPromptLine(); } @@ -175,129 +157,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` - ); - } - - 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; + this.activeProgram = program; + this.activeProgram.start(); } - 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..e317894 --- /dev/null +++ b/website/src/lib/tutorial-state.ts @@ -0,0 +1,67 @@ +import { ALL_ITEM_IDS, SECTIONS, type ItemId } from "./tut-items"; + +const STORAGE_PREFIX = "mouseterm-tut-v2-"; + +export class TutorialState { + private completed = new Set(); + private listeners = new Set<() => void>(); + + constructor() { + if (typeof localStorage === "undefined") return; + for (const id of ALL_ITEM_IDS) { + if (localStorage.getItem(STORAGE_PREFIX + id) === "1") { + this.completed.add(id); + } + } + } + + 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); + if (typeof localStorage !== "undefined") { + localStorage.setItem(STORAGE_PREFIX + id, "1"); + } + this.notify(); + return true; + } + + reset(): void { + if (this.completed.size === 0) return; + if (typeof localStorage !== "undefined") { + for (const id of this.completed) { + localStorage.removeItem(STORAGE_PREFIX + id); + } + } + this.completed.clear(); + 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(); + } +} diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index cee3807..4b12de1 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 { 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,92 @@ 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 detectorAttachRef = useRef<((api: any) => void) | null>(null); 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 has no scenario — runner takes over the screen entirely. + + const tutorialState = new TutorialState(); + stateRef.current = tutorialState; + const detector = new TutDetector(tutorialState); + 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: () => { + adapter.playScenarioNow(PANE_TARGET, scenarios.SCENARIO_BUSY_TASK_DEMO); + }, + }); + } + 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); + shellRegistry.ensureShell(PANE_TARGET); + shellRegistry.ensureShell(PANE_BOXED); - const detector = new TutorialDetector(mainShell); - detectorRef.current = detector; + // Auto-launch the tutorial in the main pane. + mainShell.runCommand("tut"); + + // Stash modules for handleApiReady to attach the detector once + // dockview is alive. + detectorAttachRef.current = (api) => { + detector.attach(api, registry, mouseSelection); + }; 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; }; }, []); @@ -87,24 +121,24 @@ 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); if (mainPanel) mainPanel.api.setActive(); - detectorRef.current?.attach(api); + detectorAttachRef.current?.(api); }, []); const handleWallEvent = useCallback((event: WallEvent) => { From 40bf2f2135515d552327efb211ae6fa9cfd837f7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 14:57:56 -0700 Subject: [PATCH 02/54] Polish tutorial runner: reset menu, in-place busy demo, esc hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Reset progress" entry below the three sections; selecting it opens a confirmation screen that requires typing `reset` + Enter before clearing localStorage. Esc cancels; a wrong word shows a red warning and clears the buffer so the user can try again. - Replace the scenario-driven busy task on tut-target with a no-output FakePtyAdapter.pumpActivity helper. Tut-target's bell still tilts and rings on its own tab; the visible animation lives entirely inside the tutorial pane: a 3-second countdown ("Fake task will finish in 3.. → 2.. → 1..") followed by a static "Fake task finished. Press s to start another one." Pressing s during or after the countdown restarts it. - Append "Press Esc to go back." to the per-section "Section complete." footer. - Drop the now-unused SCENARIO_BUSY_TASK_DEMO scenario. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/tutorial.md | 7 +- lib/src/lib/platform/fake-adapter.ts | 29 ++++++ lib/src/lib/platform/fake-scenarios.ts | 19 ---- website/src/lib/tut-runner.ts | 133 +++++++++++++++++++++++-- website/src/pages/Playground.tsx | 6 +- 5 files changed, 165 insertions(+), 29 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index d1166b4..9ae937d 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -62,7 +62,10 @@ The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, t | `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` | -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, and calls `adapter.playScenarioNow(PANE_TARGET, SCENARIO_BUSY_TASK_DEMO)`. Detection is purely output-based via the existing `ActivityMonitor`, so no shell integration is required. +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: + +1. Calls `adapter.pumpActivity(PANE_TARGET, 3000, 800)` — drives the alert-manager's activity monitor on the demo pane for 3 seconds, with **no text output**, so the bell on the demo tab tilts to BUSY without scrolling any scenario text on that pane. +2. Animates a countdown in-place where the "Press s…" hint was: `⠋ Fake task will finish in 3..` → `2..` → `1..` → `✓ Fake task done.` → `⠋ Listening for the bell to ring…` → `✓ Bell rang.` Total ~9s. Detection is purely timing-based via the existing `ActivityMonitor`, so no shell integration is required. ### Section 3 — Copy paste (4 items) @@ -85,7 +88,7 @@ The Copy Rewrapped step uses `SCENARIO_BOXED_PARAGRAPH` (in `lib/src/lib/platfor - **`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.playScenarioNow(id, scenario)`** — public method that replays a `FakeScenario` on a live pty; cancels any in-flight scenario for the same id first. Drives `alertManager.onData()` exactly like the spawn-time playback so bell state transitions fire. -- **`SCENARIO_BUSY_TASK_DEMO`** — three output bursts spaced ~1.6s apart (>1.5s = "still working") followed by silence so `T_MIGHT_NEED_ATTENTION` (2s) and `T_ALERT_RINGING_CONFIRM` (3s) elapse. Drives the bell `BUSY → MIGHT_NEED_ATTENTION → ALERT_RINGING`. +- **`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. - **`SCENARIO_BOXED_PARAGRAPH`** — boxed multi-line prose, used by `tut-boxed`. `SCENARIO_TUTORIAL_MOTD` was removed — the runner now owns the main pane's screen. diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index ffea99c..42d8169 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -232,6 +232,35 @@ export class FakePtyAdapter implements PlatformAdapter { this.playScenario(id, scenario); } + /** + * 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; + this.alertManager.onData(id); + const tick = () => { + if (cancelled) return; + this.alertManager.onData(id); + }; + const interval = setInterval(tick, intervalMs); + const stop = setTimeout(() => { + cancelled = true; + clearInterval(interval); + }, durationMs); + return () => { + cancelled = true; + clearInterval(interval); + clearTimeout(stop); + }; + } + 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 d40b1f4..f625ff6 100644 --- a/lib/src/lib/platform/fake-scenarios.ts +++ b/lib/src/lib/platform/fake-scenarios.ts @@ -142,25 +142,6 @@ export const SCENARIO_LONG_RUNNING: FakeScenario = { ], }; -/** - * Fake busy-task demo for the playground tutorial. Designed to drive the - * alert-manager through BUSY → MIGHT_NEED_ATTENTION → ALERT_RINGING with - * no shell integration: - * - Three output bursts spaced ~1.6s apart (>1.5s = "still working") - * - Then ~6s of silence so the silence thresholds elapse - */ -export const SCENARIO_BUSY_TASK_DEMO: FakeScenario = { - name: 'busy-task-demo', - chunks: [ - instant(`${fg(33)}Building project...${RESET}\r\n`, 0), - instant(` ${fg(90)}compiling src/main.ts${RESET}\r\n`, 1600), - instant(` ${fg(90)}compiling src/util.ts${RESET}\r\n`, 1600), - instant(` ${fg(90)}bundling output${RESET}\r\n`, 1600), - instant(`${fg(32)}done!${RESET}\r\n`, 200), - instant(PROMPT, 100), - ], -}; - /** * Boxed paragraph for Copy Rewrapped vs Copy Raw demonstration. The frame * is pure box-drawing characters so `rewrap.ts` strips them; the text diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 2d81d57..c464d9e 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -33,7 +33,9 @@ interface TutRunnerOptions { onTriggerBusyDemo?: () => void; } -type Screen = "menu" | "section"; +type Screen = "menu" | "section" | "reset"; + +const RESET_CONFIRM_WORD = "reset"; export class TutRunner implements InteractiveProgram { private adapter: FakePtyAdapter; @@ -45,10 +47,13 @@ export class TutRunner implements InteractiveProgram { 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) { @@ -99,6 +104,21 @@ export class TutRunner implements InteractiveProgram { 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.exit(); return; @@ -108,7 +128,7 @@ export class TutRunner implements InteractiveProgram { this.sectionId === "alert" && (ch === "s" || ch === "S") ) { - this.onTriggerBusyDemo?.(); + this.startBusyDemo(); i += 1; continue; } @@ -122,12 +142,18 @@ export class TutRunner implements InteractiveProgram { // --- 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 + SECTIONS.length) % SECTIONS.length; + this.menuIndex = (this.menuIndex - 1 + len) % len; } else if (letter === "B") { - this.menuIndex = (this.menuIndex + 1) % SECTIONS.length; + this.menuIndex = (this.menuIndex + 1) % len; } else { return; } @@ -136,11 +162,32 @@ export class TutRunner implements InteractiveProgram { 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"; 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(); + } } } @@ -151,6 +198,13 @@ export class TutRunner implements InteractiveProgram { this.render(); return; } + if (this.screen === "reset") { + this.resetBuffer = ""; + this.resetMismatch = false; + this.screen = "menu"; + this.render(); + return; + } this.exit(); } @@ -159,11 +213,26 @@ export class TutRunner implements InteractiveProgram { this.cleanup(true); } + private startBusyDemo(): void { + // Phases are computed from elapsed time inside renderBusyDemoLines: + // 0–3s countdown, 3–8s "listening", 8–11s "bell rang", then back to + // the original "Press s…" hint. Driven by the spinner's render tick; + // no separate timer needed. + this.busyDemoStart = Date.now(); + this.onTriggerBusyDemo?.(); + this.render(); + } + // --- Render --- private render(): void { if (this.disposed) return; - const lines = this.screen === "menu" ? this.renderMenu() : this.renderSection(); + const lines = + this.screen === "menu" + ? this.renderMenu() + : this.screen === "reset" + ? this.renderReset() + : this.renderSection(); let out = `${HOME}${CLEAR}`; for (const line of lines) { out += `${line}\r\n`; @@ -192,7 +261,37 @@ export class TutRunner implements InteractiveProgram { : `${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 ${fg(36)}reset${RESET}${DIM} 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; } @@ -221,17 +320,36 @@ export class TutRunner implements InteractiveProgram { if (section.id === "alert") { lines.push(""); - lines.push(` ${DIM}Press ${fg(36)}s${RESET}${DIM} here to start a fake busy task.${RESET}`); + lines.push(...this.renderBusyDemoLines()); } if (done === total) { lines.push(""); - lines.push(` ${fg(32)}Section complete.${RESET}`); + lines.push( + ` ${fg(32)}Section complete.${RESET} ${DIM}Press ${fg(36)}Esc${RESET}${DIM} to go back.${RESET}`, + ); } return lines; } + private renderBusyDemoLines(): string[] { + const idleHint = ` ${DIM}Press ${fg(36)}s${RESET}${DIM} here to start a fake busy task.${RESET}`; + if (this.busyDemoStart === null) return [idleHint]; + const elapsed = Date.now() - this.busyDemoStart; + if (elapsed < 3_000) { + const spinner = SPINNER_FRAMES[this.spinnerFrame]; + const secsLeft = Math.max(1, Math.ceil((3_000 - elapsed) / 1_000)); + const dots = ".".repeat(4 - secsLeft); + return [ + ` ${fg(33)}${spinner}${RESET} Fake task will finish in ${BOLD}${secsLeft}${RESET}${dots}`, + ]; + } + return [ + ` ${fg(32)}✓${RESET} Fake task finished. ${DIM}Press ${fg(36)}s${RESET}${DIM} 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; @@ -264,6 +382,7 @@ export class TutRunner implements InteractiveProgram { clearInterval(this.spinnerTimer); this.spinnerTimer = null; } + this.busyDemoStart = null; this.stateUnsub?.(); this.stateUnsub = null; this.resizeUnsub?.(); diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index 4b12de1..abf1ee8 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -63,7 +63,11 @@ function Playground() { state: tutorialState, onExit, onTriggerBusyDemo: () => { - adapter.playScenarioNow(PANE_TARGET, scenarios.SCENARIO_BUSY_TASK_DEMO); + // Drive the alert-manager on tut-target for 3s of activity, + // then silence (the alert-manager rings ~5s after silence + // begins). No text output — the visual feedback is the + // countdown animation rendered inside the tutorial runner. + adapter.pumpActivity(PANE_TARGET, 3000, 800); }, }); } From 0d429bb1bafa07959f0753c913bbebcd3f3893fa Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 15:11:39 -0700 Subject: [PATCH 03/54] Quiet the tutorial pane between user actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active-item marker was a braille spinner driven by a 100ms timer that wrote a fresh frame to xterm on every tick. That continuous output fed FakePtyAdapter's activity monitor on whichever pane hosted the runner, which kept the alert-manager out of the silence window — so the bell on that pane could never reach RINGING. Replace the active-item spinner with a static `●` glyph and only run the spinner timer during the 3-second busy-task countdown, where the animation is what the user is meant to see. After the countdown, stop the timer and settle on a static "Fake task finished" line. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-runner.ts | 43 +++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index c464d9e..64edea5 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -24,6 +24,15 @@ const CLEAR = "\x1b[2J"; 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; @@ -70,11 +79,27 @@ export class TutRunner implements InteractiveProgram { 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 >= 3_000 + ) { + this.stopSpinnerTicks(); + } }, SPINNER_INTERVAL_MS); - this.render(); + } + + private stopSpinnerTicks(): void { + if (!this.spinnerTimer) return; + clearInterval(this.spinnerTimer); + this.spinnerTimer = null; } handleInput(data: string): void { @@ -214,12 +239,13 @@ export class TutRunner implements InteractiveProgram { } private startBusyDemo(): void { - // Phases are computed from elapsed time inside renderBusyDemoLines: - // 0–3s countdown, 3–8s "listening", 8–11s "bell rang", then back to - // the original "Press s…" hint. Driven by the spinner's render tick; - // no separate timer needed. + // Countdown phase (0–3s) is the only animated piece in the runner. + // It writes a fresh frame to xterm every SPINNER_INTERVAL_MS, then + // settles into a static "Fake task finished" line that stays put + // until the user presses s again. this.busyDemoStart = Date.now(); this.onTriggerBusyDemo?.(); + this.startSpinnerTicks(); this.render(); } @@ -357,7 +383,7 @@ export class TutRunner implements InteractiveProgram { if (complete) { mark = `${fg(32)}✓${RESET}`; } else if (isActive) { - mark = `${fg(33)}${SPINNER_FRAMES[this.spinnerFrame]}${RESET}`; + mark = `${fg(33)}${ACTIVE_ITEM_GLYPH}${RESET}`; } else { mark = `${DIM}·${RESET}`; } @@ -378,10 +404,7 @@ export class TutRunner implements InteractiveProgram { private cleanup(notifyExit: boolean): void { if (this.disposed) return; this.disposed = true; - if (this.spinnerTimer) { - clearInterval(this.spinnerTimer); - this.spinnerTimer = null; - } + this.stopSpinnerTicks(); this.busyDemoStart = null; this.stateUnsub?.(); this.stateUnsub = null; From 5dbbe82145aa00d633a9b7e4a8fc36c9abb9163d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 15:16:00 -0700 Subject: [PATCH 04/54] Drive fake adapter activity monitor from sendOutput The Tauri (`standalone/src/tauri-adapter.ts:49`) and VSCode (`vscode-ext/src/message-router.ts:33`) adapters call `alertManager.onData(id)` on every PTY data event, which is what makes the bell tilt and ring as a session does work and goes quiet. The fake adapter only fed activity from `writePty` (when no input handler was set), `playScenario`, and the explicit `pumpActivity` helper. In the playground every pane registers an input handler via `PlaygroundShellRegistry`, so the `writePty` path is bypassed; and all browser-side programs (TutorialShell echo, AsciiSplashRunner frames, TutRunner draws) emit bytes through `sendOutput`, which never touched the activity monitor. Result: alerts in the playground never tilted or rang from organic activity. Have `sendOutput` drive `alertManager.onData(id)` so the fake adapter matches the real adapters' "any data arriving from the PTY counts as activity" semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/platform/fake-adapter.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 42d8169..8f960e5 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -209,9 +209,16 @@ export class FakePtyAdapter implements PlatformAdapter { this.inputHandlers.delete(id); } - /** Send data to a terminal's output (as if the PTY produced it). */ + /** + * 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. + */ sendOutput(id: string, data: string): void { if (!this.terminals.has(id)) return; + this.alertManager.onData(id); for (const handler of this.dataHandlers) { handler({ id, data }); } From a5419cd5e3f85df3fa774e3eeb11a504494b9ff1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 15:29:52 -0700 Subject: [PATCH 05/54] Wait for attention to clear before ringing instead of swallowing the alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ActivityMonitor's needs-attention-confirm timer fired and the user was attending the pane, the existing branch called `attend()`, which fully reset the monitor to NOTHING_TO_SHOW. With no further data arriving, no future cycle could ever ring — the alert was silently dropped. This was rarely visible in standalone/vscode because real tasks (`npm install`, builds, etc.) take longer than the 15s attention window, so attention has already expired by the time the silence threshold is reached. In the playground, where demo tasks finish in seconds, the bell consistently went BUSY → MIGHT_NEED_ATTENTION → NOTHING_TO_SHOW and never rang. Fix: when about to ring while attention is held, re-arm the same confirm timer instead of resetting. The bell parks at MIGHT_NEED_ATTENTION until either new output arrives (back to BUSY) or attention expires (rings). Updates the two tests that pinned the old swallow-and-reset behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/activity-monitor.test.ts | 17 ++++++++++++++--- lib/src/lib/activity-monitor.ts | 7 ++++++- lib/src/lib/terminal-registry.alert.test.ts | 14 ++++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/lib/src/lib/activity-monitor.test.ts b/lib/src/lib/activity-monitor.test.ts index c6626c3..d942f01 100644 --- a/lib/src/lib/activity-monitor.test.ts +++ b/lib/src/lib/activity-monitor.test.ts @@ -114,17 +114,28 @@ describe('ActivityMonitor', () => { ]); }); - it('returns to NOTHING_TO_SHOW instead of ALERT_RINGING if attention is still present', () => { + it('waits in MIGHT_NEED_ATTENTION while attention is held, then rings once it clears', () => { const { monitor, changes, attention } = createMonitor(); driveMonitorToMightNeedAttention(monitor); attention.current = true; + + // Confirm timer fires while attention is held — should re-arm, not reset. vi.advanceTimersByTime(3_000); - expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); + expect(monitor.getStatus()).toBe('MIGHT_NEED_ATTENTION'); + + // Even after several confirm cycles, still parked at MIGHT_NEED_ATTENTION. + vi.advanceTimersByTime(6_000); + expect(monitor.getStatus()).toBe('MIGHT_NEED_ATTENTION'); + + // User looks away — next confirm tick should ring. + attention.current = false; + vi.advanceTimersByTime(3_000); + expect(monitor.getStatus()).toBe('ALERT_RINGING'); expect(changes).toEqual([ 'MIGHT_BE_BUSY', 'BUSY', 'MIGHT_NEED_ATTENTION', - 'NOTHING_TO_SHOW', + 'ALERT_RINGING', ]); }); diff --git a/lib/src/lib/activity-monitor.ts b/lib/src/lib/activity-monitor.ts index 56f94e2..6542194 100644 --- a/lib/src/lib/activity-monitor.ts +++ b/lib/src/lib/activity-monitor.ts @@ -162,7 +162,12 @@ export class ActivityMonitor { this.needsAttentionConfirmTimer = null; if (this.status !== 'MIGHT_NEED_ATTENTION') return; if (this.hasAttention()) { - this.attend(); + // User is currently paying attention — don't ring on top of them. + // But the task IS done; we shouldn't silently swallow the alert. + // Re-arm and check again later. The bell stays at MIGHT_NEED_ATTENTION + // until either new output arrives (back to BUSY) or attention + // expires (this branch flips to ALERT_RINGING). + this.startNeedsAttentionConfirmTimer(); return; } this.resetOutputTracking(); diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 6d37346..083933d 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -308,7 +308,7 @@ describe('terminal-registry alert behavior', () => { expect(getActivity(id)).toMatchObject({ status: 'BUSY' }); }); - it('Story 4: completion while still attended does not ring', () => { + it('Story 4: completion while still attended waits at MIGHT_NEED_ATTENTION until attention expires, then rings', () => { const id = 'story-4'; createSession(id); toggleSessionAlert(id); @@ -318,8 +318,18 @@ describe('terminal-registry alert behavior', () => { advance(2_000); advance(3_000); + // Task is done but the user is currently looking at the pane — + // park at MIGHT_NEED_ATTENTION rather than silently swallowing the alert. expect(getActivity(id)).toEqual({ - status: 'NOTHING_TO_SHOW', + status: 'MIGHT_NEED_ATTENTION', + todo: false, + }); + + // Once the attention idle timer expires (cfg.alert.userAttention = 15_000ms), + // the next confirm tick rings. + advance(15_000); + expect(getActivity(id)).toEqual({ + status: 'ALERT_RINGING', todo: false, }); }); From bdda080f0244c763f3553c5de1857b89fdbe05c4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 15:41:28 -0700 Subject: [PATCH 06/54] Revert activity-monitor change; outlast attention in the busy demo instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore `ActivityMonitor.startNeedsAttentionConfirmTimer` to its original "suppress on attention" behavior — the previous "re-arm until attention expires" rewrite parked the bell at MIGHT_NEED_ATTENTION for ~12s after every short task while the attention timer ran down, which is worse than the bug it was meant to fix. The playground's busy demo runs against the alert-manager directly, so it can size itself to outlast the attention idle window. Bump `BUSY_DEMO_DURATION_MS` to `cfg.alert.userAttention + 1` and use it for both the runner's countdown UI and the `pumpActivity` call. By the time the activity-monitor's silence threshold elapses, attention has expired and the bell rings naturally with no special-casing in the alert-manager. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/tutorial.md | 4 ++-- lib/src/lib/activity-monitor.test.ts | 17 +++-------------- lib/src/lib/activity-monitor.ts | 7 +------ lib/src/lib/terminal-registry.alert.test.ts | 14 ++------------ website/src/lib/tut-runner.ts | 18 +++++++++++++----- website/src/pages/Playground.tsx | 14 ++++++++------ 6 files changed, 29 insertions(+), 45 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 9ae937d..7ede5a5 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -64,8 +64,8 @@ The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, t 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: -1. Calls `adapter.pumpActivity(PANE_TARGET, 3000, 800)` — drives the alert-manager's activity monitor on the demo pane for 3 seconds, with **no text output**, so the bell on the demo tab tilts to BUSY without scrolling any scenario text on that pane. -2. Animates a countdown in-place where the "Press s…" hint was: `⠋ Fake task will finish in 3..` → `2..` → `1..` → `✓ Fake task done.` → `⠋ Listening for the bell to ring…` → `✓ Bell rang.` Total ~9s. Detection is purely timing-based via the existing `ActivityMonitor`, so no shell integration is required. +1. Calls `adapter.pumpActivity(PANE_TARGET, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the demo pane with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. `BUSY_DEMO_DURATION_MS` is `cfg.alert.userAttention + 1` so silence begins after the attention idle window has expired; 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. ### Section 3 — Copy paste (4 items) diff --git a/lib/src/lib/activity-monitor.test.ts b/lib/src/lib/activity-monitor.test.ts index d942f01..c6626c3 100644 --- a/lib/src/lib/activity-monitor.test.ts +++ b/lib/src/lib/activity-monitor.test.ts @@ -114,28 +114,17 @@ describe('ActivityMonitor', () => { ]); }); - it('waits in MIGHT_NEED_ATTENTION while attention is held, then rings once it clears', () => { + it('returns to NOTHING_TO_SHOW instead of ALERT_RINGING if attention is still present', () => { const { monitor, changes, attention } = createMonitor(); driveMonitorToMightNeedAttention(monitor); attention.current = true; - - // Confirm timer fires while attention is held — should re-arm, not reset. - vi.advanceTimersByTime(3_000); - expect(monitor.getStatus()).toBe('MIGHT_NEED_ATTENTION'); - - // Even after several confirm cycles, still parked at MIGHT_NEED_ATTENTION. - vi.advanceTimersByTime(6_000); - expect(monitor.getStatus()).toBe('MIGHT_NEED_ATTENTION'); - - // User looks away — next confirm tick should ring. - attention.current = false; vi.advanceTimersByTime(3_000); - expect(monitor.getStatus()).toBe('ALERT_RINGING'); + expect(monitor.getStatus()).toBe('NOTHING_TO_SHOW'); expect(changes).toEqual([ 'MIGHT_BE_BUSY', 'BUSY', 'MIGHT_NEED_ATTENTION', - 'ALERT_RINGING', + 'NOTHING_TO_SHOW', ]); }); diff --git a/lib/src/lib/activity-monitor.ts b/lib/src/lib/activity-monitor.ts index 6542194..56f94e2 100644 --- a/lib/src/lib/activity-monitor.ts +++ b/lib/src/lib/activity-monitor.ts @@ -162,12 +162,7 @@ export class ActivityMonitor { this.needsAttentionConfirmTimer = null; if (this.status !== 'MIGHT_NEED_ATTENTION') return; if (this.hasAttention()) { - // User is currently paying attention — don't ring on top of them. - // But the task IS done; we shouldn't silently swallow the alert. - // Re-arm and check again later. The bell stays at MIGHT_NEED_ATTENTION - // until either new output arrives (back to BUSY) or attention - // expires (this branch flips to ALERT_RINGING). - this.startNeedsAttentionConfirmTimer(); + this.attend(); return; } this.resetOutputTracking(); diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 083933d..6d37346 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -308,7 +308,7 @@ describe('terminal-registry alert behavior', () => { expect(getActivity(id)).toMatchObject({ status: 'BUSY' }); }); - it('Story 4: completion while still attended waits at MIGHT_NEED_ATTENTION until attention expires, then rings', () => { + it('Story 4: completion while still attended does not ring', () => { const id = 'story-4'; createSession(id); toggleSessionAlert(id); @@ -318,18 +318,8 @@ describe('terminal-registry alert behavior', () => { advance(2_000); advance(3_000); - // Task is done but the user is currently looking at the pane — - // park at MIGHT_NEED_ATTENTION rather than silently swallowing the alert. expect(getActivity(id)).toEqual({ - status: 'MIGHT_NEED_ATTENTION', - todo: false, - }); - - // Once the attention idle timer expires (cfg.alert.userAttention = 15_000ms), - // the next confirm tick rings. - advance(15_000); - expect(getActivity(id)).toEqual({ - status: 'ALERT_RINGING', + status: 'NOTHING_TO_SHOW', todo: false, }); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 64edea5..635cb6f 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -5,11 +5,20 @@ * exit. No `terminal-kit` package dependency. */ +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 runs for one tick longer than 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. + */ +export const BUSY_DEMO_DURATION_MS = cfg.alert.userAttention + 1; + const ESC = "\x1b["; const RESET = `${ESC}0m`; const BOLD = `${ESC}1m`; @@ -89,7 +98,7 @@ export class TutRunner implements InteractiveProgram { this.render(); if ( this.busyDemoStart === null || - Date.now() - this.busyDemoStart >= 3_000 + Date.now() - this.busyDemoStart >= BUSY_DEMO_DURATION_MS ) { this.stopSpinnerTicks(); } @@ -363,12 +372,11 @@ export class TutRunner implements InteractiveProgram { const idleHint = ` ${DIM}Press ${fg(36)}s${RESET}${DIM} here to start a fake busy task.${RESET}`; if (this.busyDemoStart === null) return [idleHint]; const elapsed = Date.now() - this.busyDemoStart; - if (elapsed < 3_000) { + if (elapsed < BUSY_DEMO_DURATION_MS) { const spinner = SPINNER_FRAMES[this.spinnerFrame]; - const secsLeft = Math.max(1, Math.ceil((3_000 - elapsed) / 1_000)); - const dots = ".".repeat(4 - secsLeft); + 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}${dots}`, + ` ${fg(33)}${spinner}${RESET} Fake task will finish in ${BOLD}${secsLeft}${RESET} seconds.`, ]; } return [ diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index abf1ee8..dadd63f 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -4,7 +4,7 @@ import { ThemePicker } from "mouseterm-lib/components/ThemePicker"; import { PlaygroundShellRegistry } from "../lib/playground-shells"; import { TutorialState } from "../lib/tutorial-state"; import { TutDetector } from "../lib/tut-detector"; -import { TutRunner } from "../lib/tut-runner"; +import { BUSY_DEMO_DURATION_MS, TutRunner } from "../lib/tut-runner"; export { Playground as Component }; @@ -63,11 +63,13 @@ function Playground() { state: tutorialState, onExit, onTriggerBusyDemo: () => { - // Drive the alert-manager on tut-target for 3s of activity, - // then silence (the alert-manager rings ~5s after silence - // begins). No text output — the visual feedback is the - // countdown animation rendered inside the tutorial runner. - adapter.pumpActivity(PANE_TARGET, 3000, 800); + // Run for slightly longer than the user-attention idle + // window so silence begins after attention has expired. + // Otherwise the activity-monitor's "user is looking at + // this pane" check would suppress the ring instead of + // letting it fire. No text output — the visual feedback + // is the countdown rendered inside the tutorial runner. + adapter.pumpActivity(PANE_TARGET, BUSY_DEMO_DURATION_MS, 800); }, }); } From 0af5e2ef23aa09f7a786f81ff8b0ed23e524d748 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 15:51:05 -0700 Subject: [PATCH 07/54] Snapshot the tutorial runner's UI states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks down the rendered output for five views: the top-level menu, each of the three sections in their all-incomplete state, and one section (Keyboard navigation) in its all-complete state. Subsequent edits to section titles, item hints, the active-item glyph, the progress format, the box layout, or any color now produce a clear snapshot diff instead of silently changing what users see. Drives a real `FakePtyAdapter` rather than mocking — captures every `sendOutput` write via `onPtyData` and snapshots the last full frame (everything from the trailing `HOME + CLEAR` onward). The active-item marker is the static `●`, the spinner timer only runs during the busy demo (not exercised here), and `Date.now()` is unused outside that path, so output is fully deterministic. First snapshot-based test in the repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/__snapshots__/tut-runner.test.ts.snap | 84 +++++++++++++++++++ website/src/lib/tut-runner.test.ts | 76 +++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 website/src/lib/__snapshots__/tut-runner.test.ts.snap create mode 100644 website/src/lib/tut-runner.test.ts 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..d6710ae --- /dev/null +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -0,0 +1,84 @@ +// 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, or press a in command mode with the 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 text in any pane + · Click Copy Raw + · Click Copy Rewrapped on the boxed paragraph + · Click the cursor icon on the ascii-splash pane + + Some programs trap the mouse — the cursor icon lets you override. + ascii-splash redraws every frame, so it cancels selections: looks cool, undragable. +" +`; + +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/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts new file mode 100644 index 0000000..db12230 --- /dev/null +++ b/website/src/lib/tut-runner.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; +import { SECTIONS } from "./tut-items"; +import { TutRunner } from "./tut-runner"; +import { TutorialState } from "./tutorial-state"; + +const FRAME_RESET = "\x1b[H\x1b[2J"; + +function mountRunner(completedIds: string[] = []) { + const adapter = new FakePtyAdapter(); + const id = "test-pane"; + adapter.spawnPty(id); + + const frames: string[] = []; + 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: () => {}, + }); + 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; + }, + 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(); + }); +}); From b86a442e0099782c50e0e2abc6e5cde63b1bd0b6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 16:13:22 -0700 Subject: [PATCH 08/54] Italic-and-indented tutorial item hints The hint shown under the active item was rendered DIM, which read as secondary noise rather than the actionable instruction it actually is. Switch to italic and indent two columns past the title so it sits visibly inside the item it belongs to. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/__snapshots__/tut-runner.test.ts.snap | 4 ++-- website/src/lib/tut-runner.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index d6710ae..587ee78 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -6,7 +6,7 @@ exports[`TutRunner snapshots > renders Alert and TODO with all items incomplete Esc to go back ● Enable alerts on a pane - Click the bell, or press a in command mode with the pane selected. + Click the bell, or press a in command mode with the 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 @@ -57,7 +57,7 @@ exports[`TutRunner snapshots > renders Keyboard navigation with all items incomp Esc to go back ● Enter command mode - Press LShift then RShift quickly (or LCmd then RCmd on Mac). + 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 diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 635cb6f..7eaaf1b 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -23,6 +23,7 @@ const ESC = "\x1b["; const RESET = `${ESC}0m`; const BOLD = `${ESC}1m`; const DIM = `${ESC}2m`; +const ITALIC = `${ESC}3m`; const fg = (code: number) => `${ESC}${code}m`; const ENTER_ALT = "\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l"; @@ -402,7 +403,7 @@ export class TutRunner implements InteractiveProgram { : item.title; const lines = [` ${mark} ${title}`]; if (isActive && item.hint) { - lines.push(` ${DIM}${item.hint}${RESET}`); + lines.push(` ${ITALIC}${item.hint}${RESET}`); } return lines; } From b5588b17f3b2ecaff4724df38f2e7e0223b0549c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 16:16:49 -0700 Subject: [PATCH 09/54] Highlight every hotkey in the tutorial in cyan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the runner's prompts already wrapped the action key in cyan (`Press s to start another one.` and `Press Esc to go back.`), but every other key in the UI — section headers, the menu nav line, item hints, prose, and item titles — was rendered in the same color as surrounding text. The user has to scan for "what do I press" each time. Add a `highlightKeys()` helper that converts `` `KEY` `` markers into a cyan span using `\x1b[36m` / `\x1b[39m` (default foreground only), so the highlight composes cleanly with surrounding bold / italic / dim attributes. Apply it to every line in `render()`. Mark every hotkey in `tut-items.ts` and the runner's hardcoded strings with backticks, including: - menu nav line: `Esc`/`q`/`Enter`/`↑↓` - section headers: `Esc` - reset prompt: `Esc`, `reset`, `Enter` - per-item titles where they name a key (`Cmd/Ctrl + arrow`, `Enter`) - per-item hints: `LShift`/`RShift`/`LCmd`/`RCmd`, `-`, `|`, `Shift+\`, `m`, `k`, `a`, `s`, `t`, `arrow keys`, `ascii-splash` - section prose: `%` `"` `d` `x` Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/__snapshots__/tut-runner.test.ts.snap | 28 +++++++++---------- website/src/lib/tut-items.ts | 26 ++++++++--------- website/src/lib/tut-runner.ts | 27 ++++++++++++------ 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 587ee78..f86c2e7 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -3,24 +3,24 @@ exports[`TutRunner snapshots > renders Alert and TODO with all items incomplete 1`] = ` " Alert and TODO 0/6 complete - Esc to go back + Esc to go back ● Enable alerts on a pane - Click the bell, or press a in command mode with the pane selected. + Click the bell, or press a in command mode with the 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 + · Press Enter inside the pane to clear the TODO · Manually add a TODO - Press s here to start a fake busy task. + 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 + Esc to go back ● Drag-select text in any pane · Click Copy Raw @@ -35,7 +35,7 @@ exports[`TutRunner snapshots > renders Copy paste with all items incomplete 1`] exports[`TutRunner snapshots > renders Keyboard navigation with all items complete 1`] = ` " Keyboard navigation 7/7 complete - Esc to go back + Esc to go back ✓ Enter command mode ✓ Add a horizontal divider @@ -43,36 +43,36 @@ exports[`TutRunner snapshots > renders Keyboard navigation with all items comple ✓ Add a vertical divider ✓ Minimize a pane ✓ Kill a pane - ✓ Move a pane with Cmd/Ctrl + arrow + ✓ Move a pane with Cmd/Ctrl + arrow - tmux shortcuts also work — % " d x. + tmux shortcuts also work — % " d x. - Section complete. Press Esc to go back. + 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 + Esc to go back ● Enter command mode - Press LShift then RShift quickly (or LCmd then RCmd on Mac). + 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 + · Move a pane with Cmd/Ctrl + arrow - tmux shortcuts also work — % " d x. + 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 + 0/17 complete · Esc/q to exit · Enter to open · ↑↓ to navigate ❯ Keyboard navigation [0/7 complete] Alert and TODO [0/6 complete] diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 3ea405f..7eed451 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -27,40 +27,40 @@ export const SECTIONS: Section[] = [ { id: 'kb-mode', title: 'Enter command mode', - hint: 'Press LShift then RShift quickly (or LCmd then RCmd on Mac).', + 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.', + 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.', + 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.', + 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.', + 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.', + hint: 'Press `k`, then type the random letter to confirm.', }, { id: 'kb-move', - title: 'Move a pane with Cmd/Ctrl + arrow', + title: 'Move a pane with `Cmd/Ctrl + arrow`', hint: 'Swap the selected pane with its neighbor.', }, ], - prose: ['tmux shortcuts also work — % " d x.'], + prose: ['tmux shortcuts also work — `%` `"` `d` `x`.'], }, { id: 'alert', @@ -69,12 +69,12 @@ export const SECTIONS: Section[] = [ { id: 'al-enable', title: 'Enable alerts on a pane', - hint: 'Click the bell, or press a in command mode with the pane selected.', + hint: 'Click the bell, or press `a` in command mode with the 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 the demo pane.', + hint: 'Press `s` here to start a fake busy task on the demo pane.', }, { id: 'al-ring', @@ -87,12 +87,12 @@ export const SECTIONS: Section[] = [ }, { id: 'al-todo-clear', - title: 'Press Enter inside the pane to clear the TODO', + 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.', + hint: 'Press `t` in command mode, or right-click the bell.', }, ], }, @@ -115,7 +115,7 @@ export const SECTIONS: Section[] = [ { id: 'cp-override', title: 'Click the cursor icon on the ascii-splash pane', - hint: 'Type ascii-splash in any pane to launch it first.', + hint: 'Type `ascii-splash` in any pane to launch it first.', }, ], prose: [ diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 7eaaf1b..64b1e73 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -25,6 +25,17 @@ const BOLD = `${ESC}1m`; const DIM = `${ESC}2m`; const ITALIC = `${ESC}3m`; const fg = (code: number) => `${ESC}${code}m`; +const FG_DEFAULT = `${ESC}39m`; + +/** + * Replace `` `KEY` `` markers with a cyan-foreground span. Uses + * `\x1b[36m` / `\x1b[39m` (default foreground) so the highlight composes + * cleanly with surrounding bold / italic / dim attributes — only the + * foreground color is touched, not the rest of the SGR state. + */ +function highlightKeys(line: string): string { + return line.replace(/`([^`]+)`/g, `${fg(36)}$1${FG_DEFAULT}`); +} const ENTER_ALT = "\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l"; const LEAVE_ALT = "\x1b[2J\x1b[H\x1b[?25h\x1b[?1049l"; @@ -271,7 +282,7 @@ export class TutRunner implements InteractiveProgram { : this.renderSection(); let out = `${HOME}${CLEAR}`; for (const line of lines) { - out += `${line}\r\n`; + out += `${highlightKeys(line)}\r\n`; } this.write(out); } @@ -282,7 +293,7 @@ export class TutRunner implements InteractiveProgram { 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}`, + ` ${DIM}${total.done}/${total.total} complete · \`Esc\`/\`q\` to exit · \`Enter\` to open · \`↑↓\` to navigate${RESET}`, ); lines.push(""); SECTIONS.forEach((section, index) => { @@ -314,13 +325,13 @@ export class TutRunner implements InteractiveProgram { const lines: string[] = []; lines.push(""); lines.push(` ${BOLD}Reset progress${RESET}`); - lines.push(` ${DIM}Esc to cancel${RESET}`); + lines.push(` ${DIM}\`Esc\` to cancel${RESET}`); lines.push(""); lines.push( ` This will clear all checkmarks across every section.`, ); lines.push( - ` ${DIM}Type ${fg(36)}reset${RESET}${DIM} and press Enter to confirm.${RESET}`, + ` ${DIM}Type \`reset\` and press \`Enter\` to confirm.${RESET}`, ); lines.push(""); lines.push(` ${fg(36)}>${RESET} ${this.resetBuffer}${fg(33)}_${RESET}`); @@ -341,7 +352,7 @@ export class TutRunner implements InteractiveProgram { 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(` ${DIM}\`Esc\` to go back${RESET}`); lines.push(""); const activeIndex = section.items.findIndex((i) => !this.state.isComplete(i.id)); @@ -362,7 +373,7 @@ export class TutRunner implements InteractiveProgram { if (done === total) { lines.push(""); lines.push( - ` ${fg(32)}Section complete.${RESET} ${DIM}Press ${fg(36)}Esc${RESET}${DIM} to go back.${RESET}`, + ` ${fg(32)}Section complete.${RESET} ${DIM}Press \`Esc\` to go back.${RESET}`, ); } @@ -370,7 +381,7 @@ export class TutRunner implements InteractiveProgram { } private renderBusyDemoLines(): string[] { - const idleHint = ` ${DIM}Press ${fg(36)}s${RESET}${DIM} here to start a fake busy task.${RESET}`; + 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) { @@ -381,7 +392,7 @@ export class TutRunner implements InteractiveProgram { ]; } return [ - ` ${fg(32)}✓${RESET} Fake task finished. ${DIM}Press ${fg(36)}s${RESET}${DIM} to start another one.${RESET}`, + ` ${fg(32)}✓${RESET} Fake task finished. ${DIM}Press \`s\` to start another one.${RESET}`, ]; } From f92ba45796dd665589508a08374e015fe945e0db Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 16:32:29 -0700 Subject: [PATCH 10/54] Tighten alert section hints and word-wrap long ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `al-busy` hint duplicated the runner's own "Press s here to start a fake busy task." footer that always sits at the bottom of the Alert section. Shorten it to "Press s to start a fake busy task in this demo pane." so the active-item hint adds information rather than echoing the footer. The `al-ring` item had no hint, which meant the most surprising bit of behavior in the playground — that the bell suppresses ringing while you're attending the pane — was undocumented. Add a hint that spells out the rule and references `cfg.alert.userAttention` (15s) directly so it stays in sync if the threshold changes. That hint is too long to fit a single line at typical pane widths, so `renderItem` now word-wraps the active hint to the current pty width with consistent 8-column indent and italic on every continuation line. Backtick-marked keys (`s`, `Enter`, etc.) still highlight correctly inside wrapped lines because the visible-length calculation excludes them — they expand into zero-width ANSI when `highlightKeys` runs at frame time. Existing snapshots stay untouched (al-busy/al-ring aren't the active item in any snapshotted view, and the al-enable hint still fits one line at the default 80-col fake pty). Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-items.ts | 8 ++++++- website/src/lib/tut-runner.ts | 41 ++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 7eed451..0516146 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -4,6 +4,10 @@ * localStorage key suffixes. */ +import { cfg } from "mouseterm-lib/cfg"; + +const USER_ATTENTION_SECS = Math.round(cfg.alert.userAttention / 1000); + export type ItemId = string; export interface Item { @@ -74,11 +78,13 @@ export const SECTIONS: Section[] = [ { id: 'al-busy', title: 'Watch the bell tilt while a task runs', - hint: 'Press `s` here to start a fake busy task on the demo pane.', + hint: 'Press `s` to start a fake busy task in this demo 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', diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 64b1e73..89a32ed 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -414,8 +414,47 @@ export class TutRunner implements InteractiveProgram { : item.title; const lines = [` ${mark} ${title}`]; if (isActive && item.hint) { - lines.push(` ${ITALIC}${item.hint}${RESET}`); + const indent = " "; + for (const wrapped of this.wrapHint(item.hint, indent.length)) { + lines.push(`${indent}${ITALIC}${wrapped}${RESET}`); + } + } + return lines; + } + + /** + * Word-wrap a hint to fit the current pty width, leaving room for the + * 8-column indent. Backtick-wrapped key markers get expanded into ANSI + * by `highlightKeys` later — those bytes don't take screen columns, so + * count them as zero-width here. + */ + private wrapHint(hint: 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 = hint.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; } From a265518d353424923336e1b44bb28980d7cef26b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 16:59:04 -0700 Subject: [PATCH 11/54] Rewrite Copy paste section: actionable items + word-wrapped prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old items just named buttons ("Click Copy Raw", "Click Copy Rewrapped"); new ones describe the action — drag-select, then paste with each copy mode — and the hint on each one tells the user what to look for in the pasted result ("notice how it keeps all the line-breaks. Gross!"). The cursor-icon item now explains why ascii-splash still won't paste (animation cancels the selection) and points at lazygit as a real program where the override actually unlocks copy. Section prose is rewritten to one longer paragraph that explains which programs trap the cursor and why. To keep that paragraph readable in narrow panes, generalize the word-wrap helper that was introduced for hints into `wrapText`, and run prose lines through it the same way — consistent 2-column indent on every wrapped line, backtick-marked program names (`ascii-splash`, `lazygit`) still highlight inside the dim prose. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/__snapshots__/tut-runner.test.ts.snap | 18 +++++++++------- website/src/lib/tut-items.ts | 18 +++++++++------- website/src/lib/tut-runner.ts | 21 ++++++++++++------- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index f86c2e7..02c502e 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -22,13 +22,17 @@ exports[`TutRunner snapshots > renders Copy paste with all items incomplete 1`] Copy paste 0/4 complete Esc to go back - ● Drag-select text in any pane - · Click Copy Raw - · Click Copy Rewrapped on the boxed paragraph - · Click the cursor icon on the ascii-splash pane - - Some programs trap the mouse — the cursor icon lets you override. - ascii-splash redraws every frame, so it cancels selections: looks cool, undragable. + ● 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" + · Click the cursor icon on the ascii-splash pane + + 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 which makes the cool patterns in this playground does + trap the cursor — that is how it is able to respond to mouse movement. lazygit + is an excellent and popular program which traps the cursor. " `; diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 0516146..b171120 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -108,25 +108,29 @@ export const SECTIONS: Section[] = [ items: [ { id: 'cp-select', - title: 'Drag-select text in any pane', + title: 'Drag-select some text', + hint: 'The paragraph below is a good example — "Some terminal programs..."', }, { id: 'cp-raw', - title: 'Click Copy 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: 'Click Copy Rewrapped on the boxed paragraph', + 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: 'Click the cursor icon on the ascii-splash pane', - hint: 'Type `ascii-splash` in any pane to launch it first.', + title: 'Click the cursor icon on the `ascii-splash` pane', + hint: + 'This will allow you to drag-select, which would be impossible otherwise. Unfortunately, you still won\'t be able to copy because `ascii-splash` animates, and that animation cancels the copy. But it will work on real programs like `lazygit`!', }, ], prose: [ - 'Some programs trap the mouse — the cursor icon lets you override.', - 'ascii-splash redraws every frame, so it cancels selections: looks cool, undragable.', + '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 which makes the cool patterns in this playground does trap the cursor — that is how it is able to respond to mouse movement. `lazygit` is an excellent and popular program which traps the cursor.', ], }, ]; diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 89a32ed..d38357f 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -362,7 +362,12 @@ export class TutRunner implements InteractiveProgram { if (section.prose && section.prose.length > 0) { lines.push(""); - for (const p of section.prose) lines.push(` ${DIM}${p}${RESET}`); + 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") { @@ -415,7 +420,7 @@ export class TutRunner implements InteractiveProgram { const lines = [` ${mark} ${title}`]; if (isActive && item.hint) { const indent = " "; - for (const wrapped of this.wrapHint(item.hint, indent.length)) { + for (const wrapped of this.wrapText(item.hint, indent.length)) { lines.push(`${indent}${ITALIC}${wrapped}${RESET}`); } } @@ -423,12 +428,12 @@ export class TutRunner implements InteractiveProgram { } /** - * Word-wrap a hint to fit the current pty width, leaving room for the - * 8-column indent. Backtick-wrapped key markers get expanded into ANSI - * by `highlightKeys` later — those bytes don't take screen columns, so - * count them as zero-width here. + * 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 wrapHint(hint: string, indentCols: number): string[] { + 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) => { @@ -436,7 +441,7 @@ export class TutRunner implements InteractiveProgram { for (const ch of s) if (ch !== "`") n++; return n; }; - const words = hint.split(/\s+/).filter(Boolean); + const words = text.split(/\s+/).filter(Boolean); const lines: string[] = []; let line = ""; let lineVis = 0; From 64fa5e9f9afa29eb078a6a9cb54e03aa3ee63816 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 17:09:47 -0700 Subject: [PATCH 12/54] Suppress the default scenario on the tutorial pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake adapter's `setDefaultScenario(SCENARIO_SHELL_PROMPT)` is the fallback for any pane that doesn't have an explicit scenario set. The playground was relying on a comment saying "tut-main has no scenario" to keep the runner pane clean — but with a default in place, spawnPty queued a delayed `user@mouseterm:~$` write that landed underneath the runner's menu and stayed there until the next render cleared it. Compounding the issue: the runner's `start()` writes ENTER_ALT and an initial render *before* the pty is registered, so those bytes get dropped by `sendOutput`'s `terminals.has(id)` guard. By the time the pty exists and the menu is repainted (via the resize subscription), xterm is no longer in alt-screen mode, so the late-arriving prompt goes straight to the visible buffer. Set `tut-main` to an explicit empty scenario so the default doesn't queue anything for that pane. User-spawned panes still get the default shell prompt, and the demo / boxed-paragraph panes still have their own scenarios. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/pages/Playground.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index dadd63f..ee81080 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -46,7 +46,11 @@ function Playground() { adapter.setDefaultScenario(scenarios.SCENARIO_SHELL_PROMPT); adapter.setScenario(PANE_TARGET, scenarios.SCENARIO_SHELL_PROMPT); adapter.setScenario(PANE_BOXED, scenarios.SCENARIO_BOXED_PARAGRAPH); - // tut-main has no scenario — runner takes over the screen entirely. + // 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; From 7f688a8bc0170393c7b2151e9a920f817dc75953 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 17:48:57 -0700 Subject: [PATCH 13/54] Type ItemId as a literal union of stable item ids Item ids are persistence keys, so the source-of-truth list now lives in ITEM_IDS (a `readonly` tuple) and ItemId derives from it. Detector and test call sites become typo-proof; an unrecognized id is a type error rather than silently dead code. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-items.ts | 33 ++++++++++++++++++++++-------- website/src/lib/tut-runner.test.ts | 4 ++-- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index b171120..fbb6366 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -1,14 +1,29 @@ -/** - * Section + item definitions shared by TutRunner (display) and TutDetector - * (event-to-completion mapping). Item ids are stable — they're the - * localStorage key suffixes. - */ - import { cfg } from "mouseterm-lib/cfg"; const USER_ATTENTION_SECS = Math.round(cfg.alert.userAttention / 1000); -export type ItemId = string; +// 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; @@ -23,7 +38,7 @@ export interface Section { prose?: string[]; } -export const SECTIONS: Section[] = [ +export const SECTIONS: readonly Section[] = [ { id: 'keyboard', title: 'Keyboard navigation', @@ -135,7 +150,7 @@ export const SECTIONS: Section[] = [ }, ]; -export const ALL_ITEM_IDS: ItemId[] = SECTIONS.flatMap((s) => s.items.map((i) => i.id)); +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 index db12230..9182491 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; -import { SECTIONS } from "./tut-items"; +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: string[] = []) { +function mountRunner(completedIds: ItemId[] = []) { const adapter = new FakePtyAdapter(); const id = "test-pane"; adapter.spawnPty(id); From 0f9b9c8fa1c7e8cd189bed10046f22883c678d39 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 17:49:19 -0700 Subject: [PATCH 14/54] Remove unused playScenarioNow on FakePtyAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method was added to the adapter but never called — busy-demo animation lives entirely in TutRunner now and pumpActivity covers the real need. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/platform/fake-adapter.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 8f960e5..66f8f41 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -224,21 +224,6 @@ export class FakePtyAdapter implements PlatformAdapter { } } - /** - * Replay a scenario against a live pty. Cancels any in-flight scenario - * for the same id first. Drives the alert-manager via `onData` exactly - * like the spawn-time playback path so bell state transitions fire. - */ - playScenarioNow(id: string, scenario: FakeScenario): void { - if (!this.terminals.has(id)) return; - const existing = this.activeTimers.get(id); - if (existing) { - existing.forEach(clearTimeout); - this.activeTimers.delete(id); - } - this.playScenario(id, scenario); - } - /** * Drive the alert-manager's activity monitor for a fixed duration with * no data output — useful for animating a fake "task running" state on From 80ee553ca769ec681e5d637802d236ade0d22085 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 17:49:54 -0700 Subject: [PATCH 15/54] Persist tutorial progress under one localStorage key Replaces N getItem/setItem/removeItem calls (one per item id) with a single JSON-encoded payload under "mouseterm-tut-v3". One sync read on Playground mount instead of 17. Cache the localStorage handle once and ignore unknown ids on load so a stale or hand-edited payload starts fresh instead of carrying ghost ids. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tutorial-state.ts | 35 ++++++++++++++++++------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/website/src/lib/tutorial-state.ts b/website/src/lib/tutorial-state.ts index e317894..304f9ba 100644 --- a/website/src/lib/tutorial-state.ts +++ b/website/src/lib/tutorial-state.ts @@ -1,17 +1,26 @@ -import { ALL_ITEM_IDS, SECTIONS, type ItemId } from "./tut-items"; +import { ALL_ITEM_IDS, ITEM_IDS, SECTIONS, type ItemId } from "./tut-items"; -const STORAGE_PREFIX = "mouseterm-tut-v2-"; +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() { - if (typeof localStorage === "undefined") return; - for (const id of ALL_ITEM_IDS) { - if (localStorage.getItem(STORAGE_PREFIX + id) === "1") { - this.completed.add(id); + 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. } } @@ -29,21 +38,15 @@ export class TutorialState { markComplete(id: ItemId): boolean { if (this.completed.has(id)) return false; this.completed.add(id); - if (typeof localStorage !== "undefined") { - localStorage.setItem(STORAGE_PREFIX + id, "1"); - } + this.persist(); this.notify(); return true; } reset(): void { if (this.completed.size === 0) return; - if (typeof localStorage !== "undefined") { - for (const id of this.completed) { - localStorage.removeItem(STORAGE_PREFIX + id); - } - } this.completed.clear(); + this.storage?.removeItem(STORAGE_KEY); this.notify(); } @@ -64,4 +67,8 @@ export class TutorialState { private notify(): void { for (const fn of this.listeners) fn(); } + + private persist(): void { + this.storage?.setItem(STORAGE_KEY, JSON.stringify([...this.completed])); + } } From 393fb8c9d7636addfcf55fc539e3912c76824282 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 17:51:53 -0700 Subject: [PATCH 16/54] Take store deps in TutDetector constructor; trim narrating comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TutDetector previously split its dependencies across the constructor (state) and attach() (api + stores). Folding the stores into the constructor lets Playground drop the detectorAttachRef bridge — the attach() call inside handleApiReady now reads stores via `this`. Also trim WHAT-narrating comments (file header, busy-demo countdown description, "Auto-launch the tutorial" / "Stash modules" narration). The kept comments explain non-obvious WHYs — alt-screen seeding, default-scenario suppression, BUSY_DEMO_DURATION_MS rationale. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-detector.ts | 43 +++++++++++++------------------- website/src/lib/tut-runner.ts | 11 -------- website/src/pages/Playground.tsx | 18 ++----------- 3 files changed, 20 insertions(+), 52 deletions(-) diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 5c69c00..7127cf2 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -1,9 +1,3 @@ -/** - * Watches DockviewApi, WallEvents, the alert/activity store, and the - * mouse-selection store, and marks the matching tutorial item complete in - * `TutorialState` whenever the user performs the corresponding action. - */ - import type { TutorialState } from "./tutorial-state"; type DockviewApi = any; @@ -24,31 +18,31 @@ interface MouseSelectionModule { export class TutDetector { private state: TutorialState; + private activityStore: ActivityStoreModule; + private mouseStore: MouseSelectionModule; private currentMode: WallMode = "command"; private commandModePanels = new Set(); private prevActivity = new Map(); private prevMouse = new Map(); private disposables: (() => void)[] = []; - private attached = false; - - constructor(state: TutorialState) { - this.state = state; - } - attach( - api: DockviewApi, + constructor( + state: TutorialState, activityStore: ActivityStoreModule, mouseStore: MouseSelectionModule, - ): void { - if (this.attached) return; - this.attached = true; + ) { + this.state = state; + this.activityStore = activityStore; + this.mouseStore = mouseStore; + } + attach(api: DockviewApi): void { // Seed previous-state maps so the very first listener fire isn't // mis-read as a transition from "nothing". - for (const [id, s] of activityStore.getActivitySnapshot()) { + for (const [id, s] of this.activityStore.getActivitySnapshot()) { this.prevActivity.set(id, { ...s }); } - for (const [id, s] of mouseStore.getMouseSelectionSnapshot()) { + for (const [id, s] of this.mouseStore.getMouseSelectionSnapshot()) { this.prevMouse.set(id, { ...s }); } @@ -63,10 +57,10 @@ export class TutDetector { this.disposables.push(() => activeUnsub.dispose()); this.disposables.push( - activityStore.subscribeToActivity(() => this.processActivity(activityStore)), + this.activityStore.subscribeToActivity(() => this.processActivity()), ); this.disposables.push( - mouseStore.subscribeToMouseSelection(() => this.processMouse(mouseStore)), + this.mouseStore.subscribeToMouseSelection(() => this.processMouse()), ); } @@ -102,8 +96,8 @@ export class TutDetector { } } - private processActivity(store: ActivityStoreModule): void { - const snapshot = store.getActivitySnapshot(); + private processActivity(): void { + const snapshot = this.activityStore.getActivitySnapshot(); for (const [id, current] of snapshot) { const prev = this.prevActivity.get(id); @@ -136,8 +130,8 @@ export class TutDetector { } } - private processMouse(store: MouseSelectionModule): void { - const snapshot = store.getMouseSelectionSnapshot(); + private processMouse(): void { + const snapshot = this.mouseStore.getMouseSelectionSnapshot(); for (const [id, current] of snapshot) { const prev = this.prevMouse.get(id); @@ -162,6 +156,5 @@ export class TutDetector { dispose(): void { for (const fn of this.disposables) fn(); this.disposables = []; - this.attached = false; } } diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index d38357f..435ce80 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -1,10 +1,3 @@ -/** - * Browser TUI for the playground tutorial. Follows the same pattern as - * `ascii-splash-runner.ts`: alt-screen on, render an ANSI-driven view from - * `TutorialState`, accept input via `FakePtyAdapter.writePty`, restore on - * exit. No `terminal-kit` package dependency. - */ - import { cfg } from "mouseterm-lib/cfg"; import type { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter"; import type { InteractiveProgram } from "./tutorial-shell"; @@ -260,10 +253,6 @@ export class TutRunner implements InteractiveProgram { } private startBusyDemo(): void { - // Countdown phase (0–3s) is the only animated piece in the runner. - // It writes a fresh frame to xterm every SPINNER_INTERVAL_MS, then - // settles into a static "Fake task finished" line that stays put - // until the user presses s again. this.busyDemoStart = Date.now(); this.onTriggerBusyDemo?.(); this.startSpinnerTicks(); diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index ee81080..baed148 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -25,7 +25,6 @@ function Playground() { const detectorRef = useRef(null); const stateRef = useRef(null); const dockviewDisposablesRef = useRef([]); - const detectorAttachRef = useRef<((api: any) => void) | null>(null); useEffect(() => { let cancelled = false; @@ -54,7 +53,7 @@ function Playground() { const tutorialState = new TutorialState(); stateRef.current = tutorialState; - const detector = new TutDetector(tutorialState); + const detector = new TutDetector(tutorialState, registry, mouseSelection); detectorRef.current = detector; const shellRegistry = new PlaygroundShellRegistry( @@ -67,12 +66,6 @@ function Playground() { state: tutorialState, onExit, onTriggerBusyDemo: () => { - // Run for slightly longer than the user-attention idle - // window so silence begins after attention has expired. - // Otherwise the activity-monitor's "user is looking at - // this pane" check would suppress the ring instead of - // letting it fire. No text output — the visual feedback - // is the countdown rendered inside the tutorial runner. adapter.pumpActivity(PANE_TARGET, BUSY_DEMO_DURATION_MS, 800); }, }); @@ -94,15 +87,8 @@ function Playground() { shellRegistry.ensureShell(PANE_TARGET); shellRegistry.ensureShell(PANE_BOXED); - // Auto-launch the tutorial in the main pane. mainShell.runCommand("tut"); - // Stash modules for handleApiReady to attach the detector once - // dockview is alive. - detectorAttachRef.current = (api) => { - detector.attach(api, registry, mouseSelection); - }; - setWallModule({ Wall: wall.Wall }); } loadWall(); @@ -148,7 +134,7 @@ function Playground() { const mainPanel = api.getPanel(PANE_MAIN); if (mainPanel) mainPanel.api.setActive(); - detectorAttachRef.current?.(api); + detectorRef.current?.attach(api); }, []); const handleWallEvent = useCallback((event: WallEvent) => { From 55a5594d22e07d5cdc58fe8a4e478c261a865f33 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 17:53:43 -0700 Subject: [PATCH 17/54] Centralize ANSI escape constants in lib/src/lib/ansi.ts Four files independently declared ESC/RESET/BOLD/fg/PROMPT/etc with identical byte sequences: - lib/src/lib/platform/fake-scenarios.ts - website/src/lib/tut-runner.ts - website/src/lib/tutorial-shell.ts - website/src/lib/ascii-splash-runner.ts (alt-screen toggles only) Move them into a single shared module and import from there. Keeps PROMPT visually consistent between the playground shell and canned scenarios (otherwise drift between the two copies would silently diverge the rendering). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/ansi.ts | 26 +++++++++++++++++ lib/src/lib/platform/fake-scenarios.ts | 9 +----- website/src/lib/ascii-splash-runner.ts | 3 +- website/src/lib/tut-runner.ts | 40 ++++++++++++-------------- website/src/lib/tutorial-shell.ts | 8 +----- 5 files changed, 47 insertions(+), 39 deletions(-) create mode 100644 lib/src/lib/ansi.ts 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-scenarios.ts b/lib/src/lib/platform/fake-scenarios.ts index f625ff6..6a68f3c 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,14 +60,6 @@ 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. */ 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/tut-runner.ts b/website/src/lib/tut-runner.ts index 435ce80..67d6164 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -1,3 +1,15 @@ +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"; @@ -12,29 +24,13 @@ import type { TutorialState } from "./tutorial-state"; */ export const BUSY_DEMO_DURATION_MS = cfg.alert.userAttention + 1; -const ESC = "\x1b["; -const RESET = `${ESC}0m`; -const BOLD = `${ESC}1m`; -const DIM = `${ESC}2m`; -const ITALIC = `${ESC}3m`; -const fg = (code: number) => `${ESC}${code}m`; -const FG_DEFAULT = `${ESC}39m`; - -/** - * Replace `` `KEY` `` markers with a cyan-foreground span. Uses - * `\x1b[36m` / `\x1b[39m` (default foreground) so the highlight composes - * cleanly with surrounding bold / italic / dim attributes — only the - * foreground color is touched, not the rest of the SGR state. - */ +// 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 ENTER_ALT = "\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l"; -const LEAVE_ALT = "\x1b[2J\x1b[H\x1b[?25h\x1b[?1049l"; -const HOME = "\x1b[H"; -const CLEAR = "\x1b[2J"; - const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const SPINNER_INTERVAL_MS = 100; @@ -88,7 +84,7 @@ export class TutRunner implements InteractiveProgram { } start(): void { - this.write(ENTER_ALT); + 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(); @@ -269,7 +265,7 @@ export class TutRunner implements InteractiveProgram { : this.screen === "reset" ? this.renderReset() : this.renderSection(); - let out = `${HOME}${CLEAR}`; + let out = `${CURSOR_HOME}${CLEAR_SCREEN}`; for (const line of lines) { out += `${highlightKeys(line)}\r\n`; } @@ -463,7 +459,7 @@ export class TutRunner implements InteractiveProgram { this.stateUnsub = null; this.resizeUnsub?.(); this.resizeUnsub = null; - this.write(LEAVE_ALT); + this.write(LEAVE_ALT_SCREEN); if (notifyExit) this.onExit(); } diff --git a/website/src/lib/tutorial-shell.ts b/website/src/lib/tutorial-shell.ts index 3a05513..5e067d0 100644 --- a/website/src/lib/tutorial-shell.ts +++ b/website/src/lib/tutorial-shell.ts @@ -1,10 +1,4 @@ -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}$ `; -const CLEAR_LINE = `${ESC}2K`; +import { CLEAR_LINE, PROMPT, RESET, fg } from 'mouseterm-lib/lib/ansi'; export type SendOutput = (data: string) => void; From f74d9ccbbb67d9b4a01ee101e409c28bbf2d1032 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:00:34 -0700 Subject: [PATCH 18/54] Fix playground tutorial autostart race --- lib/src/lib/platform/fake-adapter.ts | 4 +++ website/src/pages/Playground.tsx | 39 +++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 66f8f41..be6d09d 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -162,6 +162,10 @@ export class FakePtyAdapter implements PlatformAdapter { return this.terminalSizes.get(id) ?? DEFAULT_PTY_SIZE; } + hasPty(id: string): boolean { + return this.terminals.has(id); + } + async readClipboardFilePaths(): Promise { return null; } async readClipboardImageAsFilePath(): Promise { return null; } diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index baed148..b67a921 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -25,6 +25,35 @@ function Playground() { const detectorRef = useRef(null); const stateRef = useRef(null); const dockviewDisposablesRef = useRef([]); + const tutorialAutoStartedRef = useRef(false); + const tutorialAutoStartRafRef = useRef(null); + + const scheduleTutorialAutoStart = useCallback(() => { + if (tutorialAutoStartedRef.current || tutorialAutoStartRafRef.current !== null) return; + + let attempts = 0; + const tick = () => { + tutorialAutoStartRafRef.current = null; + if (tutorialAutoStartedRef.current) return; + + const adapter = adapterRef.current; + const shellRegistry = shellRegistryRef.current; + if (!adapter || !shellRegistry) return; + + if (adapter.hasPty(PANE_MAIN)) { + tutorialAutoStartedRef.current = true; + shellRegistry.ensureShell(PANE_MAIN).runCommand("tut"); + return; + } + + attempts += 1; + if (attempts < 60) { + tutorialAutoStartRafRef.current = requestAnimationFrame(tick); + } + }; + + tutorialAutoStartRafRef.current = requestAnimationFrame(tick); + }, []); useEffect(() => { let cancelled = false; @@ -83,12 +112,10 @@ function Playground() { ); shellRegistryRef.current = shellRegistry; - const mainShell = shellRegistry.ensureShell(PANE_MAIN); + shellRegistry.ensureShell(PANE_MAIN); shellRegistry.ensureShell(PANE_TARGET); shellRegistry.ensureShell(PANE_BOXED); - mainShell.runCommand("tut"); - setWallModule({ Wall: wall.Wall }); } loadWall(); @@ -104,6 +131,11 @@ function Playground() { shellRegistryRef.current?.disposeAll(); shellRegistryRef.current = null; stateRef.current = null; + tutorialAutoStartedRef.current = false; + if (tutorialAutoStartRafRef.current !== null) { + cancelAnimationFrame(tutorialAutoStartRafRef.current); + tutorialAutoStartRafRef.current = null; + } }; }, []); @@ -135,6 +167,7 @@ function Playground() { if (mainPanel) mainPanel.api.setActive(); detectorRef.current?.attach(api); + scheduleTutorialAutoStart(); }, []); const handleWallEvent = useCallback((event: WallEvent) => { From fceaa70c6bf64fab72f6af8178a7b2b66a53229f Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:01:21 -0700 Subject: [PATCH 19/54] Support Ctrl Arrow pane swaps --- docs/specs/layout.md | 6 +++--- lib/src/components/wall/keyboard/handle-pane-navigation.ts | 2 +- lib/src/components/wall/keyboard/handle-pane-shortcuts.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index d81abd4..1856719 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 @@ -162,7 +162,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 +210,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/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 3c329af..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; From e1f05c97fcd36f821a5107f1e098222273e20f6d Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:06:39 -0700 Subject: [PATCH 20/54] Reconcile tutorial spec with implementation The storage scheme in the spec described a per-item v2 key prefix and a nonexistent FakePtyAdapter.playScenarioNow method, neither of which match the code: the runner persists a single JSON-array payload under `mouseterm-tut-v3`, and the only fake-adapter helpers it uses are sendOutput / pumpActivity / setInputHandler. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- docs/specs/tutorial.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 271e657..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 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, `mouseterm-tut-v2-` localStorage scheme, theme picker, and FakePtyAdapter extensions (`playScenarioNow`, `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/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/tutorial.md b/docs/specs/tutorial.md index 7ede5a5..c5dd25c 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -8,7 +8,7 @@ Three browser-side pieces in `website/src/lib/`, mirroring the pattern in `websi - **`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 per-item to `localStorage` under the `mouseterm-tut-v2-` prefix. +- **`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. ## Layout @@ -37,7 +37,7 @@ Esc / `q` / Ctrl+C pops back one screen (section → menu → exit). Exiting the | ID | Title | Detection | |---|---|---| -| `kb-mode` | Enter command mode (LShift→RShift / LMeta→RMeta) | `WallEvent.modeChange` to `'command'` | +| `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' }` | @@ -87,16 +87,16 @@ The Copy Rewrapped step uses `SCENARIO_BOXED_PARAGRAPH` (in `lib/src/lib/platfor ## Lib changes added for this tutorial - **`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.playScenarioNow(id, scenario)`** — public method that replays a `FakeScenario` on a live pty; cancels any in-flight scenario for the same id first. Drives `alertManager.onData()` exactly like the spawn-time playback so bell state transitions fire. - **`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`. `SCENARIO_TUTORIAL_MOTD` was removed — the runner now owns the main pane's screen. ## Storage -- Per-item completion: `localStorage["mouseterm-tut-v2-"] = "1"`. Wiped on `TutorialState.reset()`. -- Legacy keys `mouseterm-tutorial-step-N` from the previous design are not read; new playground sessions get a fresh start. +- 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 From 3e8c1c7fd19fbbfd2d5fe3d89b1be8e80b461886 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:07:14 -0700 Subject: [PATCH 21/54] Skip transition detection on first observation per id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TutDetector seeds prev-state maps in attach(), but a pane added after attach() has no prev entry on its first store-fire. Treating undefined as a transition from "default" credited items the user didn't do — most notably al-todo-manual if a restored pane appeared with todo=true. Now the first observation of any id is recorded silently, and transition detection only runs once we have two snapshots to compare. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-detector.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 7127cf2..4d6ef09 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -100,10 +100,17 @@ export class TutDetector { 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; + } - const wasEnabled = prev && prev.status !== "ALERT_DISABLED"; - const nowEnabled = current.status !== "ALERT_DISABLED"; - if (!wasEnabled && nowEnabled) { + if (prev.status === "ALERT_DISABLED" && current.status !== "ALERT_DISABLED") { this.state.markComplete("al-enable"); } @@ -114,15 +121,14 @@ export class TutDetector { this.state.markComplete("al-ring"); } - const prevTodo = prev?.todo ?? false; - if (!prevTodo && current.todo) { - if (prev?.status === "ALERT_RINGING") { + if (!prev.todo && current.todo) { + if (prev.status === "ALERT_RINGING") { this.state.markComplete("al-todo-auto"); } else { this.state.markComplete("al-todo-manual"); } } - if (prevTodo && !current.todo) { + if (prev.todo && !current.todo) { this.state.markComplete("al-todo-clear"); } @@ -134,18 +140,21 @@ export class TutDetector { const snapshot = this.mouseStore.getMouseSelectionSnapshot(); for (const [id, current] of snapshot) { const prev = this.prevMouse.get(id); + if (!prev) { + this.prevMouse.set(id, { ...current }); + continue; + } - if (current.copyFlash && current.copyFlash !== prev?.copyFlash) { + 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) { + if (!prev.selection && current.selection) { this.state.markComplete("cp-select"); } - const prevOverride = prev?.override ?? "off"; - if (prevOverride === "off" && current.override !== "off") { + if (prev.override === "off" && current.override !== "off") { this.state.markComplete("cp-override"); } From 5e53597a62aa1cc238f853be36595fc4e4108a10 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:07:52 -0700 Subject: [PATCH 22/54] Ignore busy-demo trigger while one is already running Each `s` press in the Alert section calls pumpActivity, which starts a fresh setInterval. Holding `s` or pressing it while the demo was still counting down stacked intervals on top of each other; they all fired alertManager.onData(id) until each one's own duration timeout expired. Gate the trigger on a simple `Date.now() - busyDemoStart < duration` check so a new run is only allowed after the prior one has finished. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-runner.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 67d6164..8a531b9 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -163,7 +163,10 @@ export class TutRunner implements InteractiveProgram { this.sectionId === "alert" && (ch === "s" || ch === "S") ) { - this.startBusyDemo(); + // 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; } @@ -248,6 +251,11 @@ export class TutRunner implements InteractiveProgram { 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?.(); From bbfc39dd715ffe94d42d95169773ec0bc73e4106 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:08:16 -0700 Subject: [PATCH 23/54] Stop the spinner timer when leaving the Alert section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startBusyDemo's setInterval kept ticking until BUSY_DEMO_DURATION_MS elapsed even after the user pressed Esc back to the menu, redrawing unrelated screens at 10 fps for a few seconds. Stop the timer in handleEscape's section→menu branch — the spinner only has anything to draw inside the Alert section. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-runner.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 8a531b9..3eee418 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -231,6 +231,11 @@ export class TutRunner implements InteractiveProgram { 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(); From 6affcaaa5d04868dac2df0237416b939c51011c6 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:15:05 -0700 Subject: [PATCH 24/54] Fix tutorial first selection detection --- website/src/lib/tut-detector.test.ts | 80 ++++++++++++++++++++++++++++ website/src/lib/tut-detector.ts | 7 +-- 2 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 website/src/lib/tut-detector.test.ts diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts new file mode 100644 index 0000000..d6f5341 --- /dev/null +++ b/website/src/lib/tut-detector.test.ts @@ -0,0 +1,80 @@ +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 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; + }; + }, + }, + ); + + 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 }), + }; +} + +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); + }); +}); diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 4d6ef09..d384c92 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -1,4 +1,5 @@ import type { TutorialState } from "./tutorial-state"; +import { DEFAULT_MOUSE_SELECTION_STATE } from "mouseterm-lib/lib/mouse-selection"; type DockviewApi = any; type WallEvent = import("mouseterm-lib/components/Wall").WallEvent; @@ -139,11 +140,7 @@ export class TutDetector { private processMouse(): void { const snapshot = this.mouseStore.getMouseSelectionSnapshot(); for (const [id, current] of snapshot) { - const prev = this.prevMouse.get(id); - if (!prev) { - this.prevMouse.set(id, { ...current }); - continue; - } + 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"); From 59df042c26ec6faecab8ee98abaf05e238d867d5 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:15:41 -0700 Subject: [PATCH 25/54] Fix tutorial arrow navigation detection --- website/src/lib/tut-detector.test.ts | 11 +++++++++++ website/src/lib/tut-detector.ts | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index d6f5341..d94425a 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -77,4 +77,15 @@ describe("TutDetector", () => { 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); + }); }); diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index d384c92..c50cf7f 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -21,7 +21,9 @@ export class TutDetector { private state: TutorialState; private activityStore: ActivityStoreModule; private mouseStore: MouseSelectionModule; + private api: DockviewApi | null = null; private currentMode: WallMode = "command"; + private currentPaneId: string | null = null; private commandModePanels = new Set(); private prevActivity = new Map(); private prevMouse = new Map(); @@ -38,6 +40,7 @@ export class TutDetector { } attach(api: DockviewApi): void { + 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()) { @@ -74,6 +77,8 @@ export class TutDetector { if (event.mode === "command" && this.currentMode === "passthrough") { this.state.markComplete("kb-mode"); this.commandModePanels.clear(); + const activePaneId = this.currentPaneId ?? this.api?.activePanel?.id; + if (activePaneId) this.commandModePanels.add(activePaneId); } this.currentMode = event.mode; break; @@ -94,6 +99,11 @@ export class TutDetector { case "move": this.state.markComplete("kb-move"); break; + case "selectionChange": + if (event.kind === "pane") { + this.currentPaneId = event.id; + } + break; } } From f98f54a4cc1a3d6138153f45eb15065fa5a2f728 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:17:01 -0700 Subject: [PATCH 26/54] Avoid eager mouse selection import --- website/src/lib/tut-detector.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index c50cf7f..4e7408c 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -1,5 +1,4 @@ import type { TutorialState } from "./tutorial-state"; -import { DEFAULT_MOUSE_SELECTION_STATE } from "mouseterm-lib/lib/mouse-selection"; type DockviewApi = any; type WallEvent = import("mouseterm-lib/components/Wall").WallEvent; @@ -17,6 +16,15 @@ interface MouseSelectionModule { getMouseSelectionSnapshot: () => Map; } +const DEFAULT_MOUSE_STATE: MouseSelectionState = { + mouseReporting: "none", + bracketedPaste: false, + override: "off", + selection: null, + hintToken: null, + copyFlash: null, +}; + export class TutDetector { private state: TutorialState; private activityStore: ActivityStoreModule; @@ -150,7 +158,7 @@ export class TutDetector { private processMouse(): void { const snapshot = this.mouseStore.getMouseSelectionSnapshot(); for (const [id, current] of snapshot) { - const prev = this.prevMouse.get(id) ?? DEFAULT_MOUSE_SELECTION_STATE; + const prev = this.prevMouse.get(id) ?? DEFAULT_MOUSE_STATE; if (current.copyFlash && current.copyFlash !== prev.copyFlash) { if (current.copyFlash === "raw") this.state.markComplete("cp-raw"); From f6657fa108ea7624b7d03bfa6264c0df8bb9f733 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:22:42 -0700 Subject: [PATCH 27/54] Gate al-busy and al-ring on a status transition Without the prev-status check, a pane already in BUSY or ALERT_RINGING at the moment its first activity event fires (restored state, or a pane spawned after attach() that arrives mid-task) would credit the user for work they did not do this session. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-detector.test.ts | 29 ++++++++++++++++++++++++++++ website/src/lib/tut-detector.ts | 12 ++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index d94425a..8ab1f17 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -88,4 +88,33 @@ describe("TutDetector", () => { 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); + }); }); diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 4e7408c..568ea25 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -133,10 +133,18 @@ export class TutDetector { this.state.markComplete("al-enable"); } - if (current.status === "BUSY" || current.status === "MIGHT_BE_BUSY") { + // 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 (current.status === "ALERT_RINGING") { + if (prev.status !== "ALERT_RINGING" && current.status === "ALERT_RINGING") { this.state.markComplete("al-ring"); } From e2fe83b6f7d65b9d0ea8c9e57b78c0dd59572094 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:28:46 -0700 Subject: [PATCH 28/54] Fix tutorial alert enable detection --- lib/src/lib/terminal-lifecycle.ts | 1 + website/src/lib/tut-detector.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+) 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/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index 8ab1f17..ab17def 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -117,4 +117,17 @@ describe("TutDetector", () => { ])); 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); + }); }); From a96b9e18509cac8b078b315c8822ec57660e482f Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:29:40 -0700 Subject: [PATCH 29/54] Clarify ascii splash override step --- docs/specs/tutorial.md | 4 ++-- website/src/lib/__snapshots__/tut-runner.test.ts.snap | 8 ++++---- website/src/lib/tut-items.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index c5dd25c..9307f98 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -76,11 +76,11 @@ The detector subscribes to `subscribeToMouseSelection()` and tracks per-id trans | `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` | Click the cursor icon on the ascii-splash pane | `override` transitions `'off' → 'temporary' \| 'permanent'` | +| `cp-override` | Run `ascii-splash`, then click its cursor icon | `override` transitions `'off' → 'temporary' \| 'permanent'` | Prose: - "Some programs trap the mouse — the cursor icon lets you override." -- "ascii-splash redraws every frame, so it cancels selections: looks cool, undragable." +- "`ascii-splash` redraws every frame, so it cancels selections: looks cool, undragable." 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. diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 02c502e..9b952df 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -26,13 +26,13 @@ exports[`TutRunner snapshots > renders Copy paste with all items incomplete 1`] 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" - · Click the cursor icon on the ascii-splash pane + · 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 which makes the cool patterns in this playground does - trap the cursor — that is how it is able to respond to mouse movement. lazygit - is an excellent and popular program which traps the cursor. + 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. " `; diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index fbb6366..bbb7a1d 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -139,13 +139,13 @@ export const SECTIONS: readonly Section[] = [ }, { id: 'cp-override', - title: 'Click the cursor icon on the `ascii-splash` pane', + title: 'Run `ascii-splash`, then click its cursor icon', hint: - 'This will allow you to drag-select, which would be impossible otherwise. Unfortunately, you still won\'t be able to copy because `ascii-splash` animates, and that animation cancels the copy. But it will work on real programs like `lazygit`!', + '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 which makes the cool patterns in this playground does trap the cursor — that is how it is able to respond to mouse movement. `lazygit` is an excellent and popular program which traps the cursor.', + '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.', ], }, ]; From b25bbc7d6677b927a08de88502c9e23323e6e444 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:30:47 -0700 Subject: [PATCH 30/54] Avoid duplicate playground prompts --- website/src/lib/playground-shells.test.ts | 18 ++++++++++++++++++ website/src/lib/playground-shells.ts | 9 ++++++++- website/src/lib/tutorial-shell.ts | 7 ++++++- website/src/pages/Playground.tsx | 1 + 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/website/src/lib/playground-shells.test.ts b/website/src/lib/playground-shells.test.ts index 8ef3cfb..22c32d1 100644 --- a/website/src/lib/playground-shells.test.ts +++ b/website/src/lib/playground-shells.test.ts @@ -32,6 +32,24 @@ describe("PlaygroundShellRegistry", () => { expect(output.two.join("")).toContain("$ "); }); + it("does not print a duplicate prompt when the scenario already provided one", () => { + const adapter = new FakePtyAdapter(); + const output: string[] = []; + adapter.onPtyData((detail) => output.push(detail.data)); + adapter.spawnPty("one"); + + const registry = new PlaygroundShellRegistry( + adapter, + () => createProgram(), + () => true, + ); + registry.ensureShell("one"); + + adapter.writePty("one", "x"); + + expect(output.join("")).toBe("x"); + }); + it("starts interactive programs against the active terminal id", () => { const adapter = new FakePtyAdapter(); const program = createProgram(); diff --git a/website/src/lib/playground-shells.ts b/website/src/lib/playground-shells.ts index e593c9d..533092f 100644 --- a/website/src/lib/playground-shells.ts +++ b/website/src/lib/playground-shells.ts @@ -11,14 +11,20 @@ export type StartPlaygroundProgram = ( export class PlaygroundShellRegistry { private adapter: FakePtyAdapter; private startProgram: StartPlaygroundProgram; + private promptInitiallyShown: (id: string) => boolean; private shells = new Map(); private handlePtyExit = (detail: { id: string }) => { this.disposeShell(detail.id); }; - constructor(adapter: FakePtyAdapter, startProgram: StartPlaygroundProgram) { + constructor( + adapter: FakePtyAdapter, + startProgram: StartPlaygroundProgram, + promptInitiallyShown: (id: string) => boolean = () => false, + ) { this.adapter = adapter; this.startProgram = startProgram; + this.promptInitiallyShown = promptInitiallyShown; this.adapter.onPtyExit(this.handlePtyExit); } @@ -29,6 +35,7 @@ export class PlaygroundShellRegistry { const shell = new TutorialShell( (data) => this.adapter.sendOutput(id, data), (name, args, onExit) => this.startProgram(id, name, args, onExit), + { promptShown: this.promptInitiallyShown(id) }, ); this.shells.set(id, shell); this.adapter.setInputHandler(id, (data) => shell.handleInput(data)); diff --git a/website/src/lib/tutorial-shell.ts b/website/src/lib/tutorial-shell.ts index 5e067d0..75641c3 100644 --- a/website/src/lib/tutorial-shell.ts +++ b/website/src/lib/tutorial-shell.ts @@ -34,9 +34,14 @@ export class TutorialShell { private activeProgram: InteractiveProgram | null = null; private promptShown = false; - constructor(sendOutput: SendOutput, startProgram: StartProgram) { + constructor( + sendOutput: SendOutput, + startProgram: StartProgram, + options: { promptShown?: boolean } = {}, + ) { this.sendOutput = sendOutput; this.startProgram = startProgram; + this.promptShown = options.promptShown ?? false; } dispose(): void { diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index b67a921..bad2879 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -109,6 +109,7 @@ function Playground() { } return null; }, + (terminalId) => terminalId !== PANE_MAIN && terminalId !== PANE_BOXED, ); shellRegistryRef.current = shellRegistry; From 696ad54160565072ab127b6a22064c686c05b3cd Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:36:21 -0700 Subject: [PATCH 31/54] Widen the fake-busy guard margin from 1ms to 250ms The +1ms offset was a clever-but-fragile timing: it relied on the busy demo finishing exactly one millisecond after user-attention expired so the bell could escape the "user is looking at this pane" suppression. Any scheduler jitter could flip the order. 250ms is still well below the spinner's display resolution (Math.ceil to seconds is unchanged) but gives a comfortable margin against clock skew. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-runner.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 3eee418..5bfbc1b 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -17,12 +17,14 @@ import { SECTIONS, type Item } from "./tut-items"; import type { TutorialState } from "./tutorial-state"; /** - * The fake busy task runs for one tick longer than 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 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 + 1; +export const BUSY_DEMO_DURATION_MS = cfg.alert.userAttention + 250; // Replace `` `KEY` `` markers with a cyan span. Uses default-foreground // (39m) to close the span so the highlight composes cleanly with From 357b0174586602b5b096e7d0f01f60eabba62a1a Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:37:29 -0700 Subject: [PATCH 32/54] Reuse the shared DEFAULT_MOUSE_SELECTION_STATE constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector was copy-pasting the literal default — if mouse-selection adds a field, the runtime fallback drifts from the type definition. The test file already imports the upstream constant; the runtime now does too. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-detector.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 568ea25..f67c129 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -1,3 +1,4 @@ +import { DEFAULT_MOUSE_SELECTION_STATE } from "mouseterm-lib/lib/mouse-selection"; import type { TutorialState } from "./tutorial-state"; type DockviewApi = any; @@ -16,15 +17,6 @@ interface MouseSelectionModule { getMouseSelectionSnapshot: () => Map; } -const DEFAULT_MOUSE_STATE: MouseSelectionState = { - mouseReporting: "none", - bracketedPaste: false, - override: "off", - selection: null, - hintToken: null, - copyFlash: null, -}; - export class TutDetector { private state: TutorialState; private activityStore: ActivityStoreModule; @@ -166,7 +158,7 @@ export class TutDetector { private processMouse(): void { const snapshot = this.mouseStore.getMouseSelectionSnapshot(); for (const [id, current] of snapshot) { - const prev = this.prevMouse.get(id) ?? DEFAULT_MOUSE_STATE; + 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"); From 900dcc25f183b16962a674d02caad87b20aa9c70 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:37:50 -0700 Subject: [PATCH 33/54] Drop detector prev-state entries when their pane disappears processActivity / processMouse only wrote into the prev-state maps; nothing pruned ids that had left the snapshot, so killed panes leaked entries forever. Sweep the maps after each pass so they track the current snapshot exactly. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-detector.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index f67c129..835b8f0 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -153,6 +153,9 @@ export class TutDetector { this.prevActivity.set(id, { ...current }); } + for (const id of this.prevActivity.keys()) { + if (!snapshot.has(id)) this.prevActivity.delete(id); + } } private processMouse(): void { @@ -175,6 +178,9 @@ export class TutDetector { this.prevMouse.set(id, { ...current }); } + for (const id of this.prevMouse.keys()) { + if (!snapshot.has(id)) this.prevMouse.delete(id); + } } dispose(): void { From 53571c63df8640466002e7b2d7e93993a7861d93 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:38:03 -0700 Subject: [PATCH 34/54] Warn when tutorial auto-start exhausts its retry budget Previously, if PANE_MAIN's pty never appeared within ~60 frames the RAF loop just stopped silently and the user got an empty pane with no prompt and no tut. Log a warning so the failure mode is visible in devtools. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/pages/Playground.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index bad2879..ea4ef96 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -49,6 +49,10 @@ function Playground() { attempts += 1; if (attempts < 60) { tutorialAutoStartRafRef.current = requestAnimationFrame(tick); + } else { + console.warn( + `[Playground] gave up waiting for ${PANE_MAIN} pty after ${attempts} frames; tutorial did not auto-start`, + ); } }; From decba32ef316932d7f54011b58d0247f8e9605a9 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:40:54 -0700 Subject: [PATCH 35/54] Tag scenarios with endsWithPrompt instead of hardcoding pane ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry used to take a (terminalId) => boolean predicate so Playground could enumerate which panes ended at a prompt — brittle because the answer is a property of the scenario, not the id. Move that knowledge onto FakeScenario as an opt-in flag, expose adapter.scenarioEndsWithPrompt(id), and let the registry consult it directly. Drops the third constructor argument and the predicate at the Playground call site. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/platform/fake-adapter.ts | 11 +++++++ lib/src/lib/platform/fake-scenarios.ts | 4 +++ website/src/lib/playground-shells.test.ts | 38 ++++++++++++++--------- website/src/lib/playground-shells.ts | 10 ++---- website/src/pages/Playground.tsx | 1 - 5 files changed, 40 insertions(+), 24 deletions(-) diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index be6d09d..ee9dbbb 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 { @@ -166,6 +170,13 @@ export class FakePtyAdapter implements PlatformAdapter { 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 { + const scenario = this.scenarioMap.get(id) ?? this.defaultScenario; + return scenario?.endsWithPrompt === true; + } + async readClipboardFilePaths(): Promise { return null; } async readClipboardImageAsFilePath(): Promise { return null; } diff --git a/lib/src/lib/platform/fake-scenarios.ts b/lib/src/lib/platform/fake-scenarios.ts index 6a68f3c..a7e0835 100644 --- a/lib/src/lib/platform/fake-scenarios.ts +++ b/lib/src/lib/platform/fake-scenarios.ts @@ -66,6 +66,7 @@ export function flattenScenario(scenario: FakeScenario): FakeScenario { export const SCENARIO_SHELL_PROMPT: FakeScenario = { name: 'shell-prompt', chunks: [instant(PROMPT, 500)], + endsWithPrompt: true, }; /** Types `ls -la` then shows colorized directory listing. */ @@ -92,6 +93,7 @@ export const SCENARIO_LS_OUTPUT: FakeScenario = { ), instant(PROMPT, 200), ], + endsWithPrompt: true, }; /** Demonstrates all 16 ANSI colors with labels. */ @@ -118,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. */ @@ -133,6 +136,7 @@ export const SCENARIO_LONG_RUNNING: FakeScenario = { instant(`\r\nadded 847 packages in 5.2s\r\n\r\n`, 100), instant(PROMPT, 200), ], + endsWithPrompt: true, }; /** diff --git a/website/src/lib/playground-shells.test.ts b/website/src/lib/playground-shells.test.ts index 22c32d1..61e691d 100644 --- a/website/src/lib/playground-shells.test.ts +++ b/website/src/lib/playground-shells.test.ts @@ -33,21 +33,29 @@ describe("PlaygroundShellRegistry", () => { }); it("does not print a duplicate prompt when the scenario already provided one", () => { - const adapter = new FakePtyAdapter(); - const output: string[] = []; - adapter.onPtyData((detail) => output.push(detail.data)); - adapter.spawnPty("one"); - - const registry = new PlaygroundShellRegistry( - adapter, - () => createProgram(), - () => true, - ); - registry.ensureShell("one"); - - adapter.writePty("one", "x"); - - expect(output.join("")).toBe("x"); + 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", () => { diff --git a/website/src/lib/playground-shells.ts b/website/src/lib/playground-shells.ts index 533092f..cbb1dfe 100644 --- a/website/src/lib/playground-shells.ts +++ b/website/src/lib/playground-shells.ts @@ -11,20 +11,14 @@ export type StartPlaygroundProgram = ( export class PlaygroundShellRegistry { private adapter: FakePtyAdapter; private startProgram: StartPlaygroundProgram; - private promptInitiallyShown: (id: string) => boolean; private shells = new Map(); private handlePtyExit = (detail: { id: string }) => { this.disposeShell(detail.id); }; - constructor( - adapter: FakePtyAdapter, - startProgram: StartPlaygroundProgram, - promptInitiallyShown: (id: string) => boolean = () => false, - ) { + constructor(adapter: FakePtyAdapter, startProgram: StartPlaygroundProgram) { this.adapter = adapter; this.startProgram = startProgram; - this.promptInitiallyShown = promptInitiallyShown; this.adapter.onPtyExit(this.handlePtyExit); } @@ -35,7 +29,7 @@ export class PlaygroundShellRegistry { const shell = new TutorialShell( (data) => this.adapter.sendOutput(id, data), (name, args, onExit) => this.startProgram(id, name, args, onExit), - { promptShown: this.promptInitiallyShown(id) }, + { promptShown: this.adapter.scenarioEndsWithPrompt(id) }, ); this.shells.set(id, shell); this.adapter.setInputHandler(id, (data) => shell.handleInput(data)); diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index ea4ef96..11e5ac4 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -113,7 +113,6 @@ function Playground() { } return null; }, - (terminalId) => terminalId !== PANE_MAIN && terminalId !== PANE_BOXED, ); shellRegistryRef.current = shellRegistry; From 5a6c8510da1b0dbf24c10ea4f29b7d102dfcc7a0 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:41:30 -0700 Subject: [PATCH 36/54] Route the kill event through fireEvent like every other Wall event acceptKill was the only place still calling onEventRef.current?.() directly. The whole point of fireEvent is to centralize that pattern, so use it. Required hoisting fireEvent above acceptKill so the useCallback dependency array can reference it. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Wall.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index e4e7b7f..e91137a 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -185,6 +185,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,16 +205,9 @@ export function Wall({ if (!ck || ck.exit) return; setConfirmKill({ ...ck, exit: 'confirm' }); onExit(); - onEventRef.current?.({ type: 'kill', id: ck.id }); + fireEvent({ type: 'kill', id: ck.id }); confirmTimerRef.current = setTimeout(() => setConfirmKill(null), KILL_CONFIRM_MS); - }, []); - - // --- External event notifications --- - const onEventRef = useRef(onEvent); - onEventRef.current = onEvent; - const fireEvent = useCallback((event: WallEvent) => { - onEventRef.current?.(event); - }, []); + }, [fireEvent]); useEffect(() => { onEventRef.current?.({ type: 'modeChange', mode }); }, [mode]); useEffect(() => { onEventRef.current?.({ type: 'zoomChange', zoomed }); }, [zoomed]); From 8f0ae05e9a6146304b183c33f9d674e158043769 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:47:31 -0700 Subject: [PATCH 37/54] Make playground tutorial interactive on load --- docs/specs/layout.md | 2 ++ docs/specs/tutorial.md | 2 +- lib/src/components/Wall.tsx | 4 +++- website/src/pages/Playground.tsx | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 1856719..5edfba6 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -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 diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 9307f98..a02a6b6 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -15,7 +15,7 @@ Three browser-side pieces in `website/src/lib/`, mirroring the pattern in `websi - `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 three initial panes: +- `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. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index e91137a..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'); diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index 11e5ac4..7b2c193 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -195,6 +195,7 @@ function Playground() { {WallModule ? ( From f4ab4d1118b3ef8605d347b17fc75eed60fa0b47 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 18:54:09 -0700 Subject: [PATCH 38/54] Drive playground tutorial auto-start from a pty-spawn event Replace the requestAnimationFrame poll (60-frame budget, silent warn-and-give-up on slow paint) with a FakePtyAdapter.onPtySpawn subscription. The Playground subscribes before mounting Wall so the TerminalPane mount effect's spawn can't race past us, and falls back to a hasPty() check for the case where the pty already exists by the time we attach. --- lib/src/lib/platform/fake-adapter.ts | 13 +++++++ website/src/pages/Playground.tsx | 52 +++++++++------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index ee9dbbb..c9e43b6 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -27,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[]>(); @@ -76,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(); @@ -96,6 +98,9 @@ export class FakePtyAdapter implements PlatformAdapter { cols: options?.cols ?? DEFAULT_PTY_SIZE.cols, rows: options?.rows ?? DEFAULT_PTY_SIZE.rows, }); + for (const handler of this.spawnHandlers) { + handler({ id }); + } const scenario = this.scenarioMap.get(id) ?? this.defaultScenario; if (scenario) { this.playScenario(id, scenario); @@ -191,6 +196,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 {} diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index 7b2c193..ff45588 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -26,37 +26,14 @@ function Playground() { const stateRef = useRef(null); const dockviewDisposablesRef = useRef([]); const tutorialAutoStartedRef = useRef(false); - const tutorialAutoStartRafRef = useRef(null); + const spawnUnsubRef = useRef<(() => void) | null>(null); - const scheduleTutorialAutoStart = useCallback(() => { - if (tutorialAutoStartedRef.current || tutorialAutoStartRafRef.current !== null) return; - - let attempts = 0; - const tick = () => { - tutorialAutoStartRafRef.current = null; - if (tutorialAutoStartedRef.current) return; - - const adapter = adapterRef.current; - const shellRegistry = shellRegistryRef.current; - if (!adapter || !shellRegistry) return; - - if (adapter.hasPty(PANE_MAIN)) { - tutorialAutoStartedRef.current = true; - shellRegistry.ensureShell(PANE_MAIN).runCommand("tut"); - return; - } - - attempts += 1; - if (attempts < 60) { - tutorialAutoStartRafRef.current = requestAnimationFrame(tick); - } else { - console.warn( - `[Playground] gave up waiting for ${PANE_MAIN} pty after ${attempts} frames; tutorial did not auto-start`, - ); - } - }; - - tutorialAutoStartRafRef.current = requestAnimationFrame(tick); + const tryAutoStartTutorial = useCallback(() => { + if (tutorialAutoStartedRef.current) return; + const shellRegistry = shellRegistryRef.current; + if (!shellRegistry) return; + tutorialAutoStartedRef.current = true; + shellRegistry.ensureShell(PANE_MAIN).runCommand("tut"); }, []); useEffect(() => { @@ -120,6 +97,14 @@ function Playground() { 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(); @@ -136,10 +121,8 @@ function Playground() { shellRegistryRef.current = null; stateRef.current = null; tutorialAutoStartedRef.current = false; - if (tutorialAutoStartRafRef.current !== null) { - cancelAnimationFrame(tutorialAutoStartRafRef.current); - tutorialAutoStartRafRef.current = null; - } + spawnUnsubRef.current?.(); + spawnUnsubRef.current = null; }; }, []); @@ -171,7 +154,6 @@ function Playground() { if (mainPanel) mainPanel.api.setActive(); detectorRef.current?.attach(api); - scheduleTutorialAutoStart(); }, []); const handleWallEvent = useCallback((event: WallEvent) => { From 363d82e3e43c774fa46a7cca48809b4cf103d7ed Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:00:01 -0700 Subject: [PATCH 39/54] Fix tutorial q navigation --- website/src/lib/tut-runner.test.ts | 19 ++++++++++++++++++- website/src/lib/tut-runner.ts | 3 +-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 9182491..6004193 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -12,6 +12,7 @@ function mountRunner(completedIds: ItemId[] = []) { adapter.spawnPty(id); const frames: string[] = []; + let exitCount = 0; adapter.onPtyData(({ data }) => frames.push(data)); const state = new TutorialState(); @@ -21,7 +22,9 @@ function mountRunner(completedIds: ItemId[] = []) { adapter, terminalId: id, state, - onExit: () => {}, + onExit: () => { + exitCount += 1; + }, }); adapter.setInputHandler(id, (data) => runner.handleInput(data)); runner.start(); @@ -34,6 +37,7 @@ function mountRunner(completedIds: ItemId[] = []) { const i = all.lastIndexOf(FRAME_RESET); return i >= 0 ? all.slice(i) : all; }, + exitCount: () => exitCount, dispose: () => runner.dispose(), }; } @@ -73,4 +77,17 @@ describe("TutRunner snapshots", () => { 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 index 5bfbc1b..9da7c22 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -157,7 +157,7 @@ export class TutRunner implements InteractiveProgram { continue; } if (ch === "q" || ch === "Q") { - this.exit(); + this.handleEscape(); return; } if ( @@ -482,4 +482,3 @@ export class TutRunner implements InteractiveProgram { this.adapter.sendOutput(this.terminalId, data); } } - From 8dc104dcecc9560d4e8176d5dce6b070b6ca541f Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:06:54 -0700 Subject: [PATCH 40/54] Add skipActivity opt-out to FakePtyAdapter.sendOutput TutRunner re-renders its alt-screen frames through sendOutput on every state change. With sendOutput unconditionally feeding alertManager.onData, enabling the bell on the runner pane would tilt it on every menu redraw even though the runner is UI chrome, not a running task. Add an option that lets the runner opt out, leaving the default behavior (browser-side echo and ascii-splash frames feeding the activity monitor) intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/platform/fake-adapter.ts | 9 +++++++-- website/src/lib/tut-runner.ts | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index c9e43b6..2d41ac9 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -243,10 +243,15 @@ export class FakePtyAdapter implements PlatformAdapter { * 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): void { + sendOutput(id: string, data: string, options: { skipActivity?: boolean } = {}): void { if (!this.terminals.has(id)) return; - this.alertManager.onData(id); + if (!options.skipActivity) this.alertManager.onData(id); for (const handler of this.dataHandlers) { handler({ id, data }); } diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 9da7c22..f07eba5 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -479,6 +479,9 @@ export class TutRunner implements InteractiveProgram { } private write(data: string): void { - this.adapter.sendOutput(this.terminalId, data); + // 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 }); } } From 457e78cda47c44ddcc2312b2dbd2e0422b8d5954 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:07:09 -0700 Subject: [PATCH 41/54] Resume tutorial spinner when re-entering Alert mid-demo Esc out of the Alert section stops the spinner timer but leaves busyDemoStart set so the countdown still tracks elapsed time. On re-entry the countdown line rendered correctly but the spinner glyph was frozen at whichever frame the timer last drew. Restart the timer when the user re-opens Alert while a demo is still in flight. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-runner.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index f07eba5..95d5d3d 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -213,6 +213,12 @@ export class TutRunner implements InteractiveProgram { 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; } From 025c6e6da1bae196662519f1171fdcbb3004326f Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:07:29 -0700 Subject: [PATCH 42/54] Seed kb-arrows from dockview's active panel, not wall selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When entering command mode, the detector seeds commandModePanels with the source pane so the very next arrow press credits kb-arrows. The seed previously preferred currentPaneId (set by selectionChange), which can lag or differ from dockview's active panel — the source the arrow-nav handler actually navigates from. Flip the priority so the seed matches what onDidActivePanelChange fires against, keeping currentPaneId as a fallback for tests that don't expose api.activePanel. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-detector.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 835b8f0..6e3316e 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -77,7 +77,11 @@ export class TutDetector { if (event.mode === "command" && this.currentMode === "passthrough") { this.state.markComplete("kb-mode"); this.commandModePanels.clear(); - const activePaneId = this.currentPaneId ?? this.api?.activePanel?.id; + // 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; From 4d23cc9b1800e3b6f519779cec5bce105287ebe0 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:07:57 -0700 Subject: [PATCH 43/54] Cancel Playground busy-demo timers on unmount pumpActivity returns a dispose handle that cancels its setInterval and setTimeout. The Playground was dropping the handle on the floor, so navigating away from /playground while a demo was in flight left the timers running until they expired. Track the latest handle in a ref, dispose it on unmount, and dispose any prior handle before starting a new demo as a defense-in-depth against stacked intervals. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/pages/Playground.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index ff45588..edda66f 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -27,6 +27,7 @@ function Playground() { const dockviewDisposablesRef = useRef([]); const tutorialAutoStartedRef = useRef(false); const spawnUnsubRef = useRef<(() => void) | null>(null); + const busyDemoDisposeRef = useRef<(() => void) | null>(null); const tryAutoStartTutorial = useCallback(() => { if (tutorialAutoStartedRef.current) return; @@ -76,7 +77,12 @@ function Playground() { state: tutorialState, onExit, onTriggerBusyDemo: () => { - adapter.pumpActivity(PANE_TARGET, BUSY_DEMO_DURATION_MS, 800); + busyDemoDisposeRef.current?.(); + busyDemoDisposeRef.current = adapter.pumpActivity( + PANE_TARGET, + BUSY_DEMO_DURATION_MS, + 800, + ); }, }); } @@ -123,6 +129,8 @@ function Playground() { tutorialAutoStartedRef.current = false; spawnUnsubRef.current?.(); spawnUnsubRef.current = null; + busyDemoDisposeRef.current?.(); + busyDemoDisposeRef.current = null; }; }, []); From e5dba244bb32fdbf5b2987d09ec515384122daa4 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:13:41 -0700 Subject: [PATCH 44/54] docs: align tutorial spec with runner behavior --- docs/specs/tutorial.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index a02a6b6..eb8e3b0 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -1,6 +1,6 @@ # Playground Tutorial -At the `/playground` route on the website. Interactive TUI: each item shows a spinner while pending, becomes a green check when MouseTerm detects the corresponding action. +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. ## Architecture @@ -28,7 +28,7 @@ Every playground pane gets a `TutorialShell` input handler through `PlaygroundSh 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: - `✓` (green) — complete -- `⠋` (yellow spinner) — first incomplete item, with hint text shown below +- `●` (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 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. @@ -64,7 +64,7 @@ The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, t 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: -1. Calls `adapter.pumpActivity(PANE_TARGET, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the demo pane with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. `BUSY_DEMO_DURATION_MS` is `cfg.alert.userAttention + 1` so silence begins after the attention idle window has expired; otherwise the "user is looking at this pane" check inside `ActivityMonitor.startNeedsAttentionConfirmTimer` would suppress the ring rather than let it fire. +1. Calls `adapter.pumpActivity(PANE_TARGET, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the demo pane with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. `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. ### Section 3 — Copy paste (4 items) From 7e050e4c6fb4ae6dbcf99ca77117dcf092b1a437 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:18:04 -0700 Subject: [PATCH 45/54] Notify listeners before persisting in markComplete Previously a localStorage write failure (quota, private mode, etc.) would short-circuit the listener notification, leaving the runner UI out of sync with the in-memory completion set. Reorder so the in-memory update propagates regardless, and swallow persistence errors so they don't escape into callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tutorial-state.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/website/src/lib/tutorial-state.ts b/website/src/lib/tutorial-state.ts index 304f9ba..a52ee54 100644 --- a/website/src/lib/tutorial-state.ts +++ b/website/src/lib/tutorial-state.ts @@ -38,8 +38,8 @@ export class TutorialState { markComplete(id: ItemId): boolean { if (this.completed.has(id)) return false; this.completed.add(id); - this.persist(); this.notify(); + this.persist(); return true; } @@ -69,6 +69,12 @@ export class TutorialState { } private persist(): void { - this.storage?.setItem(STORAGE_KEY, JSON.stringify([...this.completed])); + 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. + } } } From 10fb5a930e20db43608eaa8de0fe40d91aba3ad1 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:18:40 -0700 Subject: [PATCH 46/54] Stop pumpActivity ticks when the pty is gone If a pty was killed mid-duration, the interval kept calling alertManager.onData(id) for a terminal that no longer existed. Check terminals.has(id) inside the tick and cancel ourselves on miss, so the activity monitor doesn't accumulate ghost activity for removed panes. Also unified the cancel paths through a single closure so the duration timeout and external dispose can't race past each other's clears. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/platform/fake-adapter.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 2d41ac9..2721264 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -269,21 +269,28 @@ export class FakePtyAdapter implements PlatformAdapter { 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); }; - const interval = setInterval(tick, intervalMs); - const stop = setTimeout(() => { - cancelled = true; - clearInterval(interval); - }, durationMs); - return () => { - cancelled = true; - clearInterval(interval); - clearTimeout(stop); - }; + interval = setInterval(tick, intervalMs); + stop = setTimeout(cancel, durationMs); + return cancel; } private playScenario(id: string, scenario: FakeScenario): void { From 936ff4929c9ce70ac95f366f91e6a1f3b473f04a Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:27:01 -0700 Subject: [PATCH 47/54] Fix tutorial alert demo target --- docs/specs/tutorial.md | 4 +-- .../lib/__snapshots__/tut-runner.test.ts.snap | 3 +- website/src/lib/tut-detector.test.ts | 25 +++++++++++++ website/src/lib/tut-detector.ts | 36 ++++++++++++++++++- website/src/lib/tut-items.ts | 4 +-- website/src/pages/Playground.tsx | 12 +++++-- 6 files changed, 76 insertions(+), 8 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index eb8e3b0..c71a542 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -62,9 +62,9 @@ The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, t | `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` | -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: +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: -1. Calls `adapter.pumpActivity(PANE_TARGET, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the demo pane with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. `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. +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. ### Section 3 — Copy paste (4 items) diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 9b952df..18764ac 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -6,7 +6,8 @@ exports[`TutRunner snapshots > renders Alert and TODO with all items incomplete Esc to go back ● Enable alerts on a pane - Click the bell, or press a in command mode with the pane selected. + 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 diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index ab17def..2f9bdf2 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -10,6 +10,7 @@ function makeDetectorHarness() { 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( @@ -32,6 +33,7 @@ function makeDetectorHarness() { }; }, }, + { onAlertDemoPaneChange }, ); detector.attach({ @@ -53,6 +55,7 @@ function makeDetectorHarness() { mouseListener?.(); }, activePanelChange: (id: string) => activePanelListener?.({ id }), + onAlertDemoPaneChange, }; } @@ -130,4 +133,26 @@ describe("TutDetector", () => { 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 index 6e3316e..1d9fd4d 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -17,14 +17,21 @@ interface MouseSelectionModule { 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 prevActivity = new Map(); private prevMouse = new Map(); private disposables: (() => void)[] = []; @@ -33,10 +40,12 @@ export class TutDetector { 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 { @@ -45,7 +54,12 @@ export class TutDetector { // 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 }); } @@ -127,6 +141,15 @@ export class TutDetector { 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 @@ -158,7 +181,14 @@ export class TutDetector { this.prevActivity.set(id, { ...current }); } for (const id of this.prevActivity.keys()) { - if (!snapshot.has(id)) this.prevActivity.delete(id); + 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(); + } + } } } @@ -191,4 +221,8 @@ export class TutDetector { 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 index bbb7a1d..92b7109 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -88,12 +88,12 @@ export const SECTIONS: readonly Section[] = [ { id: 'al-enable', title: 'Enable alerts on a pane', - hint: 'Click the bell, or press `a` in command mode with the pane selected.', + 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` to start a fake busy task in this demo pane.', + hint: 'Press `s` here to start a fake busy task on that alert-enabled pane.', }, { id: 'al-ring', diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index edda66f..b1a8b8f 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -28,6 +28,7 @@ function Playground() { 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; @@ -64,7 +65,11 @@ function Playground() { const tutorialState = new TutorialState(); stateRef.current = tutorialState; - const detector = new TutDetector(tutorialState, registry, mouseSelection); + const detector = new TutDetector(tutorialState, registry, mouseSelection, { + onAlertDemoPaneChange: (id) => { + alertDemoPaneIdRef.current = id; + }, + }); detectorRef.current = detector; const shellRegistry = new PlaygroundShellRegistry( @@ -77,9 +82,11 @@ function Playground() { state: tutorialState, onExit, onTriggerBusyDemo: () => { + const paneId = alertDemoPaneIdRef.current ?? PANE_TARGET; + const sessionId = registry.resolveTerminalSessionId(paneId); busyDemoDisposeRef.current?.(); busyDemoDisposeRef.current = adapter.pumpActivity( - PANE_TARGET, + sessionId, BUSY_DEMO_DURATION_MS, 800, ); @@ -127,6 +134,7 @@ function Playground() { shellRegistryRef.current = null; stateRef.current = null; tutorialAutoStartedRef.current = false; + alertDemoPaneIdRef.current = null; spawnUnsubRef.current?.(); spawnUnsubRef.current = null; busyDemoDisposeRef.current?.(); From f1c7aa962c27a29256d1aa25bf9757420bc1b4c6 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:32:55 -0700 Subject: [PATCH 48/54] Don't credit kb-arrows for the focus change after a Cmd/Ctrl+Arrow swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Cmd/Ctrl+Arrow swap fires a `move` event then re-selects the swap target, which would grow commandModePanels to 2 and falsely mark `kb-arrows` complete on the user's first modifier-arrow press — before they ever pressed a bare arrow key. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-detector.test.ts | 17 +++++++++++++++++ website/src/lib/tut-detector.ts | 10 ++++++++++ 2 files changed, 27 insertions(+) diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index 2f9bdf2..c8a01d7 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -92,6 +92,23 @@ describe("TutDetector", () => { 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(); diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 1d9fd4d..2cc451e 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -32,6 +32,7 @@ export class TutDetector { 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)[] = []; @@ -67,6 +68,14 @@ export class TutDetector { 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"); @@ -116,6 +125,7 @@ export class TutDetector { break; case "move": this.state.markComplete("kb-move"); + this.pendingMoveTargetId = event.toId; break; case "selectionChange": if (event.kind === "pane") { From 5765c4f0734b02aca811651573bfdd53c514c7b5 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:42:45 -0700 Subject: [PATCH 49/54] Snapshot shell keys before disposing in PlaygroundShellRegistry disposeShell() deletes from this.shells, so iterating over this.shells.keys() directly relies on Map's "delete during iteration is safe" semantics. Snapshot the keys so the loop is robust to future changes in either disposeShell or Map iteration. --- website/src/lib/playground-shells.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/lib/playground-shells.ts b/website/src/lib/playground-shells.ts index cbb1dfe..6708b76 100644 --- a/website/src/lib/playground-shells.ts +++ b/website/src/lib/playground-shells.ts @@ -44,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); } } From 6a1e1057bdf3832ba7b1b48fd847e415ad1476e3 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:42:54 -0700 Subject: [PATCH 50/54] Throw on unknown sectionId in TutRunner.renderSection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fallback silently mutated this.screen mid-render and returned the menu — masking a bug instead of surfacing it. The sectionId is always set from SECTIONS via handleEnter, so reaching this branch indicates a real invariant violation. --- website/src/lib/tut-runner.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 95d5d3d..2e982b0 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -351,8 +351,7 @@ export class TutRunner implements InteractiveProgram { private renderSection(): string[] { const section = SECTIONS.find((s) => s.id === this.sectionId); if (!section) { - this.screen = "menu"; - return this.renderMenu(); + throw new Error(`renderSection: unknown sectionId ${this.sectionId}`); } const { done, total } = this.state.sectionProgress(section.id); const lines: string[] = []; From 1e33fc8b4c20fa1df73ed2e1e18b536a0ebf2297 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:43:09 -0700 Subject: [PATCH 51/54] Centralize scenario lookup in FakePtyAdapter spawnPty() and scenarioEndsWithPrompt() were each computing "per-id scenario, falling back to the default" inline. Pull both through a single resolveScenario() helper so future call sites inherit the same precedence rule. --- lib/src/lib/platform/fake-adapter.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 2721264..513c10e 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -101,12 +101,16 @@ export class FakePtyAdapter implements PlatformAdapter { for (const handler of this.spawnHandlers) { handler({ id }); } - const scenario = this.scenarioMap.get(id) ?? this.defaultScenario; + 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 @@ -178,8 +182,7 @@ export class FakePtyAdapter implements PlatformAdapter { /** 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 { - const scenario = this.scenarioMap.get(id) ?? this.defaultScenario; - return scenario?.endsWithPrompt === true; + return this.resolveScenario(id)?.endsWithPrompt === true; } async readClipboardFilePaths(): Promise { return null; } From 02069a384ac2f8257840eb860164f1a2543b7b9d Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:43:21 -0700 Subject: [PATCH 52/54] Type the DockviewApi surface TutDetector actually consumes The detector only touches activePanel and onDidActivePanelChange. Replace the any escape hatch with a 2-line interface so typos in either property surface at compile time. --- website/src/lib/tut-detector.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 2cc451e..b05d9c7 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -1,7 +1,12 @@ import { DEFAULT_MOUSE_SELECTION_STATE } from "mouseterm-lib/lib/mouse-selection"; import type { TutorialState } from "./tutorial-state"; -type DockviewApi = any; +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; From 55ed8ac1b57816c7a4a17630bc873c548eb2ece1 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:43:28 -0700 Subject: [PATCH 53/54] Reject double-attach in TutDetector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second attach() would register a second onDidActivePanelChange listener and double-process every activity/mouse subscription — the detector would credit each event twice and the dispose path would only tear down half of them. Throw rather than silently accumulating. --- website/src/lib/tut-detector.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index b05d9c7..84a3460 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -55,6 +55,9 @@ export class TutDetector { } 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". From 9364b277df420cb2361cfc257c16c70bfca25026 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Tue, 5 May 2026 19:43:58 -0700 Subject: [PATCH 54/54] Derive busy-demo tick interval from cfg.alert.busyCandidateGap The hardcoded 800ms in Playground worked because it was below the 1500ms busyCandidateGap, but the dependency was implicit: tuning cfg.alert would silently break the demo. Tie the interval to busyCandidateGap so the relationship is explicit and self-correcting. --- website/src/lib/tut-runner.ts | 9 +++++++++ website/src/pages/Playground.tsx | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 2e982b0..ea8f7b4 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -26,6 +26,15 @@ import type { TutorialState } from "./tutorial-state"; */ 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. diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index b1a8b8f..e7a52cc 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -4,7 +4,7 @@ import { ThemePicker } from "mouseterm-lib/components/ThemePicker"; import { PlaygroundShellRegistry } from "../lib/playground-shells"; import { TutorialState } from "../lib/tutorial-state"; import { TutDetector } from "../lib/tut-detector"; -import { BUSY_DEMO_DURATION_MS, TutRunner } from "../lib/tut-runner"; +import { BUSY_DEMO_DURATION_MS, BUSY_DEMO_INTERVAL_MS, TutRunner } from "../lib/tut-runner"; export { Playground as Component }; @@ -88,7 +88,7 @@ function Playground() { busyDemoDisposeRef.current = adapter.pumpActivity( sessionId, BUSY_DEMO_DURATION_MS, - 800, + BUSY_DEMO_INTERVAL_MS, ); }, });