From df1b5a67c1025d8310ddb00c8cf564727c2d5e2d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 00:16:15 -0700 Subject: [PATCH 1/2] docs: correct and condense the terminal, notepad, and clipboard specs An audit of terminal-state, terminal-context, terminal-escapes, mouse-and-clipboard, and notepad found rules that no longer described the code, pointers that named the wrong symbol or test, and mechanism restated in a spec that the code already carries at the line it constrains. Corrections: the helper status table listed strings the view no longer renders, so it is now one rule pointing at HELPER_STATUS; OSC 8 names the confirmation host and its test; the drag-drop flag no longer claims to keep HTML5 drag-and-drop alive, since pane dragging is pointer-based; the standalone quit gate, source-pin teardown, Door popover height, notepad scope, CwdState shape, banner Cancel flash, IS_MAC reach, clipboard writer, prompt last-line cap, WSL and bash injection shapes, and the XTSMGRAPHICS report form all now say what the code does. The `.vsix` dotfile line is a note, not an imperative, because nothing asserts it. Condensations: per-shell integration mechanism, prompt-shape enumeration, header-icon placement, and six why-clauses that were already in a rationale file move to their owning script, code comment, or spec. Budgets ratchet down for mouse-and-clipboard, notepad, and terminal-escapes. Co-Authored-By: Claude Fable 5.1 --- docs/specs/mouse-and-clipboard.md | 32 ++++++++--------- docs/specs/notepad.md | 36 +++++++++---------- docs/specs/notepad.rationale.md | 7 ++-- docs/specs/standalone.md | 5 ++- docs/specs/terminal-context.md | 12 ++----- docs/specs/terminal-escapes.md | 14 ++++---- docs/specs/terminal-state.md | 8 +++-- lib/src/components/NotepadPanel.test.tsx | 2 +- .../wall/use-session-persistence.ts | 5 ++- lib/src/lib/notepad/archive-model.ts | 6 ++-- lib/src/lib/notepad/notepad-store.ts | 3 +- scripts/spec-word-budgets.json | 6 ++-- standalone/src-tauri/src/lib.rs | 6 +++- 13 files changed, 73 insertions(+), 69 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index a6e6334d2..f60b48c53 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -48,13 +48,13 @@ It ends on the **next mouse-up inside the terminal content area** paired with a - **Counts:** a plain click (down/up that never crossed the drag threshold) or a completed drag. - **Does not count:** clicks on the No-Mouse icon or the banner buttons, and an orphan mouse-up from a drag that started outside the terminal. -- **On end** — or on **Cancel**, immediately — reporting is restored, banner dismissed, Mouse icon back. **No timeout:** absent any mouse action the override stays indefinitely. +- **On end** — or on **Cancel**, after that button's 260 ms confirmation flash — reporting is restored, banner dismissed, Mouse icon back. **No timeout:** absent any mouse action the override stays indefinitely. -**Sticky override.** **Make sticky** converts it (the store calls this state `permanent`): banner dismissed, No-Mouse icon kept with its "click to restore" hover text, mouse and wheel still going to the terminal. It persists until the user clicks the No-Mouse icon. +**Sticky override.** **Make sticky** converts it after the same flash (the store calls this state `permanent`): banner dismissed, No-Mouse icon kept with its "click to restore" hover text, mouse and wheel still going to the terminal. It persists until the user clicks the No-Mouse icon. **Auto-clear on reporting off.** **Either override clears when the inside program stops requesting mouse reporting** (it exits, or DECRSTs `?1000l`/`?1002l`/`?1003l`); icon and banner go with it. A **dead** session's replay ends in the `REPLAY_MODE_RESET` tail that DECRSTs mouse tracking (`docs/specs/terminal-escapes.md`), so a mode latched by a dead TUI cannot block selection in the restored pane. -**No keyboard path is designed** for the icons or banner buttons; as plain buttons, focus-based activation is not actively prevented — §9.1. +**No keyboard path is designed** for the icons or banner buttons, and focus-based activation is not actively prevented. --- @@ -64,7 +64,7 @@ Selection is available whenever the terminal handles the mouse (§3.5, §6.1). ### 3.1 Initiating a Selection -- **Must begin selection after a click-and-drag crosses ~4px**; plain clicks shift pane focus or activate hyperlinks. **Must capture mouse presses on xterm’s screen immediately** (rationale). Pinned by `lib/src/lib/terminal-mouse-router.test.ts`. +- **Must begin selection after a click-and-drag crosses ~4px**; plain clicks shift pane focus or activate hyperlinks. **Must capture mouse presses on xterm’s screen immediately** (rationale); that capture, and the plain click it must not break, are pinned by `lib/src/lib/terminal-mouse-router.test.ts`. - On touch or pen, a primary pointer tap-and-drag takes the same path; non-primary touch pointers are ignored. - The selection draws as a single perimeter outline tracing the union of selected cells (§7 owns rendering). Color is `--color-focus-ring` (`docs/specs/theme.md`), with a hardcoded cornflower-blue final fallback in `SelectionOverlay.tsx`. - **A drag whose button comes up outside the webview iframe must still finalize**, by captured `pointerup` or the window-`mousemove` backstop (rationale). @@ -111,7 +111,7 @@ Source of truth: `lib/src/components/wall/keyboard/handle-mouse-selection-keys.t ## 4. Selection Popup -A finalized selection gets a popup of action buttons adjacent to it, on the side opposite the drag direction — mirroring where the drag hint sat. +A finalized selection gets a popup of action buttons anchored where §3.3's drag hint sat. ### 4.1 Copy Buttons @@ -205,7 +205,7 @@ Source of truth: `terminalOwnsEvent` in `lib/src/lib/terminal-mouse-router.ts`, - **Must render outlines, hints, and popups above the cell grid**, isolated from inside-program output and redraws; header icons and banners remain persistent chrome. - **Geometry comes from the *measured* xterm cell grid** (`cellWidth`/`cellHeight`/`gridLeft`/`gridTop`), never element-width ÷ cols, so the outline stays aligned across xterm's internal padding. -- **Must remeasure both overlay and popup on every shared render tick** (scroll, resize, output), even when the selection is unchanged; the popup dismisses if the selection is canceled. Pinned by `lib/src/components/SelectionPopup.test.tsx`. +- **Must remeasure both overlay and popup on every shared render tick** (scroll, resize, output), even when the selection is unchanged; the popup dismisses if the selection is canceled. Pinned for the popup by `lib/src/components/SelectionPopup.test.tsx`. Source of truth: `lib/src/lib/selection-text.ts` (extraction and normalization), `lib/src/lib/selection-geometry.ts` (perimeter construction), `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx` and `MouseOverrideBanner` in `lib/src/components/wall/MouseOverrideBanner.tsx` — tested in `lib/src/components/wall/mouse-chrome.test.tsx`. @@ -229,7 +229,7 @@ Because Ctrl+V is intercepted everywhere, a literal control character goes in th ### 8.4 Platform Detection -**`IS_MAC` (`lib/src/lib/platform/index.ts`) is computed once at startup** from `navigator.userAgentData.platform`, else `navigator.platform`, else the user-agent string, matched against `/Mac|iPhone|iPad/i`. It gates only the copy chord (§4.2) and hint strings — the paste chord is platform-independent (§8.2). +**`IS_MAC` (`lib/src/lib/platform/index.ts`) is computed once at startup** from `navigator.userAgentData.platform`, else `navigator.platform`, matched against `/Mac|iPhone|iPad/i`. It gates the copy chord (§4.2), every platform-dependent label, and the app's own macOS chrome (the AppBar's traffic-light inset, the VS Code workbench chord map) — **the paste chord alone is platform-independent** (§8.2). ### 8.5 Bracketed Paste @@ -246,7 +246,7 @@ Source of truth: `defangPasteEscapes` in `lib/src/lib/clipboard.ts`. Paste reads the clipboard in three tiers, preferred in order: 1. **File references** (a Finder/Explorer Copy of a file). Each path is shell-escaped; the space-joined list is written to the PTY with a trailing space, so the next token starts cleanly. -2. **Plain text.** The adapter's native `readClipboardText` where it has one, else `navigator.clipboard.readText()`. **Never reverse that order:** on macOS WKWebView the `navigator` call pops a confirmation menu at the cursor on every invocation (rationale). A non-empty string goes to the PTY (bracket-wrapped, §8.5). +2. **Plain text.** The adapter's native `readClipboardText` where it has one, else `navigator.clipboard.readText()`. **Never reverse that order** (rationale). A non-empty string goes to the PTY (bracket-wrapped, §8.5). 3. **Raw image data.** Only when both of the above come back empty and the clipboard holds image bytes (e.g. a `Cmd+Shift+4` screenshot): the bytes are written to a newly-created private temp directory as `-clipboard.png`, and that path is pasted as in tier 1. **On Unix-like systems the temp directory is owner-only and the image file owner-read/write**, so clipboard screenshots are not exposed to other local users. File and directory are unlinked ~5 minutes later (rationale). **Tiers 1 and 2 are read in parallel** (independent IPC roundtrips) and the file reference wins; tier 3 is sequential because it allocates a temp file. Every tier empty ⇒ silent no-op. @@ -259,21 +259,21 @@ One shared Node module, `standalone/sidecar/clipboard-ops.js`, serves both hosts | Windows | `powershell` — `Get-Clipboard -Format FileDropList` / `-Raw`, `System.Windows.Forms.Clipboard` | | Linux | `wl-paste` and `xclip`, in whichever order `WAYLAND_DISPLAY` suggests, each falling through to the other | -**Every spawn must pass `windowsHide`** (CREATE_NO_WINDOW), or each Windows subprocess allocates a console window that flickers and steals focus, several per paste (rationale). +**Every spawn must pass `windowsHide`** (CREATE_NO_WINDOW; rationale). **The standalone/Tauri build on Windows reads the Win32 clipboard directly in Rust**, dropping the subprocess: `CF_HDROP` for file paths, `CF_UNICODETEXT` for text, `CF_DIB` for an image saved as a `.bmp` temp file — the extension differs from the sidecar path's `.png`, and the same ~5-minute cleanup applies. Non-Windows Tauri stays on the sidecar path. -**Path escaping (tiers 1 and 3, and §8.7). Quote a pasted path for the Session's launch shell** — never for the host platform, never for the app-global shell selected for future terminals; they diverge on Windows, where several shell kinds run side by side and the wrong parser is a code-execution bug (rationale). Each terminal registry entry captures its `shellKind` at spawn; a live reconnect's `pty:list` row carries the launch-shell path so the rebuilt entry keeps that kind; a cold restore launches every terminal with the current default and captures it. **Only a missing registry entry falls back** — to the app-global selected shell, then the platform (`cmd` on Windows, posix elsewhere). Classification uses the same `shellCommandKind` `dor` uses to quote commands (`docs/specs/dor-cli.md`). Three rules: +**Path escaping (tiers 1 and 3, and §8.7). Quote a pasted path for the Session's launch shell** — never for the host platform, never for the app-global shell selected for future terminals (rationale). Each terminal registry entry captures its `shellKind` at spawn and keeps it across a live reconnect (the `pty:list` row carries the launch-shell path) and a cold restore. **Only a missing registry entry falls back** — to the app-global selected shell, then the platform (`cmd` on Windows, posix elsewhere). Classification uses the same `shellCommandKind` `dor` uses to quote commands (`docs/specs/dor-cli.md`). Three rules: - **posix** — backslash-escape each metacharacter, matching macOS Terminal's drag-and-drop format (rationale). Newline/CR paths are single-quote-wrapped instead, since bash swallows `\` as a line continuation. - **cmd** — double-quote-wrap, doubling embedded `"`. cmd's own `%NAME%` (and `!NAME!` under delayed expansion) remains a parser limitation of this legacy path. -- **powershell** — bare when every character is inert in argument mode, else single-quote-wrapped with embedded `'` doubled, reusing `dor`'s `quotePowerShellArg`. **Never reuse the cmd rule here:** PowerShell's *double*-quoted strings are expandable (rationale). The bare set excludes `,` (array operator in argument mode) and `@` (splatting, or another expression form at a token's start). +- **powershell** — bare when every character is inert in argument mode, else single-quote-wrapped with embedded `'` doubled, reusing `dor`'s `quotePowerShellArg`. **Never reuse the cmd rule here** (rationale). The bare set excludes `,` (array operator in argument mode) and `@` (splatting, or another expression form at a token's start). -Source of truth: `lib/src/lib/clipboard.ts` (Session-kind selection), `lib/src/lib/shell-escape.ts` (dispatch + posix/cmd rules, pinned by `lib/src/lib/shell-escape.test.ts`), `lib/src/lib/terminal-lifecycle.ts` (captured `shellKind`), `dor/src/commands/shell-quote.ts` (`shellCommandKind`, `quotePowerShellArg`), `standalone/src-tauri/src/clipboard_win.rs` (Win32 read), and the live-PTY list contract in `docs/specs/transport.md`. +Source of truth: `lib/src/lib/clipboard.ts` (Session-kind selection), `lib/src/lib/shell-escape.ts` (dispatch + posix/cmd rules, pinned by `lib/src/lib/shell-escape.test.ts`) over `POSIX_ESCAPABLE` in `lib/src/lib/posix-escape.ts` (the escapable set, shared with the command tokenizer), `lib/src/lib/terminal-lifecycle.ts` (captured `shellKind`), `dor/src/commands/shell-quote.ts` (`shellCommandKind`, `quotePowerShellArg`), `standalone/src-tauri/src/clipboard_win.rs` (Win32 read), and the live-PTY list contract in `docs/specs/transport.md`. ### 8.7 Drag-to-Paste -Dropping files on a terminal pane types their escaped paths at the current prompt, exactly as tier 1 does (§8.6). Tauri takes the drop natively via `WindowEvent::DragDrop` and routes the paths to the selected pane (dropped if the selection is a Door or has left the layout) — but **the wiring is inert today**: `tauri.conf.json` sets `dragDropEnabled: false` so HTML5 drag-and-drop keeps working inside the webview (tauri-apps/tauri#14373, dormouse#38), so the native handler never fires. Flipping the flag is a live option, and a deliberate, separate change (rationale). +Dropping files on a terminal pane types their escaped paths at the current prompt, exactly as tier 1 does (§8.6). Tauri takes the drop natively via `WindowEvent::DragDrop` and routes the paths to the selected pane (dropped if the selection is a Door or has left the layout) — but **the wiring is inert today**: `tauri.conf.json` sets `dragDropEnabled: false` (tauri-apps/tauri#14373, dormouse#38), so the native handler never fires. **Nothing in the layout stack needs the flag off any more** — Lath's pane drag is pointer-based — so flipping it is a live option, and a deliberate, separate change (rationale). **Drag-to-paste is not supported in the VSCode build**: the workbench excludes `WebviewView` (sidebar/panel) from external-file drop routing, so the iframe never receives `dragover`/`drop` for OS files (§9.2). VSCode users paste instead (§8.1/§8.5). @@ -285,7 +285,7 @@ Right-click and OS Edit-menu paste are not implemented; users paste via §8.2's Dormouse's own ``s — pane rename, the browser URL editor, dialog fields — have no *native* clipboard chords in the menu-less standalone build (`docs/specs/standalone.md` → "Application menu"). `handleEditableClipboard` (`lib/src/components/wall/keyboard/handle-editable-clipboard.ts`) supplies them in JS, **ahead of the wall's mode and rename gates** so a focused field wins whatever the wall is doing: -- **Paste** reads through `readTextFromClipboard` (the §8.6 tier-2 preference, so no "Paste from " popup) and replaces the field's selection. **Copy** and **cut** write the selected substring with `navigator.clipboard.writeText`; a collapsed selection copies nothing. **Text only** — the file-reference and image tiers stay terminal-only. +- **Paste** reads through `readTextFromClipboard` (the §8.6 tier-2 preference, so no "Paste from " popup) and replaces the field's selection. **Copy** and **cut** write the selected substring through `writeTextToClipboard`, whose false return is what stops a cut deleting; a collapsed selection copies nothing. **Text only** — the file-reference and image tiers stay terminal-only. - The edit goes through `document.execCommand('insertText')` where the webview allows it (native undo), else **the prototype `value` setter plus a synthetic `input` event** — a plain `value` assignment desyncs a React-controlled field. - Chords are §8.2's: paste takes either modifier on every platform, copy/cut take `⌘` on macOS and `Ctrl` elsewhere. - **Scope is narrow.** Excluded: xterm's `.xterm-helper-textarea` (the terminal owns its chords), read-only and disabled fields. The handler runs only where the adapter implements the optional `readClipboardText` — today the two standalone adapters, slightly over-reaching the menu-less macOS build it is written for (rationale). Elsewhere — VS Code, the website, Pocket — it never fires and the webview's own chords are untouched. @@ -295,9 +295,9 @@ Dormouse's own ``s — pane rename, the browser URL editor, dialog fields ## Terminal context input -**Must give application-captured right-click to the terminal program**, retaining header right-click as the context entry point. Do not add a Shift-right-click override gesture. A helper never opens a recursive context. +**Must give application-captured right-click to the terminal program**, retaining header right-click as the context entry point. Do not add a Shift-right-click override gesture. -**Must route clipboard chords and selection operations to the focused helper**, while leaving its Escape, Tab, arrows, and digits with xterm. Copying and selection do not disarm autorun; terminal input, paste, drops, and application mouse reports do. +**Must route clipboard chords and selection operations to the focused helper**, while leaving its Escape, Tab, arrows, and digits with xterm. Which of those disarm autorun follows `docs/specs/terminal-context.md` → "Helper lifecycle". Source of truth: `TerminalPanel` in `lib/src/components/wall/TerminalPanel.tsx`; `useWallKeyboard` in `lib/src/components/wall/use-wall-keyboard.ts`; `markSessionTouched` in `lib/src/lib/terminal-lifecycle.ts`. diff --git a/docs/specs/notepad.md b/docs/specs/notepad.md index 6b17dcf8a..bef88393d 100644 --- a/docs/specs/notepad.md +++ b/docs/specs/notepad.md @@ -14,9 +14,9 @@ A notepad is one ordered list of notes per Surface, held in renderer memory for - **A run keeps bold, italic, foreground, and background, and nothing else.** Colors are normalized lowercase `#rrggbb`; a missing color means the theme default, so an excerpt stays theme-adaptive. **Underline, dim, blink, strike-through, and hyperlinks are dropped at capture**, never later. - **A note's source pin is runtime-only.** `RuntimeTerminalSource` holds live xterm markers, so `ArchivedNote` is `LiveNote` minus that field and no marker ever reaches a store. - **A closure appends one `ArchiveBatch` per Surface** — `id`, `closedAt`, `surfaceTitle`, `surfaceKind`, `cwd`, and the notes in creation order. -- **`ArchiveBatch.cwd` is required and nullable.** It is the whole canonical `CwdState` snapshotted before teardown — path, URI, host, path kind, source, observation time — or `null` for a browser Surface and a terminal that never reported one. **Never persist a preformatted CWD label beside it**; the Archive renders path and remote host through `cwdDisplay`. -- **A batch id is remembered per Surface across its closure attempts and forgotten once one lands**, and **every attempt deletes and re-appends that id in one mutation**, so an attempt that landed and *then* reported failure is replaced by the next, carrying the edits, additions, and deletions made in between. **`closedAt` is minted per attempt.** -- **A mutation applies its deletes before its appends**, which is what makes that pair a replacement. Appends stay idempotent by batch and note id — an already-stored note is dropped from an appended batch and a batch left empty is skipped — and deleting something already gone is a no-op. +- **`ArchiveBatch.cwd` is required and nullable.** It is the whole canonical `CwdState` snapshotted before teardown, or `null` for a browser Surface and a terminal that never reported one. **Never persist a preformatted CWD label beside it**; the Archive renders path and remote host through `cwdDisplay`. +- **A batch id is remembered per Surface across its closure attempts and forgotten once one lands**, and **every attempt deletes and re-appends that id in one mutation**. **`closedAt` is minted per attempt.** +- **A mutation applies its deletes before its appends.** Appends stay idempotent by batch and note id — an already-stored note is dropped from an appended batch and a batch left empty is skipped — and deleting something already gone is a no-op. Source of truth: `lib/src/lib/notepad/types.ts`; `applyArchiveMutation`, `buildArchiveBatch`, `readNotepadArchive` and `toArchivedNote` in `lib/src/lib/notepad/archive-model.ts`; `pendingBatchId` in `lib/src/lib/notepad/notepad-store.ts`. @@ -52,7 +52,7 @@ Source of truth: `NotepadArchivePort` in `lib/src/lib/notepad/types.ts`; `mutate - **Capture is not Copy Rewrapped**: no paragraph joining and no box-drawing stripping (`docs/specs/mouse-and-clipboard.md` §4.1.2; rationale). - **Retain the raw selected text separately.** `extractSelectionText` over the same selection is the pin's validation key, read from the buffer rather than rebuilt from the runs. - **Colors record what xterm drew.** Walk buffer cells over the normalized selection, skip width-zero continuation cells, emit a wide character once, resolve palette and RGB colors, swap inverse ones to explicit values, and merge adjacent runs of identical styling. **Bold text on palette entries 0–7 resolves to the bright entry while `drawBoldTextInBrightColors` is on.** Entries 0–15 come from the live theme, 16–255 from xterm's computed table. -- **An alternate-buffer capture gets no pin.** A full-screen program rewrites its grid in place, so there is nothing stable to point at. +- **An alternate-buffer capture gets no pin**, a full-screen program rewriting its grid in place. - **A capture flashes in place and dismisses the selection; it never opens the notepad.** Source of truth: `extractRichRuns` and `captureRichSelection` in `lib/src/lib/notepad/rich-extract.ts`; `addSelectionToNotepad` in `lib/src/lib/notepad/capture.ts`. @@ -63,8 +63,8 @@ A pin is the runtime link from a captured note back to the scrollback it came fr - **Pin an ordinary Session's normal-buffer capture with two xterm markers plus the normalized endpoint columns and the raw text.** Markers ride the buffer as it scrolls; the columns and text rebuild and prove the range. - **Must prove a pin before opening Tool context.** Close the notepad, reattach a minimized Surface, rebuild the range from markers and columns, and compare it exactly with the captured raw text. On success open context, prove the displayed range again, then scroll and restore the Dormouse selection, popup, and render-invalidation baseline. -- **Column restoration after a resize is best effort**; the raw-text equality is what prevents navigating to the wrong output. Trimmed scrollback is discovered only when a pin is used. -- **While the alternate buffer is active a pin is temporarily unavailable and kept** — the markers belong to the normal buffer and resolve again once the program exits; the notepad says to exit it. +- **Column restoration after a resize is best effort**; the raw-text equality is what prevents navigating to the wrong output (rationale). Trimmed scrollback is discovered only when a pin is used. +- **While the alternate buffer is active a pin is temporarily unavailable and kept**, the notepad saying to exit it (rationale). - **Must retain a pin that resolves before opening context but fails after its synchronous refit**, reporting that the layout changed without selecting stale coordinates. This does not remap wrapped text; subsequent attempts use ordinary proof and failure rules (rationale). - **Every other pin failure removes the pin and keeps the note.** Disposed markers, rows out of range, and a text mismatch report that the source is unavailable; the notepad stays or reopens with the notice. - **Disposing or replacing a terminal instance drops its pins immediately**, notes untouched — a marker belongs to one xterm instance. @@ -72,19 +72,19 @@ A pin is the runtime link from a captured note back to the scrollback it came fr notes (rationale). - **Pins never affect ordering and are not user-controlled favorites.** -Source of truth: `registerTerminalSource`, `resolveTerminalSource` and `revealResolvedSource` in `lib/src/lib/notepad/source-link.ts`; `revealNoteSource` in `lib/src/lib/notepad/pin.ts` (tested in `lib/src/lib/notepad/pin.test.ts`); `setTerminalSelectionBaseline` in `lib/src/lib/terminal-store.ts`; `dropSourcesForTerminal` in `lib/src/lib/notepad/notepad-store.ts`, called from `disposeSession` in `lib/src/lib/terminal-lifecycle.ts`. +Source of truth: `registerTerminalSource`, `resolveTerminalSource` and `revealResolvedSource` in `lib/src/lib/notepad/source-link.ts`; `revealNoteSource` in `lib/src/lib/notepad/pin.ts` (tested in `lib/src/lib/notepad/pin.test.ts`); `setTerminalSelectionBaseline` in `lib/src/lib/terminal-store.ts`; `dropSourcesForTerminal` in `lib/src/lib/notepad/notepad-store.ts`, called from `teardownSession` in `lib/src/lib/terminal-lifecycle.ts` — so a release drops pins as surely as a dispose. ## Notepad UI **Must retain one notepad per Tool Surface.** Context changes its presentation only (`docs/specs/terminal-context.md` → Tool context); following a source pin reveals that same terminal. -**The header notepad icon sits after the mouse-override icon and before the split controls** (`docs/specs/layout.md` → "Pane header"), filled while the Surface has notes and regular otherwise. **At the minimal tier an empty notepad yields its space to the title; one with notes stays until the width can no longer hold it beside the pane-action group** (`docs/specs/layout.md` → "Pane header responsive sizing" owns that boundary)**.** +**The header notepad icon is filled while the Surface has notes and regular otherwise.** Its placement and its survival per density tier belong to `docs/specs/layout.md` → "Pane header". - **The attached notepad is a panel in the top-right of the Surface body, three quarters of it wide and tall.** It closes on its close control, Escape, or an outside click. -- **Only one Surface notepad is open per Wall.** The store holds a single open id, and opening a Door's popover closes the attached panel. +- **Only one Surface notepad is open per window.** The store holds a single open id across every Wall in the webview, and opening a Door's popover closes the attached panel. - **An open notepad owns the keyboard.** It is a `role="dialog"` with a focus trap, takes a lease on the Wall's dialog keyboard so command-mode dispatch stands down, and stops key and mouse events from reaching the Surface under it — a live browser pane's key forwarder included. - **A Door is a wrapper carrying `data-door-id` with one or two buttons.** The wrapper is what the selection ring and the baseboard fitting measure; the title button keeps click-to-reattach and the drag press, the notepad button does neither. **A minimized Surface with no notes gets no notepad button.** -- **The Door popover opens above its Door, edge-clamped, capped at 30rem wide and 75% of the Wall height. Opening it never reattaches the Surface** — only following a pin from it does. +- **The Door popover opens above its Door, edge-clamped, capped at 30rem wide and 75% of the viewport height. Opening it never reattaches the Surface** — only following a pin from it does. Editing and copying: @@ -94,7 +94,7 @@ Editing and copying: - **Rich runs render as escaped spans in a whitespace-preserving container**, never as injected HTML. - **Copy always writes `text/plain`.** A terminal note adds a `text/html` flavor built only from escaped text and the four supported attributes — never by serializing rendered DOM. **Anything the rich clipboard refuses falls back to the plain-text write**, best effort by contract. -Source of truth: `NotepadBody` in `lib/src/components/NotepadBody.tsx`, placed by `lib/src/components/NotepadPanel.tsx` and `lib/src/components/DoorNotepadPopover.tsx`; `applyPlainEdit` in `lib/src/components/NoteList.tsx`; `lib/src/components/Door.tsx`; `noteToHtml` and `copyNoteToClipboard` in `lib/src/lib/notepad/rich-clipboard.ts`. +Source of truth: `NotepadHeaderButton` in `lib/src/components/wall/NotepadHeaderButton.tsx`, placed by `lib/src/components/wall/SurfacePaneHeader.tsx`; `NotepadBody` in `lib/src/components/NotepadBody.tsx`, placed by `lib/src/components/NotepadPanel.tsx` and `lib/src/components/DoorNotepadPopover.tsx`; `applyPlainEdit` in `lib/src/components/NoteList.tsx`; `lib/src/components/Door.tsx`; `noteToHtml` and `copyNoteToClipboard` in `lib/src/lib/notepad/rich-clipboard.ts`. The popup's third action and the `⌘N` / `Ctrl+N` chord belong to `docs/specs/mouse-and-clipboard.md` §4.1 and §4.2. Two rules are the notepad's own: **intercept the chord only while that terminal has a finalized Dormouse selection**, so with none it reaches the program unchanged; and **a host whose browser reserves the chord shows no shortcut and binds none**, gated by `browserReservesNotepadChord`, which the website demo and the standalone browser-dev harness set (rationale). Source of truth: `isNotepadChordBound` in `lib/src/lib/notepad/capture.ts`, `lib/src/components/SelectionPopup.tsx`. @@ -109,7 +109,7 @@ A **Notepad archive** entry in Settings replaces that dialog's content with a ro - **Back, Escape, and the close control commit the staged set as one mutation before leaving. A failed commit keeps the view open with its staged set intact and shows the error**; pressing the same control again is the retry. - **Stored data that fails validation is reported as unreadable and never silently replaced.** Every append fails meanwhile, and closures take the failure path in [Closure](#closure). **Exactly one user-initiated recovery moves the stored data aside — never deletes it — and starts an empty archive** (rationale). - **Must revalidate a VS Code recovery inside the storage transaction**; a now-valid or absent archive stays untouched (`vscode-ext/test/notepad-archive-store.test.ts`). -- **An unknown field anywhere in stored data fails validation**, because every mutation rewrites the whole archive, so a field this build does not know would be erased on the next save (rationale). +- **An unknown field anywhere in stored data fails validation** (rationale). - **Archived entries stay until explicitly deleted.** No age limit and no count limit. Source of truth: `lib/src/components/NotepadArchiveView.tsx`; the Settings entry in `lib/src/components/SettingsDialog.tsx`. @@ -118,7 +118,7 @@ Source of truth: `lib/src/components/NotepadArchiveView.tsx`; the Settings entry **Every user-visible permanent Surface closure routes through the close coordinator**, which builds stable-id batches from whatever notes exist and appends them in one mutation *before* teardown. Routed: the header kill button, keyboard kills both confirmed and on the untouched fast path, `dor kill`, the Door-restore kill path, and controlled application quit. **A multi-Surface closure appends every batch in one mutation**, and a closure with no notes writes nothing — **unless an earlier attempt of that Surface landed a batch, which the same mutation then deletes**, its notes having been deleted after the user was told none were stored. -**A Surface's notes are frozen from the moment its closure snapshots them until the write settles** — adds, edits, and deletes are refused, a refused capture releases its own markers, and the panel renders read-only behind an "Archiving notes…" line — so nothing taken during the write can be archived stale or dropped unarchived by the forget step (rationale). The freeze is counted, so overlapping closures of one Surface thaw it once. **Empty plain notes are never archived**: an untouched Add New still on screen when the kill lands is not a note, and a Surface holding only those closes as if it held none. +**A Surface's notes are frozen from the moment its closure snapshots them until the write settles** — adds, edits, and deletes are refused, a refused capture releases its own markers, and the panel renders read-only behind an "Archiving notes…" line (rationale). The freeze is counted, so overlapping closures of one Surface thaw it once. **Empty plain notes are never archived**: an untouched Add New still on screen when the kill lands is not a note, and a Surface holding only those closes as if it held none. **A terminal Surface's process CWD is refreshed immediately before its batch is built** — every such Surface at once, bounded as one batch at `PROCESS_CWD_REFRESH_MS`, a timeout, refusal, or synchronous lookup error keeping whatever the Session last reported, and **never overriding, or even asking about, a CWD the shell integration reported** — so a shell with no CWD escapes still archives where it was, and no Surface pays for an answer that would be discarded. @@ -151,12 +151,12 @@ Source of truth: `archiveSurfaceNotes` in `lib/src/lib/notepad/close-coordinator - **A failure or timeout leaves the quit pending in Rust**, whose phase-2 wait is unbounded for exactly this (`docs/specs/standalone.md` → "Quit flow"), and the dialog shows the error with **Cancel** (default) and **Quit anyway**, which discards the notes. **Only Cancel calls `quit_cancel`**: Quit anyway must reach teardown with the watchdog still armed. - **A timeout aborts the archive it stopped waiting for** ([Closure](#closure)). -- **Must include Surfaces with pending batch IDs even after their last note is deleted**, both when archiving and discarding on Quit anyway; pinned by `standalone/src/quit-notepad.test.ts`. +- **Must include Surfaces with pending batch IDs even after their last note is deleted**, both when archiving (`standalone/src/quit-notepad.test.ts`) and when discarding on Quit anyway (`standalone/src/window-close.test.ts`). - **Teardown's own rule is untouched**: once teardown begins, no failing step prevents exit. -The store is `/notepad-archive-v1.json`, **outside `sessions/` and outside the state root**, so every build shares one — a Surface's notes outlive the window whose closure archived them, so they must not ride the per-window session blob or the sweep over its directory. **It is written owner-only and atomically through the same `write_file_atomically` the session snapshot uses** (`docs/specs/security-local.md` → "Persisted state"). **The revision is a hash of the stored bytes, and every load, save and reset holds an exclusive lock on the sidecar `notepad-archive-v1.lock`** — a second Dormouse sharing `app_data_dir()`, a dev build beside the installed app, then conflicts instead of overwriting batches it never read. **Recovery renames it to `notepad-archive-v1.unreadable-.json` beside the original**, disambiguating rather than overwriting an earlier quarantine; only a temp file a crash left behind is dropped. +The store is `/notepad-archive-v1.json`, **outside `sessions/` and outside the state root**, so every build shares one (rationale). **It is written owner-only and atomically through the same `write_file_atomically` the session snapshot uses** (`docs/specs/security-local.md` → "Persisted state"). **The revision is a hash of the stored bytes, and every load, save and reset holds an exclusive lock on the sidecar `notepad-archive-v1.lock`**, so a second Dormouse sharing `app_data_dir()` conflicts instead of overwriting batches it never read (rationale). **Recovery renames it to `notepad-archive-v1.unreadable-.json` beside the original**, disambiguating rather than overwriting an earlier quarantine; only a temp file a crash left behind is dropped. -Source of truth: `archiveNotesBeforeTeardown` in `standalone/src/teardown-archive.ts`, the `'archive-failed'` phase in `standalone/src/quit-confirm-store.ts`; `write_notepad_archive_to`, `lock_notepad_archive` and `reset_notepad_archive_at` in `standalone/src-tauri/src/lib.rs`; the port in `standalone/src/tauri-adapter.ts`. +Source of truth: `archiveThenProceed` in `standalone/src/teardown-flow.ts`, which awaits `archiveNotesBeforeTeardown` in `standalone/src/teardown-archive.ts` and raises the `'archive-failed'` phase in `standalone/src/quit-confirm-store.ts`; `write_notepad_archive_to`, `lock_notepad_archive` and `reset_notepad_archive_at` in `standalone/src-tauri/src/lib.rs`; the port in `standalone/src/tauri-adapter.ts`. ## VS Code lifecycle @@ -167,8 +167,8 @@ VS Code can destroy a webview without asking, so the close coordinator may never - **The mirror holds what a close would archive minus the markers** — notes, Surface title, kind and CWD, plus its pending batch id, each terminal Surface's PTY id, and any archive deletions an open Archive view has staged. **The PTY id is mirror-only and never reaches a batch.** - **Must mirror pending batch identity before saving and retain it after the last note is deleted.** Teardown deletes and re-appends that batch; live resume restores its identity. Pinned by `vscode-ext/test/notepad-archive-store.test.ts` and `lib/src/lib/notepad/notepad-store.test.ts`. - **A teardown refreshes the mirror's process CWDs while the PTYs are alive** — bounded, and never overriding an integration-reported one — which on an editor-panel disposal is what makes the kill wait for the archive write, and in `deactivate()` puts it ahead of the session flush. -- **The mirror is memory only, never written to disk, and cleared by an extension restart.** It is a bridge across one disposal, not a draft store. -- **The mirror is sanitized on the way in**, round-tripped through the archive validator, because a teardown writes it verbatim with no webview left to ask. +- **The mirror is memory only, never written to disk, and cleared by an extension restart** (rationale). +- **The mirror is sanitized on the way in**, round-tripped through the archive validator (rationale). - **Editor-panel disposal (`killOnDispose: true`) and `deactivate()` archive their mirrored notes, best effort**, draining what they take so `deactivate()` cannot write a panel's notes again under a fresh batch id. In `deactivate()` the step sits between the recovery capture and the session flush, bounded (`docs/specs/vscode.md` → "Serialization and restore"). - **Every router disposal commits and drains that router's staged archive deletions, best effort** — they were promised irreversible once this window closed, and the webview *is* the window. **A live resume is therefore never handed a pending deletion**; `hydrateNotepadFromVolatile` ignores the field. - **A `WebviewView` disposal is not a closure.** Its PTYs stay alive, so only its *notes* stay in the mirror for the next resolve. diff --git a/docs/specs/notepad.rationale.md b/docs/specs/notepad.rationale.md index 80c736a3e..34b738396 100644 --- a/docs/specs/notepad.rationale.md +++ b/docs/specs/notepad.rationale.md @@ -30,9 +30,10 @@ Remembering the id and deleting it in the very mutation that re-appends it repla the batch wholesale instead, so edits, additions, and the user's own deletions all survive. That is why deletes apply before appends, and why the note-id dedupe is computed after them — otherwise the batch being replaced would still count as -storing its own notes. The dedupe stays because the VS Code mirror path mints a -fresh id per teardown and has nothing to address an earlier write by; a note id is -a UUID, so "already stored" is an exact test there rather than a heuristic. +storing its own notes. The dedupe stays for the VS Code mirror path: a mirrored +Surface carrying no pending batch id still mints a fresh one at teardown, leaving +that batch nothing to address an earlier write by; a note id is a UUID, so +"already stored" is an exact test there rather than a heuristic. `MAX_SAVE_ATTEMPTS` is 5. An unbounded retry against an archive somebody else is rewriting in a loop would spin instead of telling the user, and the closure paths diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index c9a5a39cb..43bb7bc6b 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -1189,9 +1189,8 @@ restart-intent tests in `standalone/src-tauri/src/quit_state.rs`, The `WindowEvent::DragDrop` handler in `lib.rs` emits the dropped paths as `dormouse://files-dropped`; `TauriAdapter` fans that out to `onFilesDropped` for the Wall. The whole path is **inert today**: `tauri.conf.json` sets -`dragDropEnabled: false` to keep in-webview HTML5 drag-and-drop working, so the -native handler never fires. Behavior and status: -`docs/specs/mouse-and-clipboard.md` (§8.7 Drag-to-Paste). +`dragDropEnabled: false`, so the native handler never fires. Behavior and +status: `docs/specs/mouse-and-clipboard.md` (§8.7 Drag-to-Paste). ## Logging diff --git a/docs/specs/terminal-context.md b/docs/specs/terminal-context.md index 1bf8ce3f0..cf9c0e274 100644 --- a/docs/specs/terminal-context.md +++ b/docs/specs/terminal-context.md @@ -17,19 +17,11 @@ - **Must retain exited output**, offer Reset, and avoid automatic restart loops. - **Must pause status and process-inspection polling while the context is hidden**, invalidating cached idle results. Reopening publishes current terminal status; source closure inspects work on demand. -| State | Status and action | -|---|---| -| Starting | Waiting for shell…; Modify | -| Autorun executing | Running the captured command; Modify | -| Untouched completion | Captured command autoran; Modify | -| User input | Skipping autorun to preserve user keystrokes; Reset | -| Empty default | Autorun off; Modify | -| No readiness | Autorun skipped: shell readiness unavailable; Modify | -| Exited | Helper exited; Reset | +**Must carry one status line per helper state**, offering Reset in place of Modify only after user input and after exit. **Must make Reset an explicit discard**, confirming loss of scrollback, unfinished input, running programs, and unsaved edits. Cancellation changes nothing; confirmation disposes the old helper and launches a fresh one using the source's current directory and current global setting. Stale timers cannot write to the replacement. -Source of truth: `openHelper` / `helperHasWork` / `disposeHelper` / `closeHelperParent` in `lib/src/lib/helper-terminal.ts`; `markSessionTouched` / `parkElement` in `lib/src/lib/terminal-lifecycle.ts`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. Tests: `lib/src/lib/helper-terminal.test.ts`. +Source of truth: `openHelper` / `helperHasWork` / `disposeHelper` / `closeHelperParent` in `lib/src/lib/helper-terminal.ts`; `markSessionTouched` / `parkElement` in `lib/src/lib/terminal-lifecycle.ts`; `HELPER_STATUS` — the state, its status line, and whether it offers Reset — in `lib/src/components/wall/TerminalContextView.tsx`. Tests: `lib/src/lib/helper-terminal.test.ts`. Notepad sharing and pin restrictions follow `docs/specs/notepad.md` → "Helper terminals". diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index cf2c0df84..465e8760a 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -93,7 +93,7 @@ Neither `params` nor the URI is parsed at the PTY boundary. **Cancel/close is the safe default; long targets must wrap and scroll without truncation.** **The confirmation host must reject deceptive verdicts even if its callback runs.** **Every adapter must revalidate through `normalizeExternalUri` before opening** (VS Code before `vscode.env.openExternal`) — consent does not replace validation. -Source of truth: `lib/src/lib/external-links.ts`, `lib/src/lib/external-link-confirmation.ts`, `lib/src/components/ExternalLinkModal.tsx`. +Source of truth: `normalizeExternalUri` in `lib/src/lib/external-links.ts` (pinned by `lib/src/lib/external-links.test.ts`), `lib/src/lib/external-link-confirmation.ts`, `lib/src/components/ExternalLinkModal.tsx`, and the host's own verdict re-check in `lib/src/components/ExternalLinkModalHost.tsx`. ## Supported CSI @@ -126,7 +126,7 @@ Source of truth: `getWebviewHtml` in `vscode-ext/src/webview-html.ts`, `app.secu `onData` includes xterm.js *replies*. **Both classifiers require every chunk token to match**, so a report glued onto real keystrokes is never mistaken for one. -- **`inputIsReplayTerminalReport`** — dropped outright while `isReplaying` (rationale). Shapes: cursor-position / device-status (`CSI [?] R` / `n`), device attributes (`CSI [?>=] c`), window-manipulation reports (`CSI t` / `x`), DECRQSS and XTSMGRAPHICS reports (`CSI [?] $y` / `S`), focus in/out (`CSI I` / `CSI O`), kitty keyboard-query replies (`CSI ? u`), and OSC, DCS, or APC replies of any shape. Also gates recording, attention ([alert.md](alert.md)), and untouched state ([layout.md](layout.md)). +- **`inputIsReplayTerminalReport`** — dropped outright while `isReplaying` (rationale). Shapes: cursor-position / device-status (`CSI [?] R` / `n`), device attributes (`CSI [?>=] c`), window-manipulation reports (`CSI t` / `x`), DECRQSS and XTSMGRAPHICS reports (`CSI [?] $y` and `CSI ? S`), focus in/out (`CSI I` / `CSI O`), kitty keyboard-query replies (`CSI ? u`), and OSC, DCS, or APC replies of any shape. Also gates recording, attention ([alert.md](alert.md)), and untouched state ([layout.md](layout.md)). - **`inputIsSyntheticTerminalReport`** — the broader prompt-recording guard (any chunk built only of CSI, SS3 `ESC O `, OSC, or APC tokens). **Never dropped, and must suppress input recording alone** — these sequences can encode real keys. - **`stripMouseReportsFromInput`** — removes X10 (`CSI M <3 bytes>`), SGR (`CSI < b;x;y M/m`) and urxvt (`CSI b;x;y M`) mouse reports during mouse-mode override, so reports bypassing DOM interception never reach the PTY ([mouse-and-clipboard.md](mouse-and-clipboard.md)). Keyboard-attention gating: `docs/specs/alert.md` → Attention. @@ -170,10 +170,10 @@ The identity provokes more iTerm2 escape codes than Dormouse implements, so **un | Shell | Mechanism | Channel | Notes | |---|---|---|---| -| zsh | `ZDOTDIR` → our dotfiles chain to the user's, then install `precmd`/`preexec` hooks | env (as reliable as the `PATH` prepend) | **Nothing may be written into our directory when shipped** — signed macOS app bundle (rationale). The user's real `ZDOTDIR` rides in `USER_ZDOTDIR`; our `.zshrc` hands `ZDOTDIR` back so `.zlogin` and child shells are unaffected, and `.zshenv`/`.zprofile` re-pin `ZDOTDIR` to ours after sourcing the user's. **A `HISTFILE` set inside our directory is redirected to `USER_ZDOTDIR`** after sourcing the user's rc; a user-set one is never touched. | -| bash | `--init-file` → our script installs a `DEBUG`-trap / `PROMPT_COMMAND` hook | shellArgs | Dormouse drops `-l` (mutually exclusive with `--init-file`); the script sources `/etc/profile` + the user's profile itself. **Injected only when the launch args are *purely* interactive/login flags** (`-i`/`-l`/`--login`), so Git Bash's `--login -i` is covered and a specific `-c ` is not (rationale). **Written for bash 3.2**: no `PS0`, no array `PROMPT_COMMAND`. **`E` is a pipeline's first simple command**; boundaries and exit codes stay exact. | -| PowerShell | dot-source a script that wraps the user's `prompt` and PSReadLine's `PSConsoleHostReadLine`; covers `pwsh` and `powershell.exe` | shellArgs | **`-NoProfile` is never passed**, so the user's profile defines their prompt before we wrap it. Injected for any **interactive** launch — a bare REPL gets `-NoExit -Command ". '