Skip to content

New tutorial in the playground#46

Merged
nedtwigg merged 54 commits intomainfrom
new-tut
May 6, 2026
Merged

New tutorial in the playground#46
nedtwigg merged 54 commits intomainfrom
new-tut

Conversation

@nedtwigg
Copy link
Copy Markdown
Member

@nedtwigg nedtwigg commented May 6, 2026

No description provided.

nedtwigg and others added 30 commits May 5, 2026 14:27
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…alert

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) <noreply@anthropic.com>
…stead

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
nedtwigg and others added 24 commits May 5, 2026 18:36
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.
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.
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.
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.
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.
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.
@nedtwigg nedtwigg merged commit 7062deb into main May 6, 2026
6 checks passed
@nedtwigg nedtwigg deleted the new-tut branch May 6, 2026 06:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant