Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions docs/specs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- `<main>` 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`.

Expand Down Expand Up @@ -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)
Expand All @@ -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.

Expand All @@ -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.

Expand All @@ -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. |
Expand Down
Original file line number Diff line number Diff line change
@@ -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<KeyboardEventInit> & { 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');
});

});
14 changes: 14 additions & 0 deletions lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
6 changes: 6 additions & 0 deletions lib/src/lib/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}$ `;
29 changes: 0 additions & 29 deletions lib/src/lib/platform/fake-scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading