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
2 changes: 1 addition & 1 deletion docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ User-pin titles must not start with `<idle>` (the sentinel that prefixes the aut

Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on React mount and `unmountElement(id)` on React unmount. The session (xterm.js instance, PTY, DOM element) persists in the registry across mount/unmount cycles — the DOM element is detached from its container but the Registry entry stays `Mounted`.

- **Create**: `getOrCreateTerminal` spawns xterm.js + FitAddon + PTY, returns existing if already created
- **Create**: `getOrCreateTerminal` spawns xterm.js + UnicodeGraphemesAddon + FitAddon + PTY, returns existing if already created. The xterm instance sets `allowProposedApi: true` because UnicodeGraphemesAddon activates through xterm's proposed Unicode API.
- **Resume**: `resumeTerminal` creates xterm entry and writes replay data without spawning a new PTY. Used when the webview is recreated while the host retains Live PTYs (Link: Severed → Resuming → Live).
- **Restore**: `restoreTerminal` creates xterm entry and spawns a new PTY with saved cwd and scrollback. Used on cold start from a saved Snapshot (Link: Cold → Live).
- **Untouched**: new `getOrCreateTerminal` sessions start untouched. `isUntouched(id)` exposes the flag, and user-originated PTY input clears it via the registry input paths. Resume/restore seed the persisted flag; missing legacy snapshot data defaults to touched (`false`) so close confirmation remains conservative.
Expand Down
12 changes: 10 additions & 2 deletions docs/specs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `dormouse-lib/lib/terminal-registry`, and the `subscribeToMouseSelection` store from `dormouse-lib/lib/mouse-selection`.
- **`tutorial-state.ts`** (`TutorialState`) — single in-memory progress store, persisted as a JSON array of completed item ids under the `dormouse-tut-v3` localStorage key.
- **`tutorial-state.ts`** (`TutorialState`) — single in-memory progress store, persisted as a JSON array of completed item ids under the `dormouse-tut-v3` localStorage key. The top-level GitHub star prompt persists separately under `dormouse-tut-star-v1`.
- **`tut-items.ts`** — section + item definitions (titles, hints) shared by runner and detector. Item ids are stable; they are the localStorage key suffixes.

## Layout
Expand All @@ -29,7 +29,13 @@ Every playground pane gets a `TutorialShell` input handler through `PlaygroundSh

## Tutorial Sections

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:
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. The menu helper below `Dormouse Playground Tutorial` shows only navigation shortcuts, not overall completion.

The top-level menu also includes `Starred on GitHub`, which sits directly below `Copy paste` without a blank spacer, and shows `[not yet]` until selected and `[thanks ⭐]` after it has been resolved. Pressing Enter on that row calls `onOpenGithub`, which `/playground` and the mobile tether page wire to `window.open("https://github.com/diffplug/dormouse", "_blank", "noopener,noreferrer")`.

After `Starred on GitHub`, the top-level menu shows the mystery row. It is `🐭 ??? 🐭` with `[LOCKED N/M]` while any section task is incomplete. `N/M` is computed from section checklist items only; `Starred on GitHub` and the mystery row do not count. When all section tasks are complete, the row becomes `🐭 Flappy Term 🐭` with a `[High score: N]` readout. Pressing Enter on the unlocked row opens Flappy Term, a runner-local mini-game: `Space`/`Up`/`Enter` flaps the bird, scoring persists as the high score, and `Esc` returns to the top-level menu. On the game-over screen, `Enter` restarts and `p` calls `onOpenPocket`, which `/playground` and the mobile tether page wire to `window.open("https://dormouse.sh/pocket", "_blank", "noopener,noreferrer")`.

Inside a section, items render as one of:

- `✓` (green) — complete
- `●` (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.
Expand Down Expand Up @@ -101,6 +107,8 @@ While the Copy paste section is open, pressing `p` toggles the **Place To Paste*
## Storage

- Completion: `localStorage["dormouse-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.
- GitHub star prompt: `localStorage["dormouse-tut-star-v1"] = "true"` after the user selects `Starred on GitHub`. Removed on `TutorialState.reset()`.
- Flappy Term high score: `localStorage["dormouse-flappy-high-v1"] = String(score)` after each new high score. Removed on `TutorialState.reset()`.
- Legacy keys `dormouse-tutorial-step-N` and `dormouse-tut-v2-*` from previous designs are not read; new playground sessions get a fresh start.

## Theme Picker
Expand Down
1 change: 1 addition & 0 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@xterm/addon-fit": "0.12.0-beta.219",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.219",
"@xterm/xterm": "6.1.0-beta.219",
"clsx": "^2.1.1",
"dockview-react": "^5.1.0",
Expand Down
5 changes: 5 additions & 0 deletions lib/src/lib/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ vi.mock('./mouse-selection', () => ({
getMouseSelectionState: () => ({ bracketedPaste: false }),
}));

vi.mock('./terminal-registry', () => ({
getTerminalInstance: () => null,
markSessionTouched: vi.fn(),
}));

import { doPaste } from './clipboard';

describe('doPaste three-tier fallthrough', () => {
Expand Down
3 changes: 3 additions & 0 deletions lib/src/lib/terminal-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Terminal, type IBufferRange } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes';
import { getPlatform } from './platform';
import { requestExternalLinkConfirmation } from './external-link-confirmation';
import { attachMouseModeObserver } from './mouse-mode-observer';
Expand Down Expand Up @@ -73,6 +74,7 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi

const theme = getTerminalTheme();
const terminal = new Terminal({
allowProposedApi: true,
fontSize: editorFontSize,
fontFamily: editorFontFamily,
cursorBlink: true,
Expand All @@ -88,6 +90,7 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi
},
});

terminal.loadAddon(new UnicodeGraphemesAddon());
const fit = new FitAddon();
terminal.loadAddon(fit);

Expand Down
6 changes: 6 additions & 0 deletions lib/src/lib/terminal-registry.alert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ vi.mock('@xterm/addon-fit', () => {
return { FitAddon };
});

vi.mock('@xterm/addon-unicode-graphemes', () => {
class UnicodeGraphemesAddon {}

return { UnicodeGraphemesAddon };
});

vi.mock('@xterm/xterm', () => {
class MockTerminal {
writes: string[] = [];
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions standalone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-updater": "^2.10.1",
"@xterm/addon-fit": "0.12.0-beta.219",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.219",
"@xterm/xterm": "6.1.0-beta.219",
"dockview-react": "^5.1.0",
"dormouse-lib": "workspace:*",
Expand Down
7 changes: 7 additions & 0 deletions website/src/data/dependencies.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
"author": "The xterm.js authors",
"homepage": "https://github.com/xtermjs/xterm.js/tree/master#readme"
},
{
"name": "@xterm/addon-unicode-graphemes",
"version": "0.5.0-beta.219",
"license": "MIT",
"author": "The xterm.js authors",
"homepage": "https://github.com/xtermjs/xterm.js/tree/master#readme"
},
{
"name": "@xterm/xterm",
"version": "6.1.0-beta.219",
Expand Down
4 changes: 3 additions & 1 deletion website/src/lib/__snapshots__/tut-runner.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,13 @@ exports[`TutRunner snapshots > renders Keyboard navigation with all items incomp
exports[`TutRunner snapshots > renders the top-level menu 1`] = `
"
Dormouse Playground Tutorial
0/17 complete · Esc/q to exit · Enter to open · ↑↓ to navigate
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]
Starred on GitHub [not yet]
🐭 ??? 🐭 [LOCKED 0/17]

Reset progress

Expand Down
70 changes: 68 additions & 2 deletions website/src/lib/tut-runner.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter";
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: ItemId[] = []) {
function mountRunner(
completedIds: ItemId[] = [],
options: { onOpenGithub?: () => void } = {},
) {
const adapter = new FakePtyAdapter();
const id = "test-pane";
adapter.spawnPty(id);
Expand All @@ -25,6 +28,7 @@ function mountRunner(completedIds: ItemId[] = []) {
onExit: () => {
exitCount += 1;
},
onOpenGithub: options.onOpenGithub,
});
adapter.setInputHandler(id, (data) => runner.handleInput(data));
runner.start();
Expand Down Expand Up @@ -90,4 +94,66 @@ describe("TutRunner snapshots", () => {
expect(exitCount()).toBe(1);
dispose();
});

it("opens GitHub and resolves the star prompt from the menu", () => {
const onOpenGithub = vi.fn();
const { state, sendKeys, lastFrame, dispose } = mountRunner([], { onOpenGithub });

sendKeys("\x1b[B\x1b[B\x1b[B\r");

expect(onOpenGithub).toHaveBeenCalledTimes(1);
expect(state.isStarPromptResolved()).toBe(true);
expect(lastFrame()).toContain("[thanks ⭐]");
expect(lastFrame()).not.toContain("[not yet]");
dispose();
});

it("clears the star prompt when reset progress is confirmed", () => {
const { state, sendKeys, dispose } = mountRunner(["kb-mode"]);
state.resolveStarPrompt();

sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\rreset\r");

expect(state.isComplete("kb-mode")).toBe(false);
expect(state.isStarPromptResolved()).toBe(false);
dispose();
});

it("keeps Flappy Term locked until every tutorial task is complete", () => {
const { sendKeys, lastFrame, dispose } = mountRunner();

sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r");

expect(lastFrame()).toContain("🐭 ??? 🐭");
expect(lastFrame()).toContain("[LOCKED 0/17]");
expect(lastFrame()).toContain("Dormouse Playground Tutorial");
dispose();
});

it("shows the unlocked Flappy Term entry with a high-score readout", () => {
const allItemIds = SECTIONS.flatMap((section) => section.items.map((i) => i.id));
const { state, sendKeys, lastFrame, dispose } = mountRunner(allItemIds);
state.recordFlappyScore(7);

// Navigate to (but don't enter) the Flappy Term row.
sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B");
expect(lastFrame()).toContain("🐭 Flappy Term 🐭");
expect(lastFrame()).toContain("[High score: 7]");
dispose();
});

it("opens Flappy Term, shows the start hint, and exits back to the menu", () => {
const allItemIds = SECTIONS.flatMap((section) => section.items.map((i) => i.id));
const { sendKeys, lastFrame, dispose } = mountRunner(allItemIds);

sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r");
const frame = lastFrame();
expect(frame).toContain("Score: 0");
expect(frame).toContain("Best:");
expect(frame).toContain("Space / Up to flap");

sendKeys("\x1b");
expect(lastFrame()).toContain("Dormouse Playground Tutorial");
dispose();
});
});
Loading