diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md
index c71a542..0134bc4 100644
--- a/docs/specs/tutorial.md
+++ b/docs/specs/tutorial.md
@@ -15,11 +15,15 @@ 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 `initialMode="passthrough"` and three initial panes:
- - **`tut-main`** (left, ~50%) — auto-launches `TutRunner` on mount via `mainShell.runCommand("tut")`.
- - **`tut-target`** (right-top, ~25%) — `SCENARIO_SHELL_PROMPT`. Used as the demo pane for keyboard-nav and alert sections.
- - **`tut-boxed`** (right-bottom, ~25%) — `SCENARIO_BOXED_PARAGRAPH`. The boxed paragraph for Copy Rewrapped vs Copy Raw.
-- The two right-side panes are added in `onApiReady` with `position: { referencePanel, direction }` after Wall creates the initial main pane.
+- `Wall` runs `FakePtyAdapter` with `initialMode="passthrough"`. The pane layout branches at mount on `window.innerWidth < 768` (Tailwind's `md` breakpoint, locked at mount; not reactive to resize):
+ - **Desktop (≥ 768px)** — three panes:
+ - **`tut-main`** (left, ~50%) — auto-launches `TutRunner` via `mainShell.runCommand("tut")`.
+ - **`tut-boxed`** (right-top, ~25%) — titled "changelog". Auto-launches `ChangelogRunner` via `boxedShell.runCommand("changelog")`. Doubles as the Copy Rewrapped target — its wrapped lines exercise the rewrap path.
+ - **`tut-splash`** (right-bottom, ~25%) — titled "ascii-splash". Auto-launches `AsciiSplashRunner` via `splashShell.runCommand("ascii-splash")`.
+ - **Phone (< 768px)** — two stacked panes; the changelog is dropped because the screen is too narrow to host it usefully:
+ - **`tut-main`** (top, ~50%) — same as desktop.
+ - **`tut-splash`** (bottom, ~50%) — same as desktop.
+- Side panes are added in `onApiReady` with `position: { referencePanel, direction }` after Wall creates the initial main pane.
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`.
@@ -64,7 +68,7 @@ The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, t
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. 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.
+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_BOXED` (the changelog pane). `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)
@@ -75,21 +79,22 @@ 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-rewrap` | Click Copy Rewrapped on wrapped text in the changelog pane | `copyFlash` transitions to `'rewrapped'` |
| `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."
-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.
+The Copy Rewrapped step uses the wrapped item lines `ChangelogRunner` produces in the `tut-boxed` pane. The runner word-wraps each item to fit the pane width, so Rewrapped joins those lines back together while Raw preserves the wrap; clipboard contents visibly differ. The user must override mouse capture first (the `cp-override` step) before drag-selecting inside the changelog pane, since the runner enables SGR mouse-reporting.
+
+While the Copy paste section is open, pressing `p` toggles the **Place To Paste** modal — a draggable scratch box with eight pointer-event resize handles (four edges + four corners), rendered by `website/src/components/PlaceToPaste.tsx` and mounted at the page level. `TutRunner` intercepts `p`/`P` (mirroring the Alert section's `s` busy-demo intercept) and calls `onTogglePlaceToPaste`; `Playground` flips a `placeToPasteOpen` flag so the modal is portal-free and overlays the wall. The runner renders a persistent `Press \`p\` to toggle the Place To Paste …` line above the section's prose paragraph so the prompt is visible regardless of which item is active. Users paste copied text into the modal's single textarea and resize it to see whether the text reflows (Rewrapped) or stays line-broken (Raw).
## 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.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.
@@ -115,7 +120,7 @@ The picker restores the persisted active theme on mount. The playground header i
## Mouse and Clipboard Feature Coverage
-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.
+The Playground is the primary dogfood surface for the features in `docs/specs/mouse-and-clipboard.md`. The tutorial layout (`tut-main` running the runner, `tut-boxed` auto-running `changelog`, `tut-splash` auto-running `ascii-splash`) covers most of the spec; one notable gap remains.
Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable.
@@ -130,7 +135,7 @@ Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable.
| §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) | ✅ | `SCENARIO_BOXED_PARAGRAPH` provides a boxed paragraph in `tut-boxed`. |
+| §4.1.2 | Copy Rewrapped (paragraph unwrap) | ✅ | `ChangelogRunner` in `tut-boxed` renders wrapped item lines that exercise the rewrap path. |
| §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. |
diff --git a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts
new file mode 100644
index 0000000..31c8575
--- /dev/null
+++ b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts
@@ -0,0 +1,56 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { handleMouseSelectionKeys } from './handle-mouse-selection-keys';
+import type { WallKeyboardCtx } from './types';
+
+vi.mock('../../../lib/clipboard', () => ({
+ copyRaw: vi.fn(),
+ copyRewrapped: vi.fn(),
+ doPaste: vi.fn(),
+}));
+vi.mock('../../../lib/platform', () => ({ IS_MAC: true }));
+
+function makeCtx(): WallKeyboardCtx {
+ return {
+ selectedIdRef: { current: 'pane-a' },
+ } as unknown as WallKeyboardCtx;
+}
+
+function fakeEvent(target: HTMLElement, init: Partial & { key: string }): KeyboardEvent {
+ const e = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
+ Object.defineProperty(e, 'target', { value: target });
+ return e;
+}
+
+describe('handleMouseSelectionKeys', () => {
+ it('does not intercept Cmd+V on a non-xterm textarea', async () => {
+ const { doPaste } = await import('../../../lib/clipboard');
+ const ta = document.createElement('textarea');
+ document.body.appendChild(ta);
+ const e = fakeEvent(ta, { key: 'v', metaKey: true });
+
+ const handled = handleMouseSelectionKeys(e, makeCtx());
+
+ expect(handled).toBe(false);
+ expect(e.defaultPrevented).toBe(false);
+ expect(doPaste).not.toHaveBeenCalled();
+ });
+
+ it('still intercepts Cmd+V on the xterm helper textarea', async () => {
+ const { doPaste } = await import('../../../lib/clipboard');
+ vi.mocked(doPaste).mockClear();
+ const ta = document.createElement('textarea');
+ ta.classList.add('xterm-helper-textarea');
+ document.body.appendChild(ta);
+ const e = fakeEvent(ta, { key: 'v', metaKey: true });
+
+ const handled = handleMouseSelectionKeys(e, makeCtx());
+
+ expect(handled).toBe(true);
+ expect(e.defaultPrevented).toBe(true);
+ expect(doPaste).toHaveBeenCalledWith('pane-a');
+ });
+
+});
diff --git a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts
index ec3f4af..5f7924b 100644
--- a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts
+++ b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts
@@ -13,6 +13,20 @@ import type { WallKeyboardCtx } from './types';
* Cmd-C / Cmd-Shift-C / Cmd-V outside drag. Returns true if handled.
*/
export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx): boolean {
+ // Don't shadow native clipboard ops when focus is inside a real text
+ // input (overlay modal, search box, etc.) — let the browser handle
+ // copy/paste there. Xterm's hidden helper textarea is the input proxy
+ // for the terminal itself, so we keep intercepting its keydowns.
+ const tgt = e.target as HTMLElement | null;
+ if (
+ tgt &&
+ (tgt.tagName === 'INPUT' ||
+ (tgt.tagName === 'TEXTAREA' && !tgt.classList.contains('xterm-helper-textarea')) ||
+ tgt.isContentEditable)
+ ) {
+ return false;
+ }
+
const sid = ctx.selectedIdRef.current;
if (!sid) return false;
diff --git a/lib/src/lib/ansi.ts b/lib/src/lib/ansi.ts
index ffd60f7..a34d0ab 100644
--- a/lib/src/lib/ansi.ts
+++ b/lib/src/lib/ansi.ts
@@ -21,6 +21,12 @@ export const fg = (code: number): string => `${ESC}${code}m`;
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`;
+// SGR mouse-reporting toggles. xterm parses these and the wall's
+// mouse-mode-observer flips the cursor-icon override on/off so the user
+// knows MouseTerm is "trapping the mouse" while the program runs.
+export const MOUSE_ENABLE = `${ESC}?1000h${ESC}?1002h${ESC}?1003h${ESC}?1006h`;
+export const MOUSE_DISABLE = `${ESC}?1003l${ESC}?1002l${ESC}?1000l${ESC}?1006l`;
+
// 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 a7e0835..af8b669 100644
--- a/lib/src/lib/platform/fake-scenarios.ts
+++ b/lib/src/lib/platform/fake-scenarios.ts
@@ -139,35 +139,6 @@ export const SCENARIO_LONG_RUNNING: FakeScenario = {
endsWithPrompt: true,
};
-/**
- * Boxed paragraph for Copy Rewrapped vs Copy Raw demonstration. The frame
- * is pure box-drawing characters so `rewrap.ts` strips them; the text
- * inside wraps across lines so Rewrapped joins them with single spaces.
- */
-export const SCENARIO_BOXED_PARAGRAPH: FakeScenario = {
- name: 'boxed-paragraph',
- chunks: [
- instant(
- [
- '',
- `${fg(36)}┌─────────────────────────────────────────┐${RESET}`,
- `${fg(36)}│${RESET} ${BOLD}Release notes — v1.4.0${RESET} ${fg(36)}│${RESET}`,
- `${fg(36)}├─────────────────────────────────────────┤${RESET}`,
- `${fg(36)}│${RESET} MouseTerm now keeps a tab visible ${fg(36)}│${RESET}`,
- `${fg(36)}│${RESET} even while a long-running command is ${fg(36)}│${RESET}`,
- `${fg(36)}│${RESET} hidden in the baseboard, so background ${fg(36)}│${RESET}`,
- `${fg(36)}│${RESET} work never gets lost. ${fg(36)}│${RESET}`,
- `${fg(36)}│${RESET} ${fg(36)}│${RESET}`,
- `${fg(36)}│${RESET} Drag-select the paragraph above and ${fg(36)}│${RESET}`,
- `${fg(36)}│${RESET} try Copy Raw vs Copy Rewrapped. ${fg(36)}│${RESET}`,
- `${fg(36)}└─────────────────────────────────────────┘${RESET}`,
- '',
- ].join('\r\n'),
- 400,
- ),
- ],
-};
-
/** Rapid output burst — tests xterm.js scroll performance. */
export const SCENARIO_FAST_OUTPUT: FakeScenario = {
name: 'fast-output',
diff --git a/website/src/components/PlaceToPaste.tsx b/website/src/components/PlaceToPaste.tsx
new file mode 100644
index 0000000..c427d1e
--- /dev/null
+++ b/website/src/components/PlaceToPaste.tsx
@@ -0,0 +1,160 @@
+import { useRef, useState } from "react";
+
+interface PlaceToPasteProps {
+ onClose: () => void;
+}
+
+const INITIAL = { x: 360, y: 140, w: 420, h: 280 };
+const MIN = { w: 240, h: 140 };
+
+type ResizeDir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
+
+const RESIZE_HANDLES: { dir: ResizeDir; cls: string; cursor: string }[] = [
+ { dir: "n", cls: "top-0 left-0 right-0 h-1.5", cursor: "cursor-ns-resize" },
+ { dir: "s", cls: "bottom-0 left-0 right-0 h-1.5", cursor: "cursor-ns-resize" },
+ { dir: "w", cls: "top-0 bottom-0 left-0 w-1.5", cursor: "cursor-ew-resize" },
+ { dir: "e", cls: "top-0 bottom-0 right-0 w-1.5", cursor: "cursor-ew-resize" },
+ { dir: "nw", cls: "top-0 left-0 w-2.5 h-2.5", cursor: "cursor-nwse-resize" },
+ { dir: "ne", cls: "top-0 right-0 w-2.5 h-2.5", cursor: "cursor-nesw-resize" },
+ { dir: "sw", cls: "bottom-0 left-0 w-2.5 h-2.5", cursor: "cursor-nesw-resize" },
+ { dir: "se", cls: "bottom-0 right-0 w-2.5 h-2.5", cursor: "cursor-nwse-resize" },
+];
+
+export function PlaceToPaste({ onClose }: PlaceToPasteProps) {
+ const [pos, setPos] = useState({ x: INITIAL.x, y: INITIAL.y });
+ const [size, setSize] = useState({ w: INITIAL.w, h: INITIAL.h });
+ const dragRef = useRef<{ mx: number; my: number; px: number; py: number } | null>(null);
+ const resizeRef = useRef<
+ | {
+ dir: ResizeDir;
+ mx: number;
+ my: number;
+ px: number;
+ py: number;
+ pw: number;
+ ph: number;
+ }
+ | null
+ >(null);
+
+ const onHeaderPointerDown = (e: React.PointerEvent) => {
+ if (e.button !== 0) return;
+ if (e.target !== e.currentTarget) return;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ dragRef.current = { mx: e.clientX, my: e.clientY, px: pos.x, py: pos.y };
+ };
+
+ const onHeaderPointerMove = (e: React.PointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+ setPos({ x: d.px + e.clientX - d.mx, y: d.py + e.clientY - d.my });
+ };
+
+ const onHeaderPointerUp = (e: React.PointerEvent) => {
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ }
+ dragRef.current = null;
+ };
+
+ const onResizeDown = (dir: ResizeDir) => (e: React.PointerEvent) => {
+ if (e.button !== 0) return;
+ e.stopPropagation();
+ e.currentTarget.setPointerCapture(e.pointerId);
+ resizeRef.current = {
+ dir,
+ mx: e.clientX,
+ my: e.clientY,
+ px: pos.x,
+ py: pos.y,
+ pw: size.w,
+ ph: size.h,
+ };
+ };
+
+ const onResizeMove = (e: React.PointerEvent) => {
+ const r = resizeRef.current;
+ if (!r) return;
+ const dx = e.clientX - r.mx;
+ const dy = e.clientY - r.my;
+ let x = r.px;
+ let y = r.py;
+ let w = r.pw;
+ let h = r.ph;
+ if (r.dir.includes("e")) w = r.pw + dx;
+ if (r.dir.includes("w")) {
+ w = r.pw - dx;
+ x = r.px + dx;
+ }
+ if (r.dir.includes("s")) h = r.ph + dy;
+ if (r.dir.includes("n")) {
+ h = r.ph - dy;
+ y = r.py + dy;
+ }
+ if (w < MIN.w) {
+ if (r.dir.includes("w")) x = r.px + (r.pw - MIN.w);
+ w = MIN.w;
+ }
+ if (h < MIN.h) {
+ if (r.dir.includes("n")) y = r.py + (r.ph - MIN.h);
+ h = MIN.h;
+ }
+ setPos({ x, y });
+ setSize({ w, h });
+ };
+
+ const onResizeUp = (e: React.PointerEvent) => {
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ }
+ resizeRef.current = null;
+ };
+
+ return (
+
+
+ Place To Paste
+
+
+
+ {RESIZE_HANDLES.map((h) => (
+
+ ))}
+
+ );
+}
diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap
index 18764ac..6e1dcee 100644
--- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap
+++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap
@@ -27,13 +27,15 @@ exports[`TutRunner snapshots > renders Copy paste with all items incomplete 1`]
[3mThe paragraph below is a good example — "Some terminal programs..."[0m
[2m·[0m Copy-paste it somewhere else with "Copy Raw"
[2m·[0m Copy-paste it somewhere else with "Copy Rewrapped"
- [2m·[0m Run [36mascii-splash[39m, then click its cursor icon
+ [2m·[0m Click the cursor icon in [36mchangelog[39m
+
+ [2mPress [36mp[39m to toggle the Place To Paste.[0m
[2mSome terminal programs trap the cursor, and some do not. This tutorial pane[0m
[2mdoes not trap the cursor, so MouseTerm does not show a cursor icon. The[0m
- [2m[36mascii-splash[39m program traps the cursor — that is how it is able to respond to[0m
- [2mmouse movement. [36mlazygit[39m is an excellent and popular program which traps the[0m
- [2mcursor.[0m
+ [2m[36mascii-splash[39m and [36mchangelog[39m programs trap the cursor — that is how they are[0m
+ [2mable to respond to mouse movement. [36mlazygit[39m is an excellent and popular program[0m
+ [2mwhich traps the cursor.[0m
"
`;
diff --git a/website/src/lib/ascii-splash-runner.ts b/website/src/lib/ascii-splash-runner.ts
index 4299c44..9ce7f6b 100644
--- a/website/src/lib/ascii-splash-runner.ts
+++ b/website/src/lib/ascii-splash-runner.ts
@@ -55,7 +55,12 @@ 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 {
+ ENTER_ALT_SCREEN,
+ LEAVE_ALT_SCREEN,
+ MOUSE_DISABLE,
+ MOUSE_ENABLE,
+} from "mouseterm-lib/lib/ansi";
import type { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter";
import type { InteractiveProgram } from "./tutorial-shell";
@@ -94,8 +99,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 PATTERN_NAMES = [
"waves",
diff --git a/website/src/lib/changelog-runner.ts b/website/src/lib/changelog-runner.ts
new file mode 100644
index 0000000..36f1571
--- /dev/null
+++ b/website/src/lib/changelog-runner.ts
@@ -0,0 +1,317 @@
+import {
+ BOLD,
+ CLEAR_SCREEN,
+ CURSOR_HOME,
+ DIM,
+ ENTER_ALT_SCREEN,
+ FG_DEFAULT,
+ LEAVE_ALT_SCREEN,
+ MOUSE_DISABLE,
+ MOUSE_ENABLE,
+ RESET,
+ fg,
+} from "mouseterm-lib/lib/ansi";
+import type { FakePtyAdapter } from "mouseterm-lib/lib/platform/fake-adapter";
+import type { InteractiveProgram } from "./tutorial-shell";
+import changelogData from "../data/changelog.json";
+
+const LIST_WIDTH = 12;
+const SEPARATOR = "│";
+const HEADER_ROWS = 2; // title + blank
+const FOOTER_ROWS = 1; // hint line
+
+// SGR mouse button codes for wheel events.
+const WHEEL_UP = 64;
+const WHEEL_DOWN = 65;
+
+interface Item {
+ text: string;
+ children: Item[];
+}
+
+interface Section {
+ title: string;
+ items: Item[];
+}
+
+interface Release {
+ version: string;
+ tag: string;
+ date: string;
+ sections: Section[];
+}
+
+const RELEASES = (changelogData as { releases: Release[] }).releases;
+
+function stripLinks(text: string): string {
+ return text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
+}
+
+function wrap(text: string, width: number): string[] {
+ if (width <= 1) return [text];
+ const out: string[] = [];
+ let line = "";
+ for (const word of text.split(/\s+/).filter(Boolean)) {
+ if (!line) {
+ line = word;
+ } else if (line.length + 1 + word.length <= width) {
+ line += " " + word;
+ } else {
+ out.push(line);
+ line = word;
+ }
+ }
+ if (line) out.push(line);
+ return out.length > 0 ? out : [""];
+}
+
+interface ChangelogRunnerOptions {
+ adapter: FakePtyAdapter;
+ terminalId: string;
+ onExit: () => void;
+}
+
+export class ChangelogRunner implements InteractiveProgram {
+ private adapter: FakePtyAdapter;
+ private terminalId: string;
+ private onExit: () => void;
+ private selectedIndex = 0;
+ private listOffset = 0;
+ private detailOffset = 0;
+ private resizeUnsub: (() => void) | null = null;
+ private disposed = false;
+ private detailCache: { index: number; width: number; lines: string[] } | null = null;
+ private lastSize = { cols: 0, rows: 0 };
+
+ constructor(options: ChangelogRunnerOptions) {
+ this.adapter = options.adapter;
+ this.terminalId = options.terminalId;
+ this.onExit = options.onExit;
+ }
+
+ start(): void {
+ this.write(ENTER_ALT_SCREEN);
+ this.write(MOUSE_ENABLE);
+ this.resizeUnsub = this.adapter.onPtyResize((d) => {
+ if (d.id !== this.terminalId) return;
+ const { cols, rows } = this.size;
+ if (cols === this.lastSize.cols && rows === this.lastSize.rows) return;
+ this.render();
+ });
+ 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 mouse = tail.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
+ if (mouse) {
+ this.handleMouse(Number(mouse[1]), Number(mouse[2]) - 1, Number(mouse[3]) - 1, mouse[4]);
+ i += mouse[0].length;
+ continue;
+ }
+ const csi = tail.match(/^\x1b\[(\d*)([A-Z~])/);
+ if (csi) {
+ this.handleCsi(csi[1], csi[2]);
+ i += csi[0].length;
+ continue;
+ }
+ // Bare Escape — exit.
+ this.exit();
+ return;
+ }
+ if (ch === "q" || ch === "Q") { this.exit(); return; }
+ if (ch === "j") this.moveSelection(1);
+ else if (ch === "k") this.moveSelection(-1);
+ else if (ch === "g") this.jumpFirst();
+ else if (ch === "G") this.jumpLast();
+ i++;
+ }
+ }
+
+ dispose(): void {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.resizeUnsub?.();
+ this.resizeUnsub = null;
+ this.write(MOUSE_DISABLE);
+ this.write(LEAVE_ALT_SCREEN);
+ }
+
+ // --- input ---
+
+ private exit(): void {
+ if (this.disposed) return;
+ this.dispose();
+ this.onExit();
+ }
+
+ private handleCsi(num: string, code: string): void {
+ if (code === "A") this.moveSelection(-1);
+ else if (code === "B") this.moveSelection(1);
+ else if (code === "H") this.jumpFirst();
+ else if (code === "F") this.jumpLast();
+ else if (code === "~" && num === "5") this.scrollDetail(-Math.max(1, this.bodyHeight() - 1));
+ else if (code === "~" && num === "6") this.scrollDetail(Math.max(1, this.bodyHeight() - 1));
+ }
+
+ private handleMouse(button: number, col: number, row: number, finalByte: string): void {
+ // Wheel scroll routes to whichever column the cursor is over so each
+ // side scrolls independently.
+ if (button === WHEEL_UP) {
+ if (col < LIST_WIDTH) this.scrollList(-1);
+ else this.scrollDetail(-1);
+ return;
+ }
+ if (button === WHEEL_DOWN) {
+ if (col < LIST_WIDTH) this.scrollList(1);
+ else this.scrollDetail(1);
+ return;
+ }
+ // Left-button press on the version list selects that release.
+ if (button === 0 && finalByte === "M" && col < LIST_WIDTH) {
+ const bodyTop = HEADER_ROWS;
+ const idx = this.listOffset + (row - bodyTop);
+ if (idx >= 0 && idx < RELEASES.length) {
+ this.selectedIndex = idx;
+ this.detailOffset = 0;
+ this.ensureSelectionVisible();
+ this.render();
+ }
+ }
+ }
+
+ private moveSelection(delta: number): void {
+ const next = Math.max(0, Math.min(RELEASES.length - 1, this.selectedIndex + delta));
+ if (next === this.selectedIndex) return;
+ this.selectedIndex = next;
+ this.detailOffset = 0;
+ this.ensureSelectionVisible();
+ this.render();
+ }
+
+ private jumpFirst(): void {
+ this.selectedIndex = 0;
+ this.detailOffset = 0;
+ this.listOffset = 0;
+ this.render();
+ }
+
+ private jumpLast(): void {
+ this.selectedIndex = RELEASES.length - 1;
+ this.detailOffset = 0;
+ this.ensureSelectionVisible();
+ this.render();
+ }
+
+ private ensureSelectionVisible(): void {
+ const h = this.bodyHeight();
+ if (this.selectedIndex < this.listOffset) this.listOffset = this.selectedIndex;
+ else if (this.selectedIndex >= this.listOffset + h) this.listOffset = this.selectedIndex - h + 1;
+ const maxOffset = Math.max(0, RELEASES.length - h);
+ if (this.listOffset > maxOffset) this.listOffset = maxOffset;
+ }
+
+ private scrollList(delta: number): void {
+ const max = Math.max(0, RELEASES.length - this.bodyHeight());
+ const next = Math.max(0, Math.min(max, this.listOffset + delta));
+ if (next === this.listOffset) return;
+ this.listOffset = next;
+ this.render();
+ }
+
+ private scrollDetail(delta: number): void {
+ const lines = this.getDetailLines();
+ const max = Math.max(0, lines.length - this.bodyHeight());
+ const next = Math.max(0, Math.min(max, this.detailOffset + delta));
+ if (next === this.detailOffset) return;
+ this.detailOffset = next;
+ this.render();
+ }
+
+ // --- layout ---
+
+ private get size() { return this.adapter.getPtySize(this.terminalId); }
+
+ private bodyHeight(): number {
+ return Math.max(1, this.size.rows - HEADER_ROWS - FOOTER_ROWS);
+ }
+
+ private detailWidth(): number {
+ return Math.max(10, this.size.cols - LIST_WIDTH - SEPARATOR.length);
+ }
+
+ private getDetailLines(): string[] {
+ const w = this.detailWidth();
+ const cached = this.detailCache;
+ if (cached && cached.index === this.selectedIndex && cached.width === w) {
+ return cached.lines;
+ }
+ const release = RELEASES[this.selectedIndex];
+ const lines = release ? this.buildDetailLines(release, w) : ["No releases."];
+ this.detailCache = { index: this.selectedIndex, width: w, lines };
+ return lines;
+ }
+
+ private buildDetailLines(release: Release, w: number): string[] {
+ const out: string[] = [];
+ out.push(`${BOLD}${release.version}${RESET} ${DIM}— ${release.date}${RESET}`);
+ out.push("");
+ for (const section of release.sections) {
+ out.push(`${fg(33)}${section.title}${FG_DEFAULT}`);
+ for (const item of section.items) {
+ const wrapped = wrap(stripLinks(item.text), Math.max(10, w - 4));
+ wrapped.forEach((line, idx) => {
+ out.push(`${idx === 0 ? " • " : " "}${line}`);
+ });
+ }
+ out.push("");
+ }
+ return out;
+ }
+
+ // --- render ---
+
+ private render(): void {
+ if (this.disposed) return;
+ this.lastSize = { ...this.size };
+ const bodyH = this.bodyHeight();
+ const detailLines = this.getDetailLines();
+
+ let frame = `${CURSOR_HOME}${CLEAR_SCREEN}`;
+ frame += `${BOLD}MouseTerm changelog${RESET} ${DIM}${RELEASES.length} releases · \`q\` to quit · ↑↓ select · wheel scrolls${RESET}\r\n`;
+ frame += "\r\n";
+
+ for (let r = 0; r < bodyH; r++) {
+ const idx = this.listOffset + r;
+ const release = RELEASES[idx];
+ let leftCell: string;
+ if (release) {
+ const label = `v${release.version}`;
+ const padded = label.length > LIST_WIDTH - 2 ? label.slice(0, LIST_WIDTH - 2) : label.padEnd(LIST_WIDTH - 2);
+ if (idx === this.selectedIndex) {
+ leftCell = `${fg(36)}❯${RESET} ${BOLD}${padded}${RESET}`;
+ } else {
+ leftCell = ` ${padded}`;
+ }
+ } else {
+ leftCell = " ".repeat(LIST_WIDTH);
+ }
+ const detailLine = detailLines[this.detailOffset + r] ?? "";
+ frame += `${leftCell}${DIM}${SEPARATOR}${RESET}${detailLine}\r\n`;
+ }
+
+ const more = this.detailOffset + bodyH < detailLines.length ? "↓ more" : "";
+ frame += `${DIM}${more}${RESET}`;
+ this.write(frame);
+ }
+
+ private write(data: string): void {
+ this.adapter.sendOutput(this.terminalId, data);
+ }
+}
diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts
index 92b7109..4bb111f 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: 'Run `ascii-splash`, then click its cursor icon',
+ title: 'Click the cursor icon in `changelog`',
hint:
- 'Use the demo pane prompt: run `ascii-splash`, click the cursor icon in its header, then drag-select. The animation cancels copying, but the same override works on real programs like `lazygit`.',
+ 'Try to click and drag in the changelog tab - you can\'t! That\'s because you can click the versions - the Terminal User Interface traps the mouse which breaks copy-paste. Click the cursor icon in its header, which disables the mouse tracking long enough for you to do a drag-select.',
},
],
prose: [
- 'Some terminal programs trap the cursor, and some do not. This tutorial pane does not trap the cursor, so MouseTerm does not show a cursor icon. The `ascii-splash` program traps the cursor — that is how it is able to respond to mouse movement. `lazygit` is an excellent and popular program which traps the cursor.',
+ '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` and `changelog` programs trap the cursor — that is how they are 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 ea8f7b4..783f30f 100644
--- a/website/src/lib/tut-runner.ts
+++ b/website/src/lib/tut-runner.ts
@@ -61,6 +61,8 @@ interface TutRunnerOptions {
onExit: () => void;
/** Called when the user presses `s` inside the Alert section. */
onTriggerBusyDemo?: () => void;
+ /** Called when the user presses `p` inside the Copy paste section. */
+ onTogglePlaceToPaste?: () => void;
}
type Screen = "menu" | "section" | "reset";
@@ -73,6 +75,7 @@ export class TutRunner implements InteractiveProgram {
private state: TutorialState;
private onExit: () => void;
private onTriggerBusyDemo?: () => void;
+ private onTogglePlaceToPaste?: () => void;
private screen: Screen = "menu";
private menuIndex = 0;
@@ -92,6 +95,7 @@ export class TutRunner implements InteractiveProgram {
this.state = options.state;
this.onExit = options.onExit;
this.onTriggerBusyDemo = options.onTriggerBusyDemo;
+ this.onTogglePlaceToPaste = options.onTogglePlaceToPaste;
}
start(): void {
@@ -181,6 +185,15 @@ export class TutRunner implements InteractiveProgram {
i += 1;
continue;
}
+ if (
+ this.screen === "section" &&
+ this.sectionId === "copy" &&
+ (ch === "p" || ch === "P")
+ ) {
+ this.onTogglePlaceToPaste?.();
+ i += 1;
+ continue;
+ }
i += 1;
}
}
@@ -374,6 +387,15 @@ export class TutRunner implements InteractiveProgram {
lines.push(...this.renderItem(item, index, activeIndex));
});
+ if (section.id === "copy") {
+ lines.push("");
+ const indent = " ";
+ const text = "Press `p` to toggle the Place To Paste.";
+ for (const wrapped of this.wrapText(text, indent.length)) {
+ lines.push(`${indent}${DIM}${wrapped}${RESET}`);
+ }
+ }
+
if (section.prose && section.prose.length > 0) {
lines.push("");
const indent = " ";
diff --git a/website/src/lib/tutorial-shell.ts b/website/src/lib/tutorial-shell.ts
index 75641c3..c596385 100644
--- a/website/src/lib/tutorial-shell.ts
+++ b/website/src/lib/tutorial-shell.ts
@@ -166,7 +166,7 @@ export class TutorialShell {
});
if (!program) {
this.sendOutput(
- `${fg(90)}Unknown command. Try ${fg(36)}tut${fg(90)} or ${fg(36)}ascii-splash${fg(90)}.${RESET}\r\n`,
+ `${fg(90)}Unknown command. Try ${fg(36)}tut${fg(90)}, ${fg(36)}ascii-splash${fg(90)}, or ${fg(36)}changelog${fg(90)}.${RESET}\r\n`,
);
this.showPrompt();
return;
diff --git a/website/src/pages/Home.tsx b/website/src/pages/Home.tsx
index 4870980..f36ef89 100644
--- a/website/src/pages/Home.tsx
+++ b/website/src/pages/Home.tsx
@@ -29,7 +29,7 @@ const RUNWAY_VH = 300;
const ICON_INITIAL_HIDE_FRAC = 0.67; // Fraction of icon's rendered height hidden at load — leaves top third visible
const HOOK_FADE_REMAINING = 0.10; // Hook begins fading when bottom 10% of icon enters viewport
const WORD_THRESHOLDS = [0.25, 0.40, 0.55] as const;
-const ASTERISK_THRESHOLD = 0.65;
+const FOOTNOTE_THRESHOLD = 0.65;
const HEADER_REVEAL_LEAD = 0.04;
/** Fraction of runway where the hero text unpins and scrolls away (0–1).
@@ -251,7 +251,6 @@ function Home() {
const word0Ref = useRef(null);
const word1Ref = useRef(null);
const word2Ref = useRef(null);
- const asteriskRef = useRef(null);
const footnoteRef = useRef(null);
const headerRef = useRef(null);
const headerBrandRef = useRef(null);
@@ -476,17 +475,16 @@ function Home() {
el.style.transform = `translateY(${(1 - progress) * 12}px)`;
}
- // Asterisk + footnote
- const astProgress = clamp01(
- (fraction - ASTERISK_THRESHOLD) / 0.08
+ // Footnote
+ const footnoteProgress = clamp01(
+ (fraction - FOOTNOTE_THRESHOLD) / 0.08
);
- if (asteriskRef.current) asteriskRef.current.style.opacity = String(astProgress);
- if (footnoteRef.current) footnoteRef.current.style.opacity = String(astProgress * 0.7);
+ if (footnoteRef.current) footnoteRef.current.style.opacity = String(footnoteProgress * 0.7);
// Header: reveal brand + background just before the tmux-shortcuts
// footnote appears, so it reads as dark once the line is visible.
const headerProgress = clamp01(
- (fraction - (ASTERISK_THRESHOLD - HEADER_REVEAL_LEAD)) / HEADER_REVEAL_LEAD
+ (fraction - (FOOTNOTE_THRESHOLD - HEADER_REVEAL_LEAD)) / HEADER_REVEAL_LEAD
);
if (headerBrandRef.current) {
headerBrandRef.current.style.opacity = String(headerProgress);
@@ -659,16 +657,14 @@ function Home() {
Terminal
-
- for Mice*
-
+ for Mice