From b69acb71fb1fc7b5e043c921dd63e83b596b8adf Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 09:03:23 -0700 Subject: [PATCH 01/36] feat(website): reposition homepage around the phone Promote Dormouse Pocket from a roadmap footnote to the second act of the pitch. Homepage: - Hero tagline "for mice" -> "for mice and thumbs". - Merge the tmux/layout and distribution sections into one lead section headed "VS Code or Standalone"; the tmux line survives as body copy. - Promote push notifications to section two, rewritten to carry the whole loop rather than half of it: unattended-ring push, thumb-drag gesture reply, and self-hosting. Replaces the completion-detection section. - Add "Terminals that know their ports" for the pane port listing. - Retitle the copy/paste section and rewrite it around selection, Copy Raw/Rewrapped, and smart extension. - Drop the signup form; it still ships on /playground/pocket. Copy corrections: - Pocket relays over a WebSocket, not WebRTC. - Remove pricing and launch-discount claims for an unreleased product. - Replace retired sleep/wake vocabulary with minimize/reattach. Known gaps: /playground/pocket still carries the WebRTC and pricing copy, the ports section has no video, and video-alert.mp4 is now unreferenced. Co-Authored-By: Claude Opus 5 (1M context) --- website/src/pages/Home.tsx | 177 +++++++++++++++++++++++++------------ 1 file changed, 120 insertions(+), 57 deletions(-) diff --git a/website/src/pages/Home.tsx b/website/src/pages/Home.tsx index 89f69b9d2..2951998eb 100644 --- a/website/src/pages/Home.tsx +++ b/website/src/pages/Home.tsx @@ -13,7 +13,6 @@ import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type import SiteHeader from "../components/SiteHeader"; import posterUrl from "../assets/video-climb-blink-and-stare.webp"; import videoUrl from "../assets/video-climb-blink-and-stare.mp4"; -import alertVideoUrl from "../assets/video-alert.mp4"; import copyPasteVideoUrl from "../assets/video-copy-paste.mp4"; import tmuxVideoUrl from "../assets/video-tmux.mp4"; import visualStudioIconUrl from "../assets/visual-studio-icon.svg"; @@ -21,7 +20,6 @@ import tinyIconUrl from "../assets/icon-tiny-dark.png"; import phoneMockupUrl from "../assets/phone-mockup.webp"; import standaloneLatest from "@standalone-latest"; import { prefersReducedMotion } from "dormouse-lib/lib/ui-geometry"; -import { NotifySignupForm } from "../components/NotifySignupForm"; /** Multiplier on scroll required to drive the hero animation. * 1 = baseline, 2 = half as sensitive, 0.5 = twice as sensitive. */ @@ -759,7 +757,7 @@ export default function Home() { terminal - for mice + for mice and thumbs

- {/* Section 1: narrow text over a full-width video — lead with the tmux story */} -

+ {/* Section 1: distribution + layout — one terminal, two homes */} +
-

Soft as a mouse, sharp as a tmux

+

VS Code or Standalone

- Upgrade your VS Code or native terminal with a flexible multipane - layout. Sleep the tasks you're not watching down to a compact - status indicator. + One terminal, two homes. Inside VS Code it borrows your theme and + your keybindings — it should be hard to tell it isn't built in. + Outside, it's a native app that starts in a blink. Same layout, + same alerts, same everything — pick whichever one matches the day. +

+
+
+ +
+
VS Code
+
and its forks
+
+
+
+ +
+
Standalone
+
Mac, Windows, Linux
+
+
+
+

+ Soft as a mouse, sharp as a tmux. A real tiling layout for the + terminals you actually run at once — minimize the ones you're not + watching down to a compact status indicator, and they keep + running, and keep reporting.

Do it all with the mouse, or keep your hands on the keyboard with - tmux keybinds. + tmux keybinds. Same prefix, same splits, same pane navigation — + nothing you already know gets taken away.

- {/* Section 2: image left, text right */} -
- +
+ Dormouse Pocket running on a phone
-

Stop watching terminals spin

+

+ Push notifications you can self-host +

+

+ Your agent hits a permission prompt four minutes after you leave, + then sits there until you get back. Dormouse already knows that + pane is asking for a human — so it buzzes your phone. A real push + notification, delivered by Apple or Google, to an app that's + completely closed. +

+

+ Then you answer it. Tap the terminal and a radial menu opens under + your thumb: drag down-right for{" "} + y, + up-right for{" "} + n, + or out to Esc, Ctrl+C, and a quit menu. One drag and the agent's + moving again. No keyboard, no squinting. +

+

+ The relay is one Node process. No database — state is JSON on + disk, and push keys mint themselves on first boot. Put{" "} + tailscale serve{" "} + in front of it and you're done: no account, no cloud dependency, + nothing anyone can shut off. Your laptop decides which phones get + notified — the server isn't allowed to choose for it. +

+

+ Dormouse Pocket{" "} + is in development — try the phone interface in your browser, and + sign up there to hear when it's ready. +

+
+
+ + {/* Section 4: port awareness — text left, context-menu mock right */} +
+
+

Terminals that know their ports

- Dormouse tracks activity the same way you do — visual motion. When a - pane stops changing for two seconds, it marks the task complete and - alerts you. + Six panes running and something's serving{" "} + :3000. + Which one?

- Works with any CLI tool that prints to a terminal — no plugins, no - configuration. Also supports{" "} - BEL{" "} - and{" "} - OSC 9/99/777{" "} - for native TUI integration. + Right-click a pane and Dormouse lists the ports that pane's + process tree is actually listening on — hit a number to open one. + No{" "} + lsof, + no scrolling back to find where Vite printed the URL.

+
+
+ + web — pnpm dev +
+
+ {[ + { key: "1", port: "3000", label: "vite" }, + { key: "2", port: "24678", label: "vite hmr" }, + { key: "3", port: "5432", label: "postgres" }, + ].map(({ key, port, label }) => ( +
+ {key} + localhost:{port} + {label} +
+ ))} +
+
{/* Section 3: text left, image right */}
-

Newlines and copy paste like you meant

+

Select and copy-paste like you meant

- You're used to{" "} - Shift+Enter{" "} - for a newline in the browser — but it's broken in your terminal? - Not anymore. Dormouse works the way you'd expect, no arcane - terminal knowledge required. + Click and drag in a "mouse conformant" terminal doesn't select + text; it fires escape code{" "} + {"\\e[<0;x;yM"}{" "} + at whatever's running. Dormouse notices when a TUI has grabbed the + mouse and hands you a one-click override, so you can just — select + the thing.

- Click and drag in a "mouse conformant" terminal doesn't select text; - it sends escape code{" "} - {"\\e[<0;x;yM"}. - Dormouse lets you copy-paste like a human, not a terminal. + Then copy it the way you meant it. Raw keeps + the hard wraps; Rewrapped joins them back + into the line the program actually printed. Tap{" "} + e{" "} + mid-drag to snap the selection out to the whole URL or file path.

@@ -835,7 +921,7 @@ export default function Home() {

Get Dormouse

-

A dormouse knows when to wake up. Multitasking terminal for mice.

+

A dormouse knows when to wake up. Multitasking terminal for mice and thumbs.

} @@ -928,29 +1014,6 @@ export default function Home() {
-
- Dormouse Pocket running on a phone -
-

- Walk away. Keep going. -

-

- Coming next: Dormouse Pocket. - Tether a terminal session to your phone over WebRTC and take a stroll — Dormouse - buzzes your phone when something needs attention. A hosted auto-pairing service comes - later, so you can close the laptop and walk away, no setup dance. -

-

- Open source and free to self-host, or pay a small monthly fee for our hosted version. Early adopters get a launch discount. -

- -
-
-
Supply Chain From 56b8bb7aa5762b4c79dd607d4900478c8a757c2f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 10:26:52 -0700 Subject: [PATCH 02/36] feat(website): add browser-surface section, widen section one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Browsers for you (and your agents)" between the ports and copy-paste sections: a browser is a pane in the same tiling layout, and `dor ab open surface:N` aims one at the dev server a terminal owns — the same command an agent runs, so an agent opens a pane you are already watching. Grounded in docs/specs/dor-browser.md, so it claims screencast and pop-out but not iframe (agents cannot drive it), and says plainly that Dormouse drives your own agent-browser rather than shipping one. Illustrated with a CSS mock; there is no capture for it yet. Lay the first section's two paragraphs out in two columns and drop its max-w-4xl cap so the copy spans the same width as the video beneath it — previously the text sat 40px inset on each side. All four grid sections now share md:grid-cols-[2fr_3fr] so their column edges align down the page. Co-Authored-By: Claude Opus 5 (1M context) --- website/src/pages/Home.tsx | 95 ++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/website/src/pages/Home.tsx b/website/src/pages/Home.tsx index 2951998eb..ca44ebcaa 100644 --- a/website/src/pages/Home.tsx +++ b/website/src/pages/Home.tsx @@ -774,41 +774,20 @@ export default function Home() {
{/* Section 1: distribution + layout — one terminal, two homes */}
-
-

VS Code or Standalone

-

- One terminal, two homes. Inside VS Code it borrows your theme and - your keybindings — it should be hard to tell it isn't built in. - Outside, it's a native app that starts in a blink. Same layout, - same alerts, same everything — pick whichever one matches the day. -

-
-
- -
-
VS Code
-
and its forks
-
-
-
- -
-
Standalone
-
Mac, Windows, Linux
-
-
+
+

Tmux with browsers, for VS Code and Standalone

+
+

+ Soft as a mouse, sharp as a tmux. A real tiling layout for + terminals and browser embeds. + Do it all with the mouse, or keep your hands on the keyboard with + tmux keybinds. +

+

+ Inside VS Code it follows your theme exactly - hard to tell it isn't built in. + Standalone, it's a Tauri app that starts in a blink. Same features in both places. +

-

- Soft as a mouse, sharp as a tmux. A real tiling layout for the - terminals you actually run at once — minimize the ones you're not - watching down to a compact status indicator, and they keep - running, and keep reporting. -

-

- Do it all with the mouse, or keep your hands on the keyboard with - tmux keybinds. Same prefix, same splits, same pane navigation — - nothing you already know gets taken away. -

@@ -896,6 +875,54 @@ export default function Home() {
+ {/* Section 5: browser surfaces — mock left, text right */} +
+
+
+ + +
+ localhost:3000 +
+ + screencast + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Browsers for you (and your agents)

+

+ A browser is just another pane. Park your dev server next to the + terminal that's running it — same tiling layout, same keybinds, no + alt-tab and no second monitor. +

+

+ dor ab open surface:2{" "} + aims a browser at the port that terminal is serving — the one from + the section above. Your agents run the same command, so when an + agent wants to see what it just built, it opens a pane you're + already watching. Pop it out to a real OS window when you need the + real thing. +

+

+ Dormouse drives the{" "} + agent-browser{" "} + you already have installed — it doesn't ship a browser of its own. +

+
+
+ {/* Section 3: text left, image right */}
From 1bbff379da805533ad5e5e3f374286739a28aee9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 13:27:20 -0700 Subject: [PATCH 03/36] docs(vscode-ext): make the README the canonical product guide Phase 1 of the website-docs scope. Restructure vscode-ext/README.md into the host-neutral guide that will render at /docs, on the Marketplace, and on Open VSX, and correct what it claimed. Accuracy fixes, all verified against the owning specs: - The alert section claimed "when a pane stops changing for two seconds, it marks the task complete... works with any CLI tool, no plugins or configuration." alert.md is explicit that WATCHING "applies only to command names the user explicitly asked for" with "no process heuristics", and the window is the configurable T_USER_ATTENTION, not two seconds. Replaced with the three real tracks: protocol notifications and command-exit need no setup, quiet-detection is an opt-in rule on a command name. Documents the OSC 633/133 shell integration requirement that watching depends on. - The mode-switch gesture listed LCtrl -> RCtrl. handle-dual-tap.ts detects Meta or Shift only, so that never worked -- and it was the variant Windows and Linux readers would have reached for first. - Cmd+Arrow was described as "swap pane positions"; it swaps terminal sessions between panes. Added the missing a / t / > bindings. Adds the Get Dormouse, Browsers, Themes, and Automation sections the scope calls for, and removes all five TODO: placeholders, which the planned public-doc lint rejects. package.json: add the bugs.url the listing contract requires, and reword description so it does not imply zero-config alerting for agents. Co-Authored-By: Claude Opus 5 (1M context) --- vscode-ext/README.md | 159 ++++++++++++++++++++++++++++------------ vscode-ext/package.json | 5 +- 2 files changed, 116 insertions(+), 48 deletions(-) diff --git a/vscode-ext/README.md b/vscode-ext/README.md index d0547bca5..7613077ff 100644 --- a/vscode-ext/README.md +++ b/vscode-ext/README.md @@ -1,85 +1,150 @@ # Dormouse -Terminal multiplexer for VS Code (or [standalone app](https://dormouse.sh/#download)) — tmux keybindings, mouse support, human-friendly copy-paste, and alerts for completed tasks. +A multitasking terminal for VS Code and the desktop — a real tiling layout, tmux keybindings, full mouse support, browser panes your agents can drive, and alerts that tell you when something needs you. -[dormouse.sh/playground](https://dormouse.sh/playground) — try before you install +![Dormouse running a tiling terminal layout](https://dormouse.sh/og-image.jpg) -TODO: Hero GIF. +[dormouse.sh/playground](https://dormouse.sh/playground) — try the real thing in your browser, nothing to install. -## Alert System +## Get Dormouse -Dormouse tracks activity the same way you do — visual motion. When a pane stops changing for two seconds, it marks the task complete and alerts you. Works with any CLI tool that prints to a terminal, no plugins or configuration. +- **VS Code** — install from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=diffplug.dormouse) or [Open VSX](https://open-vsx.org/extension/diffplug/dormouse). Also works in Cursor, Windsurf, and other VS Code forks. +- **Standalone** — self-updating installers for macOS, Windows, and Linux at [dormouse.sh](https://dormouse.sh/#download). +- **Playground** — [dormouse.sh/playground](https://dormouse.sh/playground) runs the real interface in your browser. -- todo-disabled alerts disabled -- todo-enabled alerts enabled -- todo-armed task is running, will send an alert when task completes -- todo-ringing task is finished and needs your attention +Both hosts run the same terminal, the same layout engine, and the same alerts. -When you click a task that was ringing, it adds a TODO next to the terminal's title. This TODO will remain until you hit `Enter` in that terminal, or until you explicitly dismiss the TODO by clicking it or typing `t` in command mode. +## Layout and panes -This lightweight TODO system remembers which tasks need follow-up so you don't have to. +Run builds, agents, servers, and scripts side by side. Split, resize, swap, and zoom panes with the mouse or the keyboard. -## Mouse-Friendly Copy and Paste +Minimize the panes you aren't watching down to a compact status indicator on the baseboard — a **door**. A minimized pane keeps running and keeps reporting, so a door still shows you when its task needs attention. Reattach it and you are back where you left off. -When you copy-paste from a terminal, you are usually stuck with a bunch of newlines that you wouldn't get if you were copying from any other kind of program. Dormouse can optionally remove these with `Copy Rewrapped`. +You can spawn and rearrange everything using any of: -copy-paste +- default tmux shortcuts +- Dormouse's own shortcuts +- the mouse -For TUIs which register for xterm mouse interception (such as `htop` and `neovim`), most terminals make it impossible for you to copy using the mouse. Dormouse makes it easy to temporarily override the mouse interception. +## Alerts and TODOs -TODO: GIF showing htop and the override mechanism +Dormouse can owe you attention in three independent ways. Two of them need no setup at all. -## Tiling Layout with Minimize +**A program asks for you.** Dormouse understands the standard terminal notification and progress protocols — `BEL`, `OSC 9`, `OSC 9;4`, `OSC 99`, and `OSC 777`. Any tool that already signals completion or progress rings its pane, with no configuration. -Run builds, agents, servers, and scripts side by side. Minimize the ones you're not watching to a compact status indicator — every pane keeps running and every alert still fires whether minimized or not. +**A command finishes while you are away.** If a foreground command was running while you were watching, you left, and it exited after you had been gone a while, that pane is ringing when you come back. Also no configuration. -You can spawn, layout, and relayout everything in the terminal using any of: +**A watched command goes quiet.** This one is opt-in, and it is the one for coding agents. Click the bell in a pane running `claude` — or press `a` in command mode — and Dormouse watches *that command name*. Every pane running `claude` is then watched, the ones open now and the ones you open later. When a watched command's output goes busy and then falls quiet while you are not looking, it rings. -- default tmux shortcuts -- intuitive modern shortcuts -- the mouse +Dormouse never guesses which commands deserve an alert. Watching is a rule you create on a command name, and turning it off anywhere removes it everywhere. + +- todo-disabled no watch rule for this pane's command +- todo-enabled this command is watched +- todo-armed a watched command is running; it will alert when it goes quiet +- todo-ringing finished, and it needs your attention + +Whichever way a pane rings, the ring becomes a **TODO** — a marker beside the pane's title that outlives the alert, so a ring you dismissed does not disappear without a trace. Clear it by clicking it or pressing `t` in command mode. + +Watching a command's output requires shell integration (`OSC 633` / `OSC 133`) so Dormouse can tell where one command ends and the next begins. Shells that do not report command boundaries — `cmd.exe`, `fish`, or any shell where the integration did not take — never engage watching. The protocol and command-exit alerts above work regardless. + +## Browsers for you and your agents + +A browser is just another pane. Put your dev server next to the terminal running it, in the same tiling layout. + +``` +dor ab open surface:2 +``` + +That aims a browser pane at the port a terminal surface is serving. Your agents run the same command, so when an agent wants to look at what it just built, it opens a pane you are already watching. + +Browser panes render three ways: a live Chromium stream inside the pane, popped out to a real OS window when you need the genuine article, or a lightweight proxied iframe. Dormouse is a client for the `agent-browser` you already have installed — it does not ship a browser of its own. + +See [`/docs/dor#agent-browser`](https://dormouse.sh/docs/dor#agent-browser) for the full command reference. -TODO: layout GIF +## Mouse, selection, and copy/paste -## Keyboard Shortcuts +Click and drag in most terminals does not select text — it fires a mouse escape sequence at whatever is running. Dormouse notices when a TUI such as `htop` or `neovim` has grabbed the mouse and gives you a one-click override, so you can select the thing. -If you use the mouse, then Dormouse is always in **passthrough** mode, where all keypresses passthrough to the selected terminal. If you press `LShift` followed by `RShift` in quick succession (or `LCmd → RCmd`, or `LCtrl → RCtrl`), then you will enter **command** mode where keypresses can spawn terminals, navigate panes, and rearrange the layout. +Then copy it the way you meant it: -### Command Mode Shortcuts +- **Copy Raw** keeps the hard wraps exactly as the terminal drew them. +- **Copy Rewrapped** joins those wrapped lines back into the line the program actually printed. +![Copy Rewrapped joining hard-wrapped terminal output back into one line](https://github.com/user-attachments/assets/8e905ba1-a8b7-43c5-86af-e7dfd84f04db) + +Hold `Alt` while dragging to toggle between block and linewise selection, and press `e` mid-drag to extend the selection out to the whole URL or file path. + +## Keyboard shortcuts + +Dormouse starts in **passthrough** mode, where every keypress goes to the selected terminal. Tap **left Shift then right Shift** within half a second to enter **command** mode, where keys drive the layout instead. Left Cmd then right Cmd works too (left Win / left Super on Windows and Linux). | Key | Action | |-----|--------| -| `Enter` | Return to **passthrough** mode | -| `\|` tmux `%` | Split left/right | -| `-` tmux `"` | Split top/bottom | -| Arrow keys | Navigate between panes | -| `Cmd+Arrow` | Swap pane positions | +| `Enter` | Return to passthrough mode | +| `\|` or tmux `%` | Split left/right | +| `-` or tmux `"` | Split top/bottom | +| Arrow keys | Move selection between panes | +| `Cmd`/`Ctrl` + arrows | Swap terminals between two panes | | `z` | Zoom / unzoom the selected pane | -| `m` tmux `d` | Minimize pane to baseboard | -| `k` tmux `x` | Kill pane | +| `m` or tmux `d` | Minimize pane to a door, or reattach one | +| `k` or tmux `x` | Kill pane (asks you to confirm) | | `,` | Rename pane | +| `a` | Toggle the alert rule for the running command | +| `t` | Toggle the TODO marker | +| `>` | Open the pane header menu, including bound ports | + +Copy and paste keep their usual bindings in both modes: `Cmd+C` / `Ctrl+C` copies raw, `Cmd+Shift+C` / `Ctrl+Shift+C` copies rewrapped, and `Cmd+V` / `Ctrl+V` pastes. On macOS, `Ctrl+C` still passes through to the running program. + +The complete table lives in [the keyboard shortcut reference](https://github.com/diffplug/dormouse/blob/main/docs/specs/shortcuts.md). + +## Themes and host integration + +Inside VS Code, Dormouse uses your VS Code theme — colors, styling, everything. Switch themes and Dormouse switches with you. No separate configuration and no mismatched colors. + +The standalone app ships the same theme system with its own picker, so a layout you like looks the same in both places. + +## Getting started + +### VS Code + +1. Install the extension. +2. Open the command palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). +3. Run one of: + - **Dormouse: Focus** — open Dormouse in the Panel area, next to the built-in terminal. + - **Dormouse: Open in Editor** — open Dormouse as an editor tab. You can open several. + - **Dormouse: New Terminal** — add a terminal to the active Dormouse. + - **Dormouse: Select Shell** — pick which shell new terminals launch. + +Dormouse works in the Panel area, the Editor area, or both at once. + +### Standalone -## Any Theme, Anywhere +1. Download an installer for your platform from [dormouse.sh](https://dormouse.sh/#download). +2. Launch it. The app updates itself, so there is nothing further to wire up. -Dormouse uses your VSCode theme — colors, styling, everything. Switch themes and Dormouse switches with you. No separate configuration, no mismatched colors. +## Automation and agents -TODO: GIF showing theme switching — user changes VSCode theme and Dormouse updates instantly to match +Every terminal Dormouse launches has `dor` on its `PATH` — a small CLI that talks to the Dormouse hosting it. It lets a script, or an agent, drive the layout: -You can also use Dormouse in the Panel area (bottom, next to the built-in terminal), in the Editor area (center region where you edit files), or both. +``` +dor list # what surfaces exist +dor ensure -- pnpm dev # make sure this is running, exactly once +dor split -- pnpm test # open a new terminal pane +dor ab open surface:2 # open a browser on that terminal's port +``` -TODO: GIF showing Dormouse in various areas +`dor ensure` is idempotent: run it twice and the second call reuses the pane already running that command in that directory instead of starting a second copy. -## Getting Started +Dormouse also bundles an agent skill describing all of this in the form agents expect. Run `dor skill` to print it, or `dor skill --install` to install it for the agent in your current project. -1. Install the extension -2. Open the command palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) - - **Dormouse: Focus** to open the "Panel" version of Dormouse (next to the terminal) - - **Dormouse: Open in Editor** to open a Dormouse tab in the content area (you can open multiple) +- [Complete CLI reference](https://dormouse.sh/docs/dor) +- [The bundled agent skill](https://dormouse.sh/docs/agent-skill) -## Links +## Help and project links -- Prefer a standalone terminal app? Self-updating installers available for Win, Mac and Linux at [dormouse.sh](https://dormouse.sh/#download) -- You can try it in a [browser playground](https://dormouse.sh/playground) -- [GitHub](https://github.com/diffplug/dormouse) +- [Documentation](https://dormouse.sh/docs) +- [Browser playground](https://dormouse.sh/playground) — no install required +- [Report an issue](https://github.com/diffplug/dormouse/issues) +- [Source on GitHub](https://github.com/diffplug/dormouse) +- [Supply chain](https://dormouse.sh/supply-chain) - Brought to you by [DiffPlug](https://www.diffplug.com/) diff --git a/vscode-ext/package.json b/vscode-ext/package.json index cea722b6e..d4e8c0017 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -1,7 +1,7 @@ { "name": "dormouse", "displayName": "Dormouse — Terminal Multiplexer", - "description": "A persistent multitasking terminal — tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", + "description": "A persistent multitasking terminal — a real tiling layout, tmux keybindings, mouse support, browser panes your agents can drive, and alerts when a task needs you.", "version": "1.1.0", "publisher": "diffplug", "license": "FSL-1.1-MIT", @@ -11,6 +11,9 @@ "type": "git", "url": "https://github.com/diffplug/dormouse" }, + "bugs": { + "url": "https://github.com/diffplug/dormouse/issues" + }, "engines": { "vscode": "^1.85.0" }, From b25489edf91b5e04e4febf4ff793bf3ede409a1a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 18 Aug 2026 13:42:05 -0700 Subject: [PATCH 04/36] feat(website): render the product guide at /docs Phase 2 of the website-docs scope. /docs is a web rendering of vscode-ext/README.md rather than separately authored product prose. The Markdown stack is in-repo with no new dependencies: - docs-parser.js supports a deliberate *subset* of CommonMark and throws UnsupportedMarkdownError on anything outside it. A third-party parser degrades gracefully on constructs it does not know; a hand-rolled one mangles them silently, so the subset boundary is a hard error that phase 4's lint turns into a build failure. - Raw HTML is rejected except a narrow allowlist (src/alt/width/ height/title, https only), since the guide's 22px alert-state icons need sizing and portable Markdown has none. - The slugger mirrors github-slugger, including replacing each space individually rather than collapsing runs, so an em dash between spaces yields a double hyphen exactly as on GitHub. That is what keeps /docs anchors identical to the same heading's anchor on GitHub. - Table cells honour backslash-escaped pipes, which the shortcut table needs for its `\|` split binding. generate-docs.js applies the /docs delta as an explicit table rather than regexes over prose. Today it holds one operation -- drop the document title, which the page shell supplies -- and it fails the build when a rule matches zero blocks or more than one. 18 parser tests cover the allowlist boundary, escaped pipes, nested and ordered lists, and slug dedupe. Generated data is gitignored like changelog.json and regenerated by predev/pretest/prebuild. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + website/package.json | 6 +- website/react-router.config.ts | 1 + website/scripts/docs-parser.js | 429 ++++++++++++++++++++ website/scripts/docs-parser.test.js | 118 ++++++ website/scripts/generate-docs.js | 125 ++++++ website/src/components/DocsLayout.tsx | 102 +++++ website/src/components/MarkdownDocument.tsx | 174 ++++++++ website/src/components/SiteHeader.tsx | 1 + website/src/pages/Docs.tsx | 32 ++ website/src/routes.ts | 1 + 11 files changed, 987 insertions(+), 3 deletions(-) create mode 100644 website/scripts/docs-parser.js create mode 100644 website/scripts/docs-parser.test.js create mode 100644 website/scripts/generate-docs.js create mode 100644 website/src/components/DocsLayout.tsx create mode 100644 website/src/components/MarkdownDocument.tsx create mode 100644 website/src/pages/Docs.tsx diff --git a/.gitignore b/.gitignore index fb00e6c55..a2d48a42d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ standalone/node_modules/ website/dist/ website/node_modules/ website/src/data/changelog.json +website/src/data/docs.json # OS .DS_Store diff --git a/website/package.json b/website/package.json index 784196564..1c60c18b4 100644 --- a/website/package.json +++ b/website/package.json @@ -6,12 +6,12 @@ "type": "module", "scripts": { "dev": "react-router dev", - "predev": "node scripts/generate-changelog.js", - "prebuild": "node scripts/generate-changelog.js", + "predev": "node scripts/generate-changelog.js && node scripts/generate-docs.js", + "prebuild": "node scripts/generate-changelog.js && node scripts/generate-docs.js", "build": "react-router build", "postbuild": "node scripts/flatten-react-router-build.js", "preview": "vite preview --outDir dist --mode test", - "pretest": "node scripts/generate-changelog.js", + "pretest": "node scripts/generate-changelog.js && node scripts/generate-docs.js", "test": "vitest run" }, "dependencies": { diff --git a/website/react-router.config.ts b/website/react-router.config.ts index 5fc5d71ea..3129fad42 100644 --- a/website/react-router.config.ts +++ b/website/react-router.config.ts @@ -12,6 +12,7 @@ export default { "/playground/pocket", "/pocket", "/changelog", + "/docs", "/supply-chain", ]; }, diff --git a/website/scripts/docs-parser.js b/website/scripts/docs-parser.js new file mode 100644 index 000000000..f4db6d4e8 --- /dev/null +++ b/website/scripts/docs-parser.js @@ -0,0 +1,429 @@ +/** + * Hand-rolled Markdown parser for the public docs pipeline. + * + * Deliberately supports a *subset* of CommonMark rather than pretending to be + * complete. A third-party parser degrades gracefully on constructs it does not + * know; a hand-rolled one silently mangles them. So anything outside the subset + * is a hard error (`UnsupportedMarkdownError`), and the public-doc lint turns + * that into a build failure instead of a rendering bug nobody notices. + * + * Raw HTML is rejected with one exception: a narrow `` allowlist, because + * the canonical README uses inline 22px alert-state icons and portable Markdown + * has no image-sizing syntax (docs/specs/website-docs.md -> /docs rendering + * contract). + * + * No dependencies, by design. Runs in Node during codegen and its output is + * plain JSON consumed by the website. + */ + +export class UnsupportedMarkdownError extends Error { + constructor(message, line) { + super(line == null ? message : `${message} (line ${line})`); + this.name = 'UnsupportedMarkdownError'; + this.line = line; + } +} + +/** Attributes an `` may carry. Everything else is rejected. */ +const IMG_ALLOWED_ATTRS = new Set(['src', 'alt', 'width', 'height', 'title']); + +// --------------------------------------------------------------------------- +// Slugger — mirrors github-slugger so /docs anchors match GitHub's own. +// --------------------------------------------------------------------------- + +export function slugify(text) { + return text + .trim() + .toLowerCase() + // Strip everything that is not alphanumeric, space, hyphen, or underscore. + // \p{L}/\p{N} keep non-ASCII headings usable. + .replace(/[^\p{L}\p{N} _-]+/gu, '') + // github-slugger replaces each space individually rather than collapsing + // runs, so "`dor list` - find" (em dash stripped from between two spaces) + // becomes "dor-list--find". Matching that exactly is what keeps /docs + // anchors identical to GitHub's for the same heading. + .replace(/ /g, '-'); +} + +export function createSlugger() { + const seen = new Map(); + return (text) => { + const base = slugify(text) || 'section'; + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + return n === 0 ? base : `${base}-${n}`; + }; +} + +// --------------------------------------------------------------------------- +// Inline parsing +// --------------------------------------------------------------------------- + +function parseImgTag(raw, line) { + const inner = raw.replace(/^$/, ''); + const attrs = {}; + const re = /([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*)"/g; + let m; + let consumed = 0; + while ((m = re.exec(inner))) { + const [full, name, value] = m; + const key = name.toLowerCase(); + if (!IMG_ALLOWED_ATTRS.has(key)) { + throw new UnsupportedMarkdownError(` attribute "${name}" is not allowed`, line); + } + attrs[key] = value; + consumed += full.length; + } + // Anything left over means unquoted or malformed attributes we did not parse. + const leftover = inner.replace(re, '').trim(); + if (leftover.length > 0) { + throw new UnsupportedMarkdownError(`could not parse attributes: "${leftover}"`, line); + } + if (!attrs.src) throw new UnsupportedMarkdownError(' requires a src', line); + if (!/^https:\/\//i.test(attrs.src)) { + throw new UnsupportedMarkdownError(` src must be https: "${attrs.src}"`, line); + } + void consumed; + return { type: 'image', src: attrs.src, alt: attrs.alt ?? '', width: attrs.width, height: attrs.height }; +} + +/** + * Parse inline content into a flat-ish node list. Emphasis may nest one level + * inside link text; anything deeper is not needed by our sources. + */ +export function parseInline(text, line) { + const nodes = []; + let buf = ''; + let i = 0; + + const flush = () => { + if (buf) { + nodes.push({ type: 'text', value: buf }); + buf = ''; + } + }; + + while (i < text.length) { + const ch = text[i]; + + // Backslash escape + if (ch === '\\' && i + 1 < text.length) { + buf += text[i + 1]; + i += 2; + continue; + } + + // Inline code — longest matching backtick run + if (ch === '`') { + let ticks = 0; + while (text[i + ticks] === '`') ticks++; + const fence = '`'.repeat(ticks); + const end = text.indexOf(fence, i + ticks); + if (end !== -1) { + flush(); + nodes.push({ type: 'code', value: text.slice(i + ticks, end) }); + i = end + ticks; + continue; + } + buf += ch; + i++; + continue; + } + + // Allowlisted inline + if (text.startsWith('', i); + if (end === -1) throw new UnsupportedMarkdownError('unterminated tag', line); + flush(); + nodes.push(parseImgTag(text.slice(i, end + 1), line)); + i = end + 1; + continue; + } + + // Any other raw HTML is rejected outright. + if (ch === '<' && /^<\/?[a-zA-Z]/.test(text.slice(i))) { + const tag = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(text.slice(i)); + throw new UnsupportedMarkdownError(`raw HTML <${tag ? tag[1] : '?'}> is not allowed`, line); + } + + // Image / link + if (ch === '!' && text[i + 1] === '[') { + const parsed = matchLink(text, i + 1); + if (parsed) { + flush(); + nodes.push({ type: 'image', src: parsed.href, alt: parsed.label, title: parsed.title }); + i = parsed.end; + continue; + } + } + if (ch === '[') { + const parsed = matchLink(text, i); + if (parsed) { + flush(); + nodes.push({ type: 'link', href: parsed.href, title: parsed.title, children: parseInline(parsed.label, line) }); + i = parsed.end; + continue; + } + } + + // Strong / emphasis + if (ch === '*' || ch === '_') { + const strong = ch + ch; + if (text.startsWith(strong, i)) { + const end = text.indexOf(strong, i + 2); + if (end !== -1) { + flush(); + nodes.push({ type: 'strong', children: parseInline(text.slice(i + 2, end), line) }); + i = end + 2; + continue; + } + } + const end = text.indexOf(ch, i + 1); + // Avoid treating snake_case as emphasis. + if (end !== -1 && !(ch === '_' && /\w/.test(text[i - 1] ?? ''))) { + flush(); + nodes.push({ type: 'em', children: parseInline(text.slice(i + 1, end), line) }); + i = end + 1; + continue; + } + } + + buf += ch; + i++; + } + + flush(); + return nodes; +} + +/** Match `[label](href "title")` starting at `start`. Returns null if not one. */ +function matchLink(text, start) { + if (text[start] !== '[') return null; + let depth = 0; + let i = start; + for (; i < text.length; i++) { + if (text[i] === '\\') { i++; continue; } + if (text[i] === '[') depth++; + else if (text[i] === ']') { + depth--; + if (depth === 0) break; + } + } + if (depth !== 0 || text[i + 1] !== '(') return null; + const label = text.slice(start + 1, i); + const close = text.indexOf(')', i + 2); + if (close === -1) return null; + const target = text.slice(i + 2, close).trim(); + const titleMatch = /^(\S+)\s+"([^"]*)"$/.exec(target); + return titleMatch + ? { label, href: titleMatch[1], title: titleMatch[2], end: close + 1 } + : { label, href: target, title: undefined, end: close + 1 }; +} + +// --------------------------------------------------------------------------- +// Block parsing +// --------------------------------------------------------------------------- + +const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; + +/** + * Split a table row into cells, honouring backslash-escaped pipes so a cell + * containing `` `\|` `` (which the shortcut table needs) survives intact. + */ +function splitRow(row, line) { + const trimmed = row.trim().replace(/^\|/, '').replace(/\|$/, ''); + const cells = []; + let cur = ''; + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]; + if (ch === '\\' && trimmed[i + 1] === '|') { cur += '|'; i++; continue; } + if (ch === '\\') { cur += ch + (trimmed[i + 1] ?? ''); i++; continue; } + if (ch === '|') { cells.push(cur); cur = ''; continue; } + cur += ch; + } + cells.push(cur); + return cells.map((c) => parseInline(c.trim(), line)); +} + +function isDelimiterRow(row) { + return /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(row) && row.includes('-'); +} + +function alignmentsFrom(row) { + return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => { + const s = c.trim(); + if (s.startsWith(':') && s.endsWith(':')) return 'center'; + if (s.endsWith(':')) return 'right'; + if (s.startsWith(':')) return 'left'; + return null; + }); +} + +/** + * Parse Markdown into a block node tree. + * + * @param {string} markdown + * @param {{ slug?: (text: string) => string }} [options] + */ +export function parseMarkdown(markdown, options = {}) { + const slug = options.slug ?? createSlugger(); + const lines = markdown.replace(/\r\n?/g, '\n').split('\n'); + const blocks = []; + const headings = []; + let i = 0; + + while (i < lines.length) { + const raw = lines[i]; + const lineNo = i + 1; + + if (raw.trim() === '') { i++; continue; } + + // Fenced code + const fence = /^(\s*)(`{3,}|~{3,})\s*([\w+-]*)\s*$/.exec(raw); + if (fence) { + const marker = fence[2][0].repeat(3); + const lang = fence[3] || null; + const body = []; + i++; + let closed = false; + while (i < lines.length) { + if (new RegExp(`^\\s*${marker[0]}{3,}\\s*$`).test(lines[i])) { closed = true; i++; break; } + body.push(lines[i]); + i++; + } + if (!closed) throw new UnsupportedMarkdownError('unterminated fenced code block', lineNo); + blocks.push({ type: 'code', lang, value: body.join('\n') }); + continue; + } + + // ATX heading + const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(raw); + if (heading) { + const depth = heading[1].length; + const children = parseInline(heading[2], lineNo); + const text = inlineToText(children); + const id = slug(text); + blocks.push({ type: 'heading', depth, id, text, children }); + headings.push({ depth, id, text }); + i++; + continue; + } + + // Thematic break + if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(raw)) { + blocks.push({ type: 'thematicBreak' }); + i++; + continue; + } + + // Table + if (raw.includes('|') && i + 1 < lines.length && isDelimiterRow(lines[i + 1])) { + const header = splitRow(raw, lineNo); + const align = alignmentsFrom(lines[i + 1]); + i += 2; + const rows = []; + while (i < lines.length && lines[i].trim() !== '' && lines[i].includes('|')) { + rows.push(splitRow(lines[i], i + 1)); + i++; + } + blocks.push({ type: 'table', align, header, rows }); + continue; + } + + // Blockquote + if (/^\s*>\s?/.test(raw)) { + const body = []; + while (i < lines.length && (/^\s*>\s?/.test(lines[i]) || (lines[i].trim() !== '' && body.length > 0))) { + body.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + blocks.push({ type: 'blockquote', children: parseMarkdown(body.join('\n'), { slug }).blocks }); + continue; + } + + // Lists + if (LIST_ITEM.test(raw)) { + const { node, next } = parseList(lines, i, slug); + blocks.push(node); + i = next; + continue; + } + + // Standalone block + if (/^\s* is not allowed`, lineNo); + } + + // Paragraph — consume until blank line or a line that starts a new block. + const para = []; + while (i < lines.length && lines[i].trim() !== '') { + const l = lines[i]; + if (para.length > 0 && (/^(#{1,6})\s+/.test(l) || LIST_ITEM.test(l) || /^\s*>\s?/.test(l) || /^(\s*)(`{3,}|~{3,})/.test(l))) break; + para.push(l.trim()); + i++; + } + blocks.push({ type: 'paragraph', children: parseInline(para.join(' '), lineNo) }); + } + + return { blocks, headings }; +} + +function parseList(lines, start, slug) { + const first = LIST_ITEM.exec(lines[start]); + const baseIndent = first[1].length; + const ordered = /\d/.test(first[2]); + const items = []; + let i = start; + + while (i < lines.length) { + const m = LIST_ITEM.exec(lines[i]); + if (!m) { + // A blank line inside a list is allowed if a further item follows. + if (lines[i].trim() === '' && LIST_ITEM.test(lines[i + 1] ?? '')) { i++; continue; } + break; + } + const indent = m[1].length; + if (indent < baseIndent) break; + if (indent > baseIndent) { + const { node, next } = parseList(lines, i, slug); + if (items.length === 0) throw new UnsupportedMarkdownError('nested list without a parent item', i + 1); + items[items.length - 1].children.push(node); + i = next; + continue; + } + const contentLines = [m[3]]; + i++; + // Continuation lines: indented further, not themselves list items. + while (i < lines.length && lines[i].trim() !== '' && !LIST_ITEM.test(lines[i]) && /^\s+/.test(lines[i])) { + contentLines.push(lines[i].trim()); + i++; + } + items.push({ type: 'listItem', children: [{ type: 'paragraph', tight: true, children: parseInline(contentLines.join(' '), i) }] }); + } + + return { node: { type: 'list', ordered, items }, next: i }; +} + +export function inlineToText(nodes) { + return nodes + .map((n) => { + switch (n.type) { + case 'text': return n.value; + case 'code': return n.value; + case 'image': return n.alt ?? ''; + case 'link': + case 'strong': + case 'em': return inlineToText(n.children); + default: return ''; + } + }) + .join(''); +} diff --git a/website/scripts/docs-parser.test.js b/website/scripts/docs-parser.test.js new file mode 100644 index 000000000..fd99bc010 --- /dev/null +++ b/website/scripts/docs-parser.test.js @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { + parseMarkdown, + parseInline, + createSlugger, + slugify, + inlineToText, + UnsupportedMarkdownError, +} from './docs-parser.js'; + +describe('slugger', () => { + it('matches GitHub-style slugs', () => { + expect(slugify('Alerts and TODOs')).toBe('alerts-and-todos'); + expect(slugify('Browsers for you and your agents')).toBe('browsers-for-you-and-your-agents'); + expect(slugify('`dor list` — find surfaces')).toBe('dor-list--find-surfaces'); + }); + + it('dedupes repeated headings', () => { + const slug = createSlugger(); + expect(slug('Usage')).toBe('usage'); + expect(slug('Usage')).toBe('usage-1'); + expect(slug('Usage')).toBe('usage-2'); + }); +}); + +describe('inline', () => { + it('parses code, links, and strong', () => { + const nodes = parseInline('see `dor list` in the [docs](https://example.com) **now**'); + expect(nodes.map((n) => n.type)).toEqual(['text', 'code', 'text', 'link', 'text', 'strong']); + }); + + it('honours backslash escapes', () => { + expect(inlineToText(parseInline('a \\| b'))).toBe('a | b'); + }); + + it('does not treat snake_case as emphasis', () => { + expect(inlineToText(parseInline('surface_id_value'))).toBe('surface_id_value'); + }); + + it('accepts an allowlisted https img', () => { + const [img] = parseInline('bell'); + expect(img).toMatchObject({ type: 'image', width: '22', height: '22', src: 'https://x.test/a.png' }); + }); + + it('rejects a disallowed img attribute', () => { + expect(() => parseInline('')) + .toThrow(UnsupportedMarkdownError); + }); + + it('rejects a non-https img src', () => { + expect(() => parseInline('')).toThrow(/must be https/); + }); + + it('rejects every other raw HTML tag', () => { + expect(() => parseInline('a b')).toThrow(/raw HTML