Skip to content

Commit 7ecff13

Browse files
authored
Improve copy-paste portion of the tutorial (#49)
2 parents 0085c67 + 1241edf commit 7ecff13

14 files changed

Lines changed: 702 additions & 101 deletions

File tree

docs/specs/tutorial.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ Three browser-side pieces in `website/src/lib/`, mirroring the pattern in `websi
1515

1616
- `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.
1717
- `<main>` is a flex container so Wall's `flex-1 min-h-0` root gets a real height.
18-
- `Wall` runs `FakePtyAdapter` with `initialMode="passthrough"` and three initial panes:
19-
- **`tut-main`** (left, ~50%) — auto-launches `TutRunner` on mount via `mainShell.runCommand("tut")`.
20-
- **`tut-target`** (right-top, ~25%) — `SCENARIO_SHELL_PROMPT`. Used as the demo pane for keyboard-nav and alert sections.
21-
- **`tut-boxed`** (right-bottom, ~25%) — `SCENARIO_BOXED_PARAGRAPH`. The boxed paragraph for Copy Rewrapped vs Copy Raw.
22-
- The two right-side panes are added in `onApiReady` with `position: { referencePanel, direction }` after Wall creates the initial main pane.
18+
- `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):
19+
- **Desktop (≥ 768px)** — three panes:
20+
- **`tut-main`** (left, ~50%) — auto-launches `TutRunner` via `mainShell.runCommand("tut")`.
21+
- **`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.
22+
- **`tut-splash`** (right-bottom, ~25%) — titled "ascii-splash". Auto-launches `AsciiSplashRunner` via `splashShell.runCommand("ascii-splash")`.
23+
- **Phone (< 768px)** — two stacked panes; the changelog is dropped because the screen is too narrow to host it usefully:
24+
- **`tut-main`** (top, ~50%) — same as desktop.
25+
- **`tut-splash`** (bottom, ~50%) — same as desktop.
26+
- Side panes are added in `onApiReady` with `position: { referencePanel, direction }` after Wall creates the initial main pane.
2327

2428
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`.
2529

@@ -64,7 +68,7 @@ The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, t
6468

6569
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:
6670

67-
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.
71+
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.
6872
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.
6973

7074
### Section 3 — Copy paste (4 items)
@@ -75,21 +79,22 @@ The detector subscribes to `subscribeToMouseSelection()` and tracks per-id trans
7579
|---|---|---|
7680
| `cp-select` | Drag-select text in any pane | `selection` transitions `null → non-null` |
7781
| `cp-raw` | Click Copy Raw | `copyFlash` transitions to `'raw'` (set by `flashCopy()` after the popup button fires) |
78-
| `cp-rewrap` | Click Copy Rewrapped on the boxed paragraph | `copyFlash` transitions to `'rewrapped'` |
82+
| `cp-rewrap` | Click Copy Rewrapped on wrapped text in the changelog pane | `copyFlash` transitions to `'rewrapped'` |
7983
| `cp-override` | Run `ascii-splash`, then click its cursor icon | `override` transitions `'off' → 'temporary' \| 'permanent'` |
8084

8185
Prose:
8286
- "Some programs trap the mouse — the cursor icon lets you override."
8387
- "`ascii-splash` redraws every frame, so it cancels selections: looks cool, undragable."
8488

85-
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.
89+
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.
90+
91+
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).
8692

8793
## Lib changes added for this tutorial
8894

8995
- **`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`.
9096
- **`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.
9197
- **`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.
92-
- **`SCENARIO_BOXED_PARAGRAPH`** — boxed multi-line prose, used by `tut-boxed`.
9398

9499
`SCENARIO_TUTORIAL_MOTD` was removed — the runner now owns the main pane's screen.
95100

@@ -115,7 +120,7 @@ The picker restores the persisted active theme on mount. The playground header i
115120

116121
## Mouse and Clipboard Feature Coverage
117122

118-
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.
123+
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.
119124

120125
Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable.
121126

@@ -130,7 +135,7 @@ Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable.
130135
| §3.6 | Keyboard routing during drag || `ascii-splash` reacts to keys and mouse; with override active, drag-time keyboard consumption is observable. |
131136
| §3.7 | Popup on mouse-up, new-drag-replaces || Any selection. |
132137
| §4.1.1 | Copy Raw || Any selection. |
133-
| §4.1.2 | Copy Rewrapped (box-strip + paragraph unwrap) || `SCENARIO_BOXED_PARAGRAPH` provides a boxed paragraph in `tut-boxed`. |
138+
| §4.1.2 | Copy Rewrapped (paragraph unwrap) || `ChangelogRunner` in `tut-boxed` renders wrapped item lines that exercise the rewrap path. |
134139
| §4.2 | Cmd+C / Cmd+Shift+C || Any selection. |
135140
| §4.3 | Esc / click-outside dismiss || Any selection popup. |
136141
| §5 | Smart-extension (URL / abs path / rel path / Windows path / error location) || No matching tokens in the scenarios. |
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { describe, expect, it, vi } from 'vitest';
5+
import { handleMouseSelectionKeys } from './handle-mouse-selection-keys';
6+
import type { WallKeyboardCtx } from './types';
7+
8+
vi.mock('../../../lib/clipboard', () => ({
9+
copyRaw: vi.fn(),
10+
copyRewrapped: vi.fn(),
11+
doPaste: vi.fn(),
12+
}));
13+
vi.mock('../../../lib/platform', () => ({ IS_MAC: true }));
14+
15+
function makeCtx(): WallKeyboardCtx {
16+
return {
17+
selectedIdRef: { current: 'pane-a' },
18+
} as unknown as WallKeyboardCtx;
19+
}
20+
21+
function fakeEvent(target: HTMLElement, init: Partial<KeyboardEventInit> & { key: string }): KeyboardEvent {
22+
const e = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
23+
Object.defineProperty(e, 'target', { value: target });
24+
return e;
25+
}
26+
27+
describe('handleMouseSelectionKeys', () => {
28+
it('does not intercept Cmd+V on a non-xterm textarea', async () => {
29+
const { doPaste } = await import('../../../lib/clipboard');
30+
const ta = document.createElement('textarea');
31+
document.body.appendChild(ta);
32+
const e = fakeEvent(ta, { key: 'v', metaKey: true });
33+
34+
const handled = handleMouseSelectionKeys(e, makeCtx());
35+
36+
expect(handled).toBe(false);
37+
expect(e.defaultPrevented).toBe(false);
38+
expect(doPaste).not.toHaveBeenCalled();
39+
});
40+
41+
it('still intercepts Cmd+V on the xterm helper textarea', async () => {
42+
const { doPaste } = await import('../../../lib/clipboard');
43+
vi.mocked(doPaste).mockClear();
44+
const ta = document.createElement('textarea');
45+
ta.classList.add('xterm-helper-textarea');
46+
document.body.appendChild(ta);
47+
const e = fakeEvent(ta, { key: 'v', metaKey: true });
48+
49+
const handled = handleMouseSelectionKeys(e, makeCtx());
50+
51+
expect(handled).toBe(true);
52+
expect(e.defaultPrevented).toBe(true);
53+
expect(doPaste).toHaveBeenCalledWith('pane-a');
54+
});
55+
56+
});

lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ import type { WallKeyboardCtx } from './types';
1313
* Cmd-C / Cmd-Shift-C / Cmd-V outside drag. Returns true if handled.
1414
*/
1515
export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx): boolean {
16+
// Don't shadow native clipboard ops when focus is inside a real text
17+
// input (overlay modal, search box, etc.) — let the browser handle
18+
// copy/paste there. Xterm's hidden helper textarea is the input proxy
19+
// for the terminal itself, so we keep intercepting its keydowns.
20+
const tgt = e.target as HTMLElement | null;
21+
if (
22+
tgt &&
23+
(tgt.tagName === 'INPUT' ||
24+
(tgt.tagName === 'TEXTAREA' && !tgt.classList.contains('xterm-helper-textarea')) ||
25+
tgt.isContentEditable)
26+
) {
27+
return false;
28+
}
29+
1630
const sid = ctx.selectedIdRef.current;
1731
if (!sid) return false;
1832

lib/src/lib/ansi.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export const fg = (code: number): string => `${ESC}${code}m`;
2121
export const ENTER_ALT_SCREEN = `${ESC}?1049h${CLEAR_SCREEN}${CURSOR_HOME}${ESC}?25l`;
2222
export const LEAVE_ALT_SCREEN = `${CLEAR_SCREEN}${CURSOR_HOME}${ESC}?25h${ESC}?1049l`;
2323

24+
// SGR mouse-reporting toggles. xterm parses these and the wall's
25+
// mouse-mode-observer flips the cursor-icon override on/off so the user
26+
// knows MouseTerm is "trapping the mouse" while the program runs.
27+
export const MOUSE_ENABLE = `${ESC}?1000h${ESC}?1002h${ESC}?1003h${ESC}?1006h`;
28+
export const MOUSE_DISABLE = `${ESC}?1003l${ESC}?1002l${ESC}?1000l${ESC}?1006l`;
29+
2430
// Stylized `user@mouseterm:~$ ` prompt used by the playground shell and
2531
// by canned scenarios so they look the same.
2632
export const PROMPT = `${fg(32)}user${RESET}@${fg(36)}mouseterm${RESET}:${BOLD}${fg(34)}~${RESET}$ `;

lib/src/lib/platform/fake-scenarios.ts

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -139,35 +139,6 @@ export const SCENARIO_LONG_RUNNING: FakeScenario = {
139139
endsWithPrompt: true,
140140
};
141141

142-
/**
143-
* Boxed paragraph for Copy Rewrapped vs Copy Raw demonstration. The frame
144-
* is pure box-drawing characters so `rewrap.ts` strips them; the text
145-
* inside wraps across lines so Rewrapped joins them with single spaces.
146-
*/
147-
export const SCENARIO_BOXED_PARAGRAPH: FakeScenario = {
148-
name: 'boxed-paragraph',
149-
chunks: [
150-
instant(
151-
[
152-
'',
153-
`${fg(36)}┌─────────────────────────────────────────┐${RESET}`,
154-
`${fg(36)}${RESET} ${BOLD}Release notes — v1.4.0${RESET} ${fg(36)}${RESET}`,
155-
`${fg(36)}├─────────────────────────────────────────┤${RESET}`,
156-
`${fg(36)}${RESET} MouseTerm now keeps a tab visible ${fg(36)}${RESET}`,
157-
`${fg(36)}${RESET} even while a long-running command is ${fg(36)}${RESET}`,
158-
`${fg(36)}${RESET} hidden in the baseboard, so background ${fg(36)}${RESET}`,
159-
`${fg(36)}${RESET} work never gets lost. ${fg(36)}${RESET}`,
160-
`${fg(36)}${RESET} ${fg(36)}${RESET}`,
161-
`${fg(36)}${RESET} Drag-select the paragraph above and ${fg(36)}${RESET}`,
162-
`${fg(36)}${RESET} try Copy Raw vs Copy Rewrapped. ${fg(36)}${RESET}`,
163-
`${fg(36)}└─────────────────────────────────────────┘${RESET}`,
164-
'',
165-
].join('\r\n'),
166-
400,
167-
),
168-
],
169-
};
170-
171142
/** Rapid output burst — tests xterm.js scroll performance. */
172143
export const SCENARIO_FAST_OUTPUT: FakeScenario = {
173144
name: 'fast-output',

0 commit comments

Comments
 (0)