Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions docs/specs/dor-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,16 +199,35 @@ Source of truth: `toolTakesOverCaller` / `toolRerunsInCaller` / `callerStillPlac

## OSC 367

**Must consume OSC 367 at the PTY owner's parser**, including malformed and unknown verbs, and emit no reply. `serve` is the only implemented verb. The escape registry is `docs/specs/terminal-escapes.md`.
**Must consume OSC 367 at the PTY owner's parser**, including malformed and unknown verbs, and emit no reply. `serve` and `state` are implemented verbs. The escape registry is `docs/specs/terminal-escapes.md`.

- **Must sanitize and bound the payload before retaining it.** `ToolAnnounce` and `parseToolAnnounce` own the field shapes and validation limits.
- **Must sanitize and bound the payload before retaining it.** `ToolAnnounce` / `parseToolAnnounce` and `ToolState` / `parseToolState` own the field shapes and validation limits.
- **Must treat an optional serve `path` as a path/query on the discovered port, never as another authority.** Accept at most 2,048 characters starting with one `/`, with no backslash, ASCII whitespace/control, or DEL; invalid paths are ignored and the default is `/`. The port still must belong to the designated Session's process tree. Live binding memory includes the path; durable saves omit it.
- **Must forward parsed announcements and command-start resets in stream order to the owning renderer.** A start clears the previous command's announcement; a later serve in the same chunk survives. Standalone uses `terminal:protocolEvents`; VS Code uses nullable `terminal:toolAnnounce` scoped to the owning webview, with null clearing the hint. The fake adapter applies locally.
- **Must reconstruct announcements and resets from raw replay without emitting replies**, preserving transferred announcements when since-mark replay has no command start, and clear the renderer record on Session disposal. Ordinary terminal announcements stay inert.
- **Must forward parsed announcements, state reports, and command-start resets in stream order to the owning renderer.** A start clears the previous command's announcement and unsaved state; later reports in that chunk survive. Standalone uses `terminal:protocolEvents`; VS Code uses nullable `terminal:toolAnnounce` and `terminal:toolState` scoped to the owning webview, with null clearing the corresponding record. The fake adapter applies locally.
- **Must reconstruct announcements, state, and resets from raw replay without emitting replies**, preserving transferred announcements when since-mark replay has no command start, and clear the renderer record on Session disposal. Ordinary terminal announcements stay inert.
- Reserved: **Must retain `name`, `dehydrate`, and `persist` as inert parsed fields**, serving the announced-name and D1/D2 items under [Future](#future). Neither `persist: never` nor a `dehydrate` verb changes current persistence.
- Reserved: **Never assign a third OSC 367 verb**; `dehydrate` belongs to D2 under [Future](#future), while existing title/progress protocols keep those roles.
- Reserved: **Never assign an OSC 367 verb beyond `serve`, `state`, and `dehydrate`**; `dehydrate` belongs to D2 under [Future](#future), while existing title/progress protocols keep those roles.

Source of truth: `TerminalProtocolParser` / `collectTerminalProtocolAlerts` in `lib/src/lib/terminal-protocol.ts`; `parseToolAnnounce` in `lib/src/lib/tool-announce.ts`; `recordToolAnnounce` in `lib/src/lib/tool-announce-store.ts`; `createOwnerPtyStream` in `vscode-ext/src/message-router.ts`; `ownerStream` in `lib/src/host/remote/sidecar-entry.ts`. Tests: `lib/src/lib/tool-announce.test.ts`, `standalone/scripts/dev-agent-browser-announce.test.mjs`.
Source of truth: `TerminalProtocolParser` / `collectTerminalProtocolAlerts` in `lib/src/lib/terminal-protocol.ts`; `parseToolAnnounce` in `lib/src/lib/tool-announce.ts`; `recordToolAnnounce` in `lib/src/lib/tool-announce-store.ts`; `recordToolEvents` in `lib/src/lib/tool-events.ts`; `createOwnerPtyStream` in `vscode-ext/src/message-router.ts`; `ownerStream` in `lib/src/host/remote/sidecar-entry.ts`. Tests: `lib/src/lib/tool-announce.test.ts`, `standalone/scripts/dev-agent-browser-announce.test.mjs`.

## Unsaved changes

**Must accept a Tool's `OSC 367;state;{"v":1,"dirty":true}` report as unsaved state**, with `false` reporting clean. Require version 1 and a boolean; malformed, oversized, and unknown-version reports leave the last state unchanged. State reports never change serving hints, Tool identity, or designation; ordinary terminal reports have no dirty UI.

**Must distinguish unreported state from clean.** Start unknown, update immediately on valid reports, and return to unknown on command start, explicit restart, or Session disposal. Command completion is not a save: retain its last report until reset. Serve announcements never clear unsaved state.

**Must retain unsaved state through minimize/reattach, renderer changes, and live Workspace transfer.** Never write it to durable session metadata; a cold-started Tool reports its own new state. Layout owns the Pane and Door indicator under `docs/specs/layout.md` → Pane header.

**Must treat this state as indication only.** It does not write files, acknowledge a save, change kill/close behavior, or authorize automatic reaping. Save coordination and close protection are under [Future](#future).

A Tool writes reports to its terminal output, for example:

```sh
printf '\033]367;state;{"v":1,"dirty":true}\033\\'
printf '\033]367;state;{"v":1,"dirty":false}\033\\'
```

Source of truth: `parseToolState` in `lib/src/lib/tool-state.ts`; `getToolDirty` / `recordToolDirty` in `lib/src/lib/tool-dirty-store.ts`; `recordToolEvents` in `lib/src/lib/tool-events.ts`; `ToolDirtyIndicator` in `lib/src/components/ToolDirtyIndicator.tsx`. Tests: `lib/src/lib/tool-state.test.ts`, `lib/src/components/wall/SurfacePaneHeader.test.tsx`, `lib/src/components/Baseboard.test.tsx`.

## Security

Expand Down Expand Up @@ -236,6 +255,8 @@ Source of truth: `PersistedToolMetadata` in `lib/src/lib/session-types.ts`; `sav

**Scope: dor-tools** — remaining design, in implementation order.

- **Save coordination and close protection.** Optional save requests with completion/failure and protection against clearing newer edits; dirty-aware Pane/Workspace/app closure. Establish an explicit safe-to-stop contract before automatic reaping; clean or unknown state alone is insufficient.

- **D1 — reaping without cooperation.** Idle-threshold reap +
rehydrate-from-args + `persist: "never"`: every stateless tool, no new API,
no Windows question.
Expand Down
8 changes: 5 additions & 3 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Panes are separated by a 7px gap (`PANE_GUTTER_PX`), odd so the 1px selection ri

### Pane header

**Must keep a Tool's unsaved-change dot visible in its Pane header and minimized Door**, across header widths and terminal/browser faces. Use inherited foreground, without animation; label and tooltip it “Unsaved changes”. Never hide it inside browser overflow controls or replace Kill. State semantics belong to `docs/specs/dor-tool.md` → Unsaved changes.

A 30px header doubling as a drag handle: **a `pointerdown` past a 5px threshold begins a Lath pane drag**; below the threshold the header's own click behavior stands. It uses `cursor-grab` / `active:cursor-grabbing`, `select-none`, the shared terminal top radius from `lib/src/components/design.tsx`, and the `--color-header-active-*` / `--color-header-inactive-*` token pairs (VSCode file-tree list colors).

**Must use browser chrome for a serving Tool, with a Terminal Context disclosure for its serving terminal.** Tool composition belongs to `docs/specs/dor-tool.md` → Lifecycle.
Expand Down Expand Up @@ -119,10 +121,10 @@ A browser header, including a Tool's (Terminal Context sits outside the measured
|---|---|
| 420px | Split and zoom hidden. |
| 360px | Navigation hidden. |
| 180px | Chrome moves into a viewport-clamped popover behind one trigger; minimize and kill stay inline. |
| 72px | Minimize and kill join the popover. |
| 180px | Chrome moves into a viewport-clamped popover behind one trigger; minimize/kill stay inline. |
| 72px (80px with an unsaved-change dot) | Minimize and kill join the popover. |

**Must reclamp the popover when its content resizes and keep it keyboard reachable** (focus enters on open, Tab stays inside, Escape returns it to the trigger) **and dismiss it on a pane resize or, without restoring focus, when its Surface is hidden**; otherwise `lib/src/components/wall/use-dismiss-overlay.ts` applies, and a control inside dismisses only after its action ran. The trigger shows a filled notepad glyph and the note count while the Surface has notes; long keys and connection labels truncate before controls.
**Must reclamp the popover on content resize and keep it keyboard reachable** (focus enters on open, Tab stays inside, Escape returns it to the trigger) **and dismiss it on resize, when a dirty report moves minimize/kill controls (restoring trigger focus), or when its Surface is hidden (without restoring focus)**; `lib/src/components/wall/use-dismiss-overlay.ts` handles other dismissal, and controls dismiss only after acting. With notes, the trigger shows a filled notepad glyph and count; keys and connection labels truncate before controls.

Source of truth: `SurfacePaneHeader` in `lib/src/components/wall/SurfacePaneHeader.tsx`; `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx`; `useHeaderTier` in `lib/src/components/wall/use-header-tier.ts`; `lib/src/components/wall/SurfacePaneHeader.test.tsx`; `lib/src/components/wall/TerminalPaneHeader.test.tsx`; `lib/src/stories/BrowserChromeHeader.stories.tsx`.

Expand Down
3 changes: 2 additions & 1 deletion docs/specs/terminal-escapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ Replay (`pty:replay`) is the raw stream requiring re-parse: **the webview runs a
| `OSC 633 ; P ; Cwd=<cwd> ST` | CWD (VS Code) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
| `OSC 777 ; notify ; <title> ; <body> ST` | rxvt/WezTerm notification | [alert.md](alert.md#terminal-reports) |
| `OSC 367 ; serve ; <json> ST` | Dor Tool announcement: selects a bound port and optional same-origin path, plus a reserved name and runtime re-key | [dor-tool.md](dor-tool.md#osc-367) |
| `OSC 367 ; <any other verb> ST` | Reserved for the staged `dehydrate` verb; consumed and ignored. | [dor-tool.md](dor-tool.md#osc-367) |
| `OSC 367 ; state ; <json> ST` | Tool-reported unsaved state | [dor-tool.md](dor-tool.md#unsaved-changes) |
| `OSC 367 ; <any other verb> ST` | Includes the reserved `dehydrate` verb; consumed and ignored. | [dor-tool.md](dor-tool.md#osc-367) |
| `OSC 1337 ; CurrentDir=<cwd> ST` | CWD (iTerm2 compatibility) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
| `OSC 1337 ; File=...:<data> ST` / `MultipartFile=...` / `FilePart=...` / `FileEnd` | iTerm2 inline image protocol (IIP); passed through to ImageAddon. | [Inline graphics](#inline-graphics) |
| `OSC 1337 ; ReportCellSize ST` | iTerm2 cell-size query; passed through and answered by the owner's ImageAddon. | [Inline graphics](#inline-graphics) |
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ Transport constraints:
| Host → webview | `pty:openPorts` | `ports: OpenPort[]` (`{ protocol, family, address, port, pid, processName }`), de-duplicated by `(family, address, port)`, sorted by port then address. Empty when the PTY is gone or enumeration fails. |
| Host → webview | `pty:data` | PTY output after state-driving supported OSCs are parsed/stripped; `OSC 8` and ImageAddon's inline-image `OSC 1337` forms are preserved for xterm.js, routed only to the owning router. **Carries an optional `textData`** (string-control payloads removed, for the prompt heuristic), **omitted when it would equal `data`**. |
| Host → webview | `terminal:semanticEvents` | Normalized CWD / prompt-command / title events the owner's parser derived, in stream order. |
| Host → webview | `terminal:protocolEvents` | Standalone notification/progress delivery and ordered Tool announcements/resets (`docs/specs/dor-tool.md` → OSC 367). VS Code keeps its `AlertManager` in the extension host. |
| Host → webview | `terminal:protocolEvents` | Standalone notification/progress delivery and ordered Tool announcements/state/resets (`docs/specs/dor-tool.md` → OSC 367). VS Code keeps its `AlertManager` in the extension host. |
| Webview → host | `dormouse:themeColors` (VS Code) / `pty_theme_colors` (standalone) | Resolved foreground / background / cursor, so the owner's parser can answer OSC 10/11/12. |
| Host → webview | `pty:replay` | Buffered raw output since spawn; the webview runs a one-shot parser over it, the only re-parse there is. |
| Host → webview | `dormouse:newTerminal` | May carry `shell`, `args`, display `name`, `replaceUntouched`, `announce`. The webview replaces the selected untouched terminal in place only when `replaceUntouched` is true, otherwise spawns a new pane. |
Expand Down Expand Up @@ -372,6 +372,6 @@ Source of truth: `TerminalContextRequest` in `lib/src/lib/terminal-context-types

## Tool transport

**Must forward parsed OSC 367 announcements from the PTY owner to its renderer**, without reparsing live display bytes or answering from a viewer (`docs/specs/dor-tool.md` → OSC 367). Tool persistence follows `docs/specs/dor-tool.md` → Persistence and hosts.
**Must forward parsed OSC 367 announcements and state from the PTY owner to its renderer**, without reparsing live display bytes or answering from a viewer (`docs/specs/dor-tool.md` → OSC 367). Tool persistence follows `docs/specs/dor-tool.md` → Persistence and hosts.

Source of truth: `ExtensionMessage` in `vscode-ext/src/message-types.ts`; `ownerStream` in `lib/src/host/remote/sidecar-entry.ts`.
43 changes: 43 additions & 0 deletions docs/testing/dor-tool-dirty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Tool dirty-indication QC

Branch: `dor-tool-dirty`, stacked on `dor-tool-qc`.

## Scope

Tools report unsaved state with `OSC 367;state;{"v":1,"dirty":true|false}`.
This pass adds indication; save coordination and close protection remain future work.

## Plan

Run source-mutating root tests before starting innerdogfood. Start the real
sidecar/staged CLI in a visible `dor ensure` pane, with an isolated XDG config.
Use `scripts/dor-tool-qc/server.mjs --announce --dirty-controls` as a user Tool.
All credentials and captures stay in the ignored `standalone/src-tauri/target/`
fixture directory and credentials are deleted at shutdown.

| Case | Expected | Result |
| --- | --- | --- |
| Unknown and clean | No dot; neither invents an unsaved report | Pass live: initial unknown and explicit clean both show no dot; tri-state distinction is unit-tested. |
| Dirty / clean updates | Dot updates immediately, accessible name and tooltip “Unsaved changes” | Pass live: 6px dot appears/clears with Tool output; role, label and tooltip verified. |
| Ordinary terminal output | State report never creates Tool identity or dirty chrome | Pass live: identical state output in the plain terminal creates no indicator. |
| Serving | State updates leave URL and browser process intact | Pass live: state-only signal retains page location and entered browser text. |
| Narrow pane | Dirty dot remains outside overflow controls and Kill stays usable | Pass live at 103px: dot remains 6px and outside popup; Kill available inside popup. |
| Minimize / reattach | Door shows the dot; updates while minimized; restored Pane agrees | Pass live: Door updates while minimized; reattached Pane retains state and page input. |
| Command exit / restart | Last dirty report remains on terminal face; new command returns to unknown | Pass live: dirty remains after Ctrl-C on terminal face; keyed restart keeps Surface and clears old state. |
| Live reload / transfer | Ordered replay and volatile transfer retain state; no durable dirty field | Live reload passed; automated tests cover both true/false Workspace transfer, since-mark replay and durable omission. Native-window transfer not exercised live. |
| Malformed output | Invalid type/version, oversized payload, or unknown verb cannot clear state | Pass automated: strict parsing and ordered stream tests retain state on invalid output. |

## Results

- Full `pnpm test` passes, including 3,569 lib tests, 228 standalone frontend tests, and 180 VS Code tests; the root lint/self-tests pass too.
- Library production TypeScript/CSS build and host typechecks pass.
- Protocol/lifecycle review found no actionable issues.
- Local screenshots: `dirty-pane.png` and `dirty-narrow.png` in the ignored fixture directory. `live.json` and `after-restart.json` capture the same Tool identity across its restart.
- The root loopback lint caught the earlier QC server once tracked. Its explicit allowlist entry documents that it is an unshipped generated-content fixture with no file, credential, or command API.

Native host rendering, cold restart, and cross-window transfer were not exercised through this browser harness. Automated coverage checks transfer and reset semantics.

## Cleanup

The fixture Tool was closed and its listener PID verified exited. The harness
was stopped and private CLI credentials deleted.
56 changes: 56 additions & 0 deletions lib/src/components/Baseboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ vi.mock('../lib/platform', () => ({
getPlatformOrNull: () => ({ alertPublishSettings: vi.fn(), notepadArchive: {} }),
}));

import { recordToolDirty, resetToolDirty } from '../lib/tool-dirty-store';
import { Baseboard } from './Baseboard';
import { installLocalStorageStub } from '../lib/test-local-storage';
import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS, getAlertSettings } from '../lib/alert-settings';
Expand Down Expand Up @@ -369,3 +370,58 @@ describe('Baseboard Door notepad', () => {
expect(onReattach).not.toHaveBeenCalled();
});
});


describe('Baseboard Tool unsaved changes', () => {
afterEach(() => act(() => resetToolDirty()));

it('refits Door overflow when a live dirty report changes a measured width', () => {
vi.stubGlobal('ResizeObserver', class {
constructor(private callback: ResizeObserverCallback) {}
observe(target: HTMLElement) {
const width = target.classList.contains('h-7') ? 244 : 72;
this.callback([{ target, contentRect: { width } } as ResizeObserverEntry], this as unknown as ResizeObserver);
}
disconnect() {}
});
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
if (this.getAttribute('role') !== 'group') return 16;
return this.querySelector('[aria-label="Unsaved changes"]') ? 94 : 80;
});
const id = 'dirty-door-fit';
act(() => root.render(<Baseboard items={[
{ id, kind: 'tool', title: 'Editor' },
{ id: 'other-door', kind: 'terminal', title: 'Shell' },
]} onReattach={() => {}} />));
expect(container.querySelectorAll('[data-door-id]')).toHaveLength(2);
act(() => recordToolDirty(id, true));
expect(container.querySelectorAll('[data-door-id]')).toHaveLength(1);
act(() => recordToolDirty(id, false));
expect(container.querySelectorAll('[data-door-id]')).toHaveLength(2);
});

it('updates a terminal-faced Tool Door and suppresses reports for other kinds', () => {
const id = 'dirty-door';
const renderKind = (kind: 'tool' | 'terminal' | 'browser') => act(() => root.render(
<Baseboard items={[{ id, kind, title: 'Editor' }]} onReattach={() => {}} />,
));
const visibleDot = () => container.querySelector(`[data-door-id="${id}"] [aria-label="Unsaved changes"]`);
renderKind('tool');
expect(visibleDot()).toBeNull();
act(() => recordToolDirty(id, true));
expect(visibleDot()).not.toBeNull();
// The hidden measurement pass and the visible Door must grow together.
expect(container.querySelectorAll('[role="img"][aria-label="Unsaved changes"]')).toHaveLength(2);
expect(container.querySelector(`[data-door-id="${id}"]`)?.getAttribute('aria-label')).toContain('Unsaved changes');
renderKind('terminal');
expect(visibleDot()).toBeNull();
renderKind('browser');
expect(visibleDot()).toBeNull();
renderKind('tool');
expect(visibleDot()).not.toBeNull();
act(() => recordToolDirty(id, false));
expect(visibleDot()).toBeNull();
act(() => recordToolDirty(id, null));
expect(visibleDot()).toBeNull();
});
});
Loading