diff --git a/.github/workflows/rust-lite-ci.yml b/.github/workflows/rust-lite-ci.yml
index b008bd6..965e750 100644
--- a/.github/workflows/rust-lite-ci.yml
+++ b/.github/workflows/rust-lite-ci.yml
@@ -4,14 +4,15 @@ on:
pull_request:
paths:
- "rust/**"
- - "lite-rs/**"
- - "src/**"
- "lite/**"
- "herdr-plugin.toml"
- - "docs/rust-lite-parity-proof.md"
- - "scripts/parity-lite.py"
- - "scripts/parity-lite.sh"
- - "scripts/smoke-rust-lite.sh"
+ - "herdr-annotate.version"
+ - "scripts/fetch-herdr-annotate.*"
+ - "scripts/lite-goldens/**"
+ - "scripts/lite-regression.py"
+ - "scripts/lite-regression.sh"
+ - "scripts/smoke.sh"
+ - "scripts/stage-local.sh"
- ".github/workflows/rust-lite-*.yml"
merge_group:
@@ -51,7 +52,7 @@ jobs:
workspaces: rust
- run: cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings
- parity:
+ regression:
strategy:
fail-fast: false
matrix:
@@ -64,10 +65,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- - uses: oven-sh/setup-bun@v2
- with:
- bun-version: 1.3.14
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- - run: bash scripts/parity-lite.sh
+ - run: bash scripts/lite-regression.sh
diff --git a/.github/workflows/rust-lite-release.yml b/.github/workflows/rust-lite-release.yml
index e16bc4e..82392e2 100644
--- a/.github/workflows/rust-lite-release.yml
+++ b/.github/workflows/rust-lite-release.yml
@@ -16,7 +16,7 @@ jobs:
run: |
version=$(sed -n 's/^version = "\(.*\)"/\1/p' rust/Cargo.toml | head -1)
test "rust-lite-v$version" = "${GITHUB_REF_NAME}"
- test "$version" = "$(tr -d '[:space:]' < lite-rs/herdr-annotate.version)"
+ test "$version" = "$(tr -d '[:space:]' < herdr-annotate.version)"
build:
needs: verify
diff --git a/.github/workflows/windows-full-ci.yml b/.github/workflows/windows-full-ci.yml
index 8743e51..cdebe58 100644
--- a/.github/workflows/windows-full-ci.yml
+++ b/.github/workflows/windows-full-ci.yml
@@ -4,10 +4,13 @@ on:
pull_request:
paths:
- "herdr-plugin.toml"
+ - "herdr-annotate.version"
- "plannotator-tui.version"
+ - "scripts/fetch-herdr-annotate.*"
- "scripts/fetch-plannotator-tui.*"
- "scripts/plannotator-tui.sh"
- "scripts/smoke.sh"
+ - "scripts/test-fetch-herdr-annotate.*"
- "scripts/test-fetch-plannotator-tui.*"
- "scripts/test-herdr-windows-plugin.ps1"
- "scripts/test-http-server.py"
@@ -33,6 +36,8 @@ jobs:
python-version: "3.11"
- name: local override and idempotence
run: ./scripts/test-fetch-plannotator-tui.ps1 -Case LocalOverride
+ - name: native lite local override and idempotence
+ run: ./scripts/test-fetch-herdr-annotate.ps1 -Case LocalOverride
- name: loopback download and checksum preservation
run: ./scripts/test-fetch-plannotator-tui.ps1 -Case Download
- uses: actions/checkout@v4
@@ -59,3 +64,4 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: bash scripts/test-fetch-plannotator-tui.sh
+ - run: bash scripts/test-fetch-herdr-annotate.sh
diff --git a/.gitignore b/.gitignore
index 9096fb9..c074431 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,4 @@
-node_modules/
.DS_Store
bin/*
!bin/.gitkeep
/rust/target/
-/lite-rs/bin/*
-!/lite-rs/bin/.gitkeep
diff --git a/README.md b/README.md
index ef10a3b..910dd18 100644
--- a/README.md
+++ b/README.md
@@ -17,16 +17,17 @@ Annotate inside [Herdr](https://github.com/herdrdev/herdr): comment on any termi
## Requirements
- Herdr 0.8.0 or later
-- [Bun](https://bun.sh/)
- macOS, Linux, or Windows
+There is no runtime to install. Both installs download a small prebuilt `herdr-annotate` binary and verify its SHA-256 checksum.
+
On Linux, install `wl-clipboard`, `xclip`, or `xsel` for clipboard access.
-On Windows, native Herdr plugin support is preview/best-effort. Bun must be on `PATH`. Clipboard access uses PowerShell; no extra clipboard package is required. The install, keybinding, configuration check, reload, and use instructions below also apply on Windows.
+On Windows, native Herdr plugin support is preview/best-effort. Clipboard access uses PowerShell; no extra clipboard package is required. The install, keybinding, configuration check, reload, and use instructions below also apply on Windows.
## Install
-Pick one. Installing the other later just swaps it (same plugin id).
+Pick one. Installing the other later just swaps it (same plugin id). An install stays on the commit it came from; run the same command again to move to the current release.
@@ -211,20 +212,28 @@ Herdr Annotate reads text that Herdr copies to the system clipboard. The plugin
## Development
+The plugin is a Rust binary in `rust/`.
+
```sh
-bun install
-bun test
-bun run typecheck
-herdr plugin link "$PWD"
+cargo test --manifest-path rust/Cargo.toml
+cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings
+bash scripts/stage-local.sh # build and stage bin/herdr-annotate.exe
+herdr plugin link "$PWD" # or "$PWD/lite" for the Lite variant
```
+`herdr plugin link` does not run manifest build hooks, so stage the binary first. It also replaces
+any existing `annotate` link; link the other directory to switch back.
+
+`bash scripts/lite-regression.sh` checks the runtime against goldens recorded from the retired Bun
+runtime. [docs/lite-testing.md](docs/lite-testing.md) covers what it compares and everything else
+that guards Lite.
+
To test a local plannotator-tui build instead of the pinned release, put it in `bin/`
before linking: `PLANNOTATOR_TUI_BIN=/path/to/plannotator-tui bash scripts/fetch-plannotator-tui.sh`.
-`herdr plugin link` replaces any existing `annotate` link; link the other directory to switch back.
Before a release, `HERDR_SESSION= bash scripts/smoke.sh` installs fresh, upgrades
-from the first shipped commit, installs lite, swaps to full, and opens the review pane, then restores
-whatever was installed.
+from the first shipped commit, installs lite, swaps to full, and opens the manager and review panes,
+then restores whatever was installed.
## Neovim integration
diff --git a/bun.lock b/bun.lock
deleted file mode 100644
index f48dabc..0000000
--- a/bun.lock
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "lockfileVersion": 1,
- "configVersion": 1,
- "workspaces": {
- "": {
- "name": "herdr-annotate",
- "devDependencies": {
- "@types/bun": "latest",
- "typescript": "^5.9.2",
- },
- },
- },
- "packages": {
- "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
-
- "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
-
- "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
-
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
-
- "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
- }
-}
diff --git a/docs/lite-testing.md b/docs/lite-testing.md
new file mode 100644
index 0000000..921dc1a
--- /dev/null
+++ b/docs/lite-testing.md
@@ -0,0 +1,83 @@
+# How Lite is tested
+
+Herdr Annotate Lite is one native Rust binary. Its behavior was defined by the Bun runtime this
+repository shipped through 0.3.0, and that definition is still enforced, by recording rather than
+by running both runtimes side by side.
+
+## Recorded goldens
+
+`scripts/lite-regression.py` drives all six entrypoints the Lite manifest declares and compares
+every observable with a recording checked in under `scripts/lite-goldens/`. The recording was taken
+from the Bun runtime at commit `2add0da`, the last commit that still contained `src/`, so a green
+run means the native runtime still does what Bun did.
+
+```sh
+bash scripts/lite-regression.sh
+```
+
+The wrapper builds and stages a release binary, then checks it. `rust-lite-ci` runs the same
+command on Ubuntu and macOS.
+
+### What one run compares
+
+- Process cases: exit code, stdout, stderr, every fake `herdr` and clipboard call with its
+ arguments, notification arguments, clipboard bytes, pending bytes and mode, and the resulting
+ filesystem tree.
+- Screen cases: real PTYs at 86x22 (editor) and 98x28 (manager). The ANSI model in the harness
+ ignores style escapes and retains the terminal cell grid, including wide-character continuation
+ cells. It snapshots the first frame and the frame after every input, and compares every OSC 52
+ clipboard sequence the pane emitted, in order. Where a case compares clipboard bytes, the last
+ sequence's payload must equal the bytes the native writer was handed.
+- Store cases: JSONL bytes, file modes, and leftover lock and temporary files after scripted editor
+ and manager mutations.
+- `store.cross-read`: exports `scripts/lite-goldens/fixtures/bun-editor-annotations.jsonl`, the
+ exact record the Bun editor wrote in the recorded run, so the on-disk format stays readable.
+- Manifests: `lite/herdr-plugin.toml` and the root Full manifest must agree on the header and on
+ every action and pane declaration, and neither may gate the annotation tools to a platform.
+- Error catalog: every user-visible message the Bun runtime produced still exists in the native
+ source file that owns it.
+- Key coverage: the run fails unless it sent every editor and manager key in the required set.
+
+The only normalized values are each run's temporary roots, the repository root, the per-user
+handoff directory, generated UUIDs, generated ISO timestamps, and the pid and time components of
+pending and temporary filenames. Seeded timestamps stay literal. Screen cells and clipboard bytes
+are never normalized.
+
+### Clipboard adapters
+
+One clipboard operation walks the platform's candidate list until an adapter works, so the goldens
+record the role (`clipboard-read`, `clipboard-write`) instead of the program and hold on every
+platform. The adapters themselves, their arguments, and the fallback order are checked against
+`CLIPBOARD_CANDIDATES` in the harness, which also requires a run to exercise both a first-adapter
+success and the full chain. macOS therefore covers `pbpaste` and `pbcopy`, and Ubuntu covers
+Wayland, then xclip, then xsel.
+
+### Re-recording
+
+`bash scripts/lite-regression.sh --record` rewrites the goldens from the current binary. Do that
+only for a deliberate behavior change, and read the diff: every rewritten line is a behavior the
+retired runtime no longer defines.
+
+## Everything else
+
+| Check | Command |
+|---|---|
+| Unit and subprocess tests | `cargo test --manifest-path rust/Cargo.toml` |
+| Lints and formatting | `cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings`, `cargo fmt --manifest-path rust/Cargo.toml --check` |
+| Manifest structure, including the Windows gates | `python3 scripts/test-windows-full-manifest.py` |
+| Binary fetchers | `bash scripts/test-fetch-herdr-annotate.sh`, `bash scripts/test-fetch-plannotator-tui.sh`, and the `.ps1` cases in `windows-full-ci` |
+| Install, upgrade, swap, and both panes in a live session | `HERDR_SESSION= bash scripts/smoke.sh` |
+
+## What retiring Bun cost
+
+The old harness ran Bun and Rust on every case in the same run, so a divergence surfaced the moment
+either side moved. The comparison is now against a fixed recording: it still catches a native
+regression, but it can no longer settle a *new* behavior against a live Bun reference, and a
+deliberate re-record is a human decision instead of a diff against a second implementation.
+
+`docs/rust-lite-parity.md` and `docs/rust-lite-parity-proof.md` mapped the TypeScript call paths to
+the Rust ones function by function. They are retired with the runtime they describe; read them at
+commit `2add0da`.
+
+Windows stays outside the PTY harness. It is covered by the Rust tests, the manifest and fetcher
+checks in `windows-full-ci`, and the manual checklist in `windows-full-acceptance.md`.
diff --git a/docs/rust-lite-parity-proof.md b/docs/rust-lite-parity-proof.md
deleted file mode 100644
index 08f523b..0000000
--- a/docs/rust-lite-parity-proof.md
+++ /dev/null
@@ -1,377 +0,0 @@
-# Rust Lite parity proof
-
-This is the re-runnable proof behind the Rust Lite evaluation. It maps the externally observable
-TypeScript Lite surface to the Rust call path at function granularity and names the differential
-case that compares the result. The current local result is:
-
-```text
-Parity Lite: 622 observables compared, 145 screens diffed, zero divergences / 1 deliberate
-```
-
-Run it from the repository root with `bash scripts/parity-lite.sh`. The shell wrapper stages a fresh
-release binary; `scripts/parity-lite.py` then runs the Bun entrypoints and the staged native binary
-against separate copies of the same fixtures. A failure retains its temporary inputs and unified
-diffs and prints their path. A green run removes its temporary workspace.
-
-## What the harness compares
-
-- Process cases compare exit code, stdout, stderr, every fake `herdr`/clipboard command and argument,
- notification arguments, clipboard bytes, pending bytes and mode, and resulting filesystem trees.
-- Manifest cases parse `lite/herdr-plugin.toml`, `lite-rs/herdr-plugin.toml`, and the root Full
- manifest and compare the declared entrypoints field by field, so a new action cannot reach one
- runtime only.
-- Screen cases use real PTYs at 86×22 (editor) and 98×28 (manager). The ANSI parser at
- `scripts/parity-lite.py:158` ignores style escapes but retains the terminal cell grid, including
- wide-character continuation cells. It snapshots the initial frame and the frame after every input.
-- Screen cases also retain the raw PTY byte stream and compare every OSC 52 clipboard sequence a
- pane emitted, in order (`scripts/parity-lite.py:388`). Where a case compares clipboard bytes, the
- payload of the last sequence is additionally required to equal the bytes the native writer received,
- so a runtime cannot emit a different copy to the terminal than it wrote to the clipboard.
-- Store cases byte-compare JSONL, modes, and leftover lock/temp files after scripted editor and
- manager mutations. `store.cross-read` makes Bun export the Rust editor's store and Rust export the
- Bun editor's store, then compares the Markdown and subprocess traces.
-- The only normalized values are each case's deliberately different temporary root, generated UUIDs,
- generated ISO timestamps, and pid/time components in pending and temporary filenames
- (`scripts/parity-lite.py:481`). Seed timestamps are allow-listed and remain literal. Product files
- are never rewritten or filtered. Screen cells and clipboard bytes are never normalized.
-- The harness runs on the host's real adapter branch. macOS therefore proves `pbpaste`/`pbcopy` and
- Ubuntu proves the Wayland → xclip → xsel chain. Windows is intentionally outside this PTY harness
- and remains in the separate build/promotion track.
-
-The parity surface is the six commands wired by the Lite manifest. Rust's single-binary dispatch
-usage error and `--version` output are packaging controls outside that surface; no valid manifest
-invocation reaches them. Conversely, TypeScript dynamic-import loader failures have no native
-counterpart because those modules are linked into the binary. Their underlying clipboard/store
-failures do have mapped counterparts below.
-
-## Entrypoints
-
-### `capture`
-
-| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence |
-|---|---|---|---|
-| Manifest invocation | `lite/herdr-plugin.toml:17` starts `bun ../src/capture.ts`. | `lite-rs/herdr-plugin.toml:27` starts `herdr-annotate capture`; `rust/src/main.rs:3` passes argv to `cli::run` at `rust/src/cli.rs:33`, then `capture` at `rust/src/cli.rs:65`. | Every `process.capture.*` case. |
-| Context decode | Top-level `src/capture.ts:12` parses `HERDR_PLUGIN_CONTEXT_JSON`; invalid JSON falls back to an empty object. `parseInvocationContext` and `selectedTextFromInvocation` are `src/types.ts:44` and `src/types.ts:58`. | `invocation_context` at `rust/src/cli.rs:58`, then `parse_invocation_context` and `selected_text_from_invocation` at `rust/src/types.rs:85` and `rust/src/types.rs:101`. | `process.capture.context`, `process.capture.invalid-context`. |
-| Required paths | `stateDir`/`pluginRoot` at `src/paths.ts:27` and `src/paths.ts:32`, checked at `src/capture.ts:21`. | `state_dir`/`plugin_root` at `rust/src/paths.rs:29` and `rust/src/paths.rs:36`, checked at `rust/src/cli.rs:69`. | `process.capture.missing-state`, `process.capture.missing-root`. |
-| Selection precedence | Invocation selection at `src/capture.ts:15`; if absent, `takeHandoff` at `src/handoff.ts:24`; if absent/blank/stale, `readClipboard` at `src/clipboard.ts:48`. | The same branches at `rust/src/cli.rs:71`, through `take_default_handoff`/`take_handoff` at `rust/src/handoff.rs:60`/`:32`, then `read_clipboard` at `rust/src/clipboard.rs:93`. | `process.capture.context` seeds all three sources and leaves the skipped handoff untouched; `process.capture.handoff` seeds a competing clipboard; stale, blank, invalid-UTF-8, and clipboard cases exercise the remaining decisions. |
-| Empty selection | `src/capture.ts:38` sends `Nothing to annotate`, creates no pending file, exits 0. | `rust/src/cli.rs:78` sends the same notification and returns success. | `process.capture.empty`. |
-| Pending record | `src/capture.ts:42` creates the state directory; `:44` constructs the record; `:49` names it; `:52` writes it. | `rust/src/cli.rs:85` constructs it; `:92` names it; `write_pending` at `:247` writes it. | All successful capture cases compare literal JSON after generated time/name normalization and assert mode 0600. |
-| Editor pane | `runHerdr` at `src/herdr.ts:11` receives the argv built at `src/capture.ts:54`. Failure removes pending at `:74`, then the catch at `:78` notifies, prints, and exits 1. | `run_herdr` at `rust/src/herdr.rs:22` receives the argv at `rust/src/cli.rs:97`. Failure removes pending at `:117`; `cli::run` notifies at `:35`; `main` prints/exits 1 at `rust/src/main.rs:3`. | `process.capture.context` compares success argv; `process.capture.open-failure` compares cleanup, notification, stderr, and exit. |
-
-The structural difference is exception flow versus `Result`. Both converge on the same process
-contract: success and blank input exit 0; a defined failure produces one stderr line, one best-effort
-`Annotate failed` notification, and exit 1.
-
-### `copy-context`
-
-| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence |
-|---|---|---|---|
-| Manifest and state | `lite/herdr-plugin.toml:24` → top-level `src/export.ts:7`; state is required at `:8`. | `lite-rs/herdr-plugin.toml:34` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:33` → `copy_context` at `:124`. | Every `process.copy.*` case. |
-| Load and ordering | `loadAnnotations` at `src/store.ts:37` locks/parses; `newestFirstAnnotations` at `:32` reverses a copy; `src/export.ts:10` propagates load failure. | `load_annotations` at `rust/src/store.rs:70` locks/parses; `newest_first_annotations` at `:65` reverses clones; `rust/src/cli.rs:126` propagates failure. | Empty, populated, invalid-store, busy-lock, and stale-lock cases. |
-| Empty store | `src/export.ts:14` notifies `No annotations` / `There is nothing to copy yet.` and exits 0. | `rust/src/cli.rs:127` sends the same notification and returns success. | `process.copy.empty`, including the newly created state-directory mode. |
-| Markdown and clipboard | `formatAnnotations` at `src/format.ts:44`, then `writeClipboard` at `src/clipboard.ts:63`. | `format_annotations` at `rust/src/format.rs:67`, then `write_clipboard` at `rust/src/clipboard.rs:110`. | `process.copy.single`, `process.copy.populated`, `process.copy.no-clipboard`, and `store.cross-read`; clipboard bytes are unnormalized. |
-| Success/failure reporting | `src/export.ts:21` sends singular/plural `Annotations copied`; catch at `:25` sends `Copy failed`, prints, exits 1. | `rust/src/cli.rs:132` sends the same success notification; `cli::run` at `:38` sends `Copy failed`; `main` prints/exits 1. | Single and populated cases prove grammar; no-clipboard, invalid-store, busy-lock, and missing-state prove failure outputs and exits. |
-
-### `copy-archive`
-
-| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence |
-|---|---|---|---|
-| Manifest and state | `lite/herdr-plugin.toml:31` → top-level `src/export-archive.ts:76` → `main` at `:45`; state is required at `:47`. | `lite-rs/herdr-plugin.toml:41` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:33` → `copy_archive` at `:183`; state is required at `:184`. | Every `process.copy-archive.*` case. |
-| Shared workflow | `copyAndArchiveAnnotations` at `src/archive-workflow.ts:29` receives the same dependencies `src/manager.ts:228` injects, wired at `src/export-archive.ts:55`. | `copy_and_archive_annotations` at `rust/src/archive_workflow.rs:27` receives the dependencies `rust/src/manager.rs:556` injects, wired at `rust/src/cli.rs:191`. | `test/archive-workflow.test.ts` and `archive_workflow::tests` cover the workflow; `store.manager.copy-archive` covers the manager key; the process cases cover the action. |
-| Operation order | Load, format newest first, clipboard write, `appendArchivedSet`, then `removeAnnotationsById` — `src/archive-workflow.ts:32`-`:52`. | The same order at `rust/src/archive_workflow.rs:38`-`:65`. | `process.copy-archive.populated` byte-compares both JSONL stores, modes, and leftover lock/temp files afterwards, plus the unnormalized clipboard bytes. |
-| Empty store | The injected loader records the empty read at `src/export-archive.ts:58`; `copyArchiveReport` at `:39` notifies `No annotations` / `There is nothing to copy yet.` and exits 0, matching `export.ts`. | `Cell` flag at `rust/src/cli.rs:194`; `copy_archive_report` at `:170` sends the same notification and returns success. | `process.copy-archive.empty` compares the notification, exit 0, and the untouched state tree. |
-| Success reporting | `copyArchiveReport` at `src/export-archive.ts:25` sends singular/plural `Annotations copied and archived`. | `copy_archive_report` at `rust/src/cli.rs:157` builds the same title and body; `copy_archive` notifies at `:207`. | `process.copy-archive.populated`; `test/export-archive.test.ts` and `cli::tests::copy_archive_maps_every_outcome_to_its_notification_and_exit_status` pin both grammars. |
-| Failure reporting | A `stay_open` failure maps to `Copy and archive failed` at `src/export-archive.ts:42`; the retained-active partial maps to `Copy and archive incomplete` at `:32`. Both print the body and exit 1 at `:70`. | The same two arms at `rust/src/cli.rs:175` and `:165`; `copy_archive` returns the body at `:209` and `main` prints/exits 1. | `process.copy-archive.no-clipboard` and `process.copy-archive.missing-state` compare notification argv, stderr, exit code, and the unchanged stores; the retained-active arm is pinned by both unit tests. |
-
-### `manage`
-
-| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence |
-|---|---|---|---|
-| Manifest and root | `lite/herdr-plugin.toml:38` → `src/open-manager.ts:5` → `pluginRoot` at `src/paths.ts:32`. | `lite-rs/herdr-plugin.toml:48` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:33` → `manage` at `:214` → `plugin_root` at `rust/src/paths.rs:36`. | `process.manage.success`, `process.manage.missing-root`. |
-| Manager pane | The argv literal is `src/open-manager.ts:13`; `runHerdr` is `src/herdr.ts:11`. Errors notify/print/exit at `src/open-manager.ts:32`. | The argv literal is `rust/src/cli.rs:216`; `run_herdr` is `rust/src/herdr.rs:22`; `cli::run`/`main` handle notify, stderr, and exit. | Success, child-stderr failure, empty-child-stderr fallback, and missing-root cases. |
-
-### `editor`
-
-| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence |
-|---|---|---|---|
-| Manifest and pending selection | `lite/herdr-plugin.toml:46` → top-level `src/editor.ts:17`. `invocationContext` at `:20`; pending-file parse at `:34`; fallback `pendingAnnotationFromInvocation` at `src/types.ts:65`; parsed-file canonicalization at `src/types.ts:79`. | `lite-rs/herdr-plugin.toml:56` → dispatcher → `editor::run` at `rust/src/editor.rs:371`; `pending_from_env` at `:345`; invocation fallback and pending parsing at `rust/src/types.rs:107` and `:119`. | Missing/invalid process cases; `screen.editor.pending-file-save`; the other editor PTY cases use invocation fallback. |
-| Terminal and screen | `render` at `src/editor.ts:77` uses `sanitizeTerminalText`, `wrapText`, `layoutComment`, and width helpers; alternate-screen setup is `:207`. | `EditorApp::draw` at `rust/src/editor.rs:68` calls the corresponding helpers at `rust/src/format.rs:7`/`:26`, `rust/src/layout.rs:14`, and `rust/src/width.rs:35`; `editor::run` owns the Ratatui terminal. | Initial frame and every frame in `screen.editor.*`; fixed 86×22 cells include wide Hangul. |
-| Input and save | Key dispatch is `src/editor.ts:172`, with `resolveEditKey` consulted at `:181` before the single-character arm; vertical movement is `:52`; `save` is `:122`; successful save renders, waits 250 ms, and exits. | `EditorApp::handle_key` at `rust/src/editor.rs:169`, with `resolve_edit_key` consulted at `:181` and applied by `apply_edit_action` at `:228`; vertical movement at `:252`; `save` at `:278`; `run` at `:371` renders the saved state, waits 250 ms, and exits. | `screen.editor.edit-save`, empty-save, missing-state, Esc, and Ctrl+C cases. Store bytes are compared after save. |
-| Cleanup and signals | `cleanup`/`exit` at `src/editor.ts:110`/`:117`; SIGTERM/SIGHUP handlers at `:160`. | `Termination::install`/`requested` at `rust/src/termination.rs:17`/`:33`; the polling loop at `rust/src/editor.rs:371` reaches `ratatui::restore`. | `screen.editor.sigterm` compares exit 0 and the restored terminal grid. |
-
-### `manager`
-
-| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence |
-|---|---|---|---|
-| Manifest, state, initial load | `lite/herdr-plugin.toml:54` → `requireStateDir` at `src/manager.ts:30`; `reloadActive`/`reloadArchives` at `:55`/`:66`. | `lite-rs/herdr-plugin.toml:64` → dispatcher → `manager::run` at `rust/src/manager.rs:724`; `ManagerApp::load` at `:63`; reload methods at `:80`/`:95`. | `process.manager.missing-state`; initial screens in all manager PTY cases. |
-| Active screen | `render` at `src/manager.ts:198` → `renderActive` at `:95`, with `clipped` at `:77`, formatting helpers, newest-first state, source and timestamp metadata. | `ManagerApp::draw` at `rust/src/manager.rs:110` → `draw_active` at `:162`, with `clipped` at `:681` and `format_timestamp` at `:691`. | Every active-view snapshot, including the detail-width regression fixture in `screen.manager.all-views`. |
-| Archive screen | `render` → `renderArchives` at `src/manager.ts:138`; archive annotations are previewed newest first. | `ManagerApp::draw` → `draw_archives` at `rust/src/manager.rs:300`; same preview ordering and clipping. | Every archive-view snapshot and scripted archive mutation. |
-| Input and mutations | Top-level key dispatch is `src/manager.ts:404`; active/archive handlers are `:320`/`:346`; action functions are `:218`–`:318`. | `ManagerApp::handle_key` is `rust/src/manager.rs:434`; view handlers are `:466`/`:503`; action methods are `:547`–`:651`. | `screen.manager.all-views`, empty-actions, success-copy sessions, and the exit/signal sessions. Resulting JSONL and clipboard bytes are compared. |
-| Cleanup and signals | `cleanup`/`exit` at `src/manager.ts:378`/`:385`; signal handlers at `:392`. | `Termination` at `rust/src/termination.rs:17`; polling/restore at `rust/src/manager.rs:724`. | `screen.manager.sighup` compares exit 0 and restored cells. |
-
-## Every editor key
-
-All key paths clear the prior status before acting. TypeScript dispatch is `src/editor.ts:172`;
-Rust dispatch is `EditorApp::handle_key` at `rust/src/editor.rs:169`; both render again after the
-transition.
-
-| Key | TypeScript → Rust call path | Required state/store/screen effect | Harness step |
-|---|---|---|---|
-| Character input, including wide text | `src/editor.ts:199` uses `Array.from` and `splice` → `rust/src/editor.rs:215` calls `insert` at `:247`. | Insert Unicode scalar(s) at cursor, advance by character count, render using cell width. | `screen.editor.edit-save`: `chars`, `chars-second-line`. |
-| Enter | `src/editor.ts:196` → Rust `KeyCode::Enter` at `rust/src/editor.rs:214` → `insert`. | Insert `\n`, move cursor, preserve explicit blank/line layout. | `enter`. |
-| Backspace | `src/editor.ts:180` → `rust/src/editor.rs:187`. | If cursor > 0, remove the character before it and move left; otherwise no change. | `backspace`. |
-| Delete | `src/editor.ts:182` → `rust/src/editor.rs:193`. | Remove the character at cursor if present; cursor stays. | `delete`. |
-| Left / Right | `src/editor.ts:184`/`:186` → `rust/src/editor.rs:198`/`:199`. | Move one character, clamped to `[0, length]`. | `left`, `right`. |
-| Up / Down | `moveCursorVertical` at `src/editor.ts:52` → `move_cursor_vertical` at `rust/src/editor.rs:252`. | Preserve terminal-cell column as closely as possible on the adjacent line; clamp first/last row and never split a wide glyph. | `up`, `down`. |
-| Home / End | `src/editor.ts:192`/`:194` → `rust/src/editor.rs:202`/`:207`. | Move to start/end of the current logical line. | `home`, `end`. |
-| Word left (Option/Alt+Left, Ctrl+Left, Alt+B) | `resolveEditKey` at `src/edit-keys.ts:53` → `wordStart` at `:16`, applied at `src/editor.ts:182`. | `resolve_edit_key` at `rust/src/edit_keys.rs:111` → `word_start` at `:56`, applied at `rust/src/editor.rs:230`. | Skip whitespace back to the previous word start; a newline is crossed by itself in one step; a CJK run is one word. | `screen.editor.word-line-moves`: `alt-left`, `alt-b`, `ctrl-left`, `alt-left-word`, `alt-left-previous-word`, `alt-left-line-first-word`, `alt-left-over-newline`. |
-| Word right (Option/Alt+Right, Ctrl+Right, Alt+F) | `resolveEditKey` at `src/edit-keys.ts:53` → `wordEnd` at `:25`, applied at `src/editor.ts:184`. | `resolve_edit_key` at `rust/src/edit_keys.rs:111` → `word_end` at `:71`, applied at `rust/src/editor.rs:231`. | Skip whitespace forward to the next word end; a newline is crossed by itself in one step. | `screen.editor.word-line-moves`: `alt-f`, `alt-right`, `ctrl-right`, `alt-right-over-newline`, `alt-right-next-word`. |
-| Line start (Command/Super+Left, Ctrl+A) | `resolveEditKey` at `src/edit-keys.ts:53` (xterm modifier 9 via the raw sequence) → `lineStart` at `:33`, applied at `src/editor.ts:186`. | `resolve_edit_key` at `rust/src/edit_keys.rs:111` (`KeyModifiers::SUPER`) → `line_start` at `:86`, applied at `rust/src/editor.rs:232`. | Move to the first character of the current logical line without crossing the newline. | `screen.editor.word-line-moves`: `super-left`, `ctrl-a`. |
-| Line end (Command/Super+Right, Ctrl+E) | `resolveEditKey` at `src/edit-keys.ts:53` → `lineEnd` at `:39`, applied at `src/editor.ts:188`. | `resolve_edit_key` at `rust/src/edit_keys.rs:111` → `line_end` at `:95`, applied at `rust/src/editor.rs:233`. | Move to the end of the current logical line without crossing the newline. | `screen.editor.word-line-moves`: `super-right`, `ctrl-e`. |
-| Delete word (Ctrl+W, Alt/Option+Backspace) | `src/editor.ts:190` splices `wordStart(comment, cursor)..cursor`. | `rust/src/editor.rs:234` drains `word_start(..)..cursor`. | Kill back to the word start and leave the cursor there; at a line start the kill takes only the newline. | `screen.editor.word-line-kills`: `ctrl-w`, `alt-backspace-marker`, `alt-backspace-word`, `alt-backspace-newline`. |
-| Delete line (Ctrl+U) | `src/editor.ts:194` splices `lineStart(comment, cursor)..cursor`. | `rust/src/editor.rs:239` drains `line_start(..)..cursor`. | Kill back to the line start and leave the cursor there; at a line start it removes nothing. | `screen.editor.word-line-kills`: `ctrl-u`, `ctrl-u-at-line-start`. |
-| Ctrl+S | `src/editor.ts:175` → `save` at `:122`; Rust control branch at `rust/src/editor.rs:178` → `save` at `:278`. | Blank comment: `Write a comment before saving.` and remain. Missing state: `Plugin state directory is unavailable.` and remain. Store error: display it and remain. Success: append exact JSONL, display `Saved.`, wait 250 ms, cleanly exit 0. | `screen.editor.edit-save`, `empty-save-escape`, `missing-state`. |
-| Esc | `src/editor.ts:179` → `exit`/`cleanup`; Rust `rust/src/editor.rs:186` sets quit and `run` restores. | No store write; cursor shown, screen restored, exit 0. | `screen.editor.empty-save-escape`. |
-| Ctrl+C | `src/editor.ts:174` → `exit`/`cleanup`; Rust `rust/src/editor.rs:174` sets quit. | Same cancellation effect as Esc. | `screen.editor.control-c`. |
-
-### How each runtime decodes the word and line modifiers
-
-The two runtimes reach the same action from different modifier models, so the mapping is stated here
-rather than inferred.
-
-- Node's readline folds xterm modifiers 3 and 9 into a single `key.meta` flag (`modifier & 10`), so
- `src/edit-keys.ts:54` inspects the raw sequence with `/;9[A-Z~]$/` to tell Command from Option.
-- Crossterm's `parse_modifiers` subtracts one and reads the result as a bit set, so modifier 3 is
- `KeyModifiers::ALT` and modifier 9 is `KeyModifiers::SUPER`. `KeyModifiers::META` is bit 32, which
- is modifier 33, never Command, so `rust/src/edit_keys.rs:111` gates line moves on `SUPER` alone.
-- Crossterm parses an `ESC`-prefixed byte as the following event with `ALT` folded in, so `\x1bb` is
- `Char('b')` plus `ALT` and `\x1b\x7f` is `Backspace` plus `ALT`, matching Node's `meta` forms.
-- Word boundaries use JavaScript's `\s` class, not `char::is_whitespace`. The two sets differ at
- U+0085 and U+FEFF, so `is_javascript_whitespace` at `rust/src/edit_keys.rs:30` spells the
- TypeScript set out and `edit_keys::tests::javascript_whitespace_matches_the_typescript_class`
- pins both scalars.
-
-## Every manager key in both views
-
-The common TypeScript dispatcher is `src/manager.ts:404`; Rust's is `rust/src/manager.rs:434`.
-Each non-exit transition re-renders the full grid. Arrow Up/Down are aliases of `k`/`j` and are
-also fed by `screen.manager.all-views`.
-
-| Key | Active view: TypeScript → Rust and effect | Archives view: TypeScript → Rust and effect | Evidence |
-|---|---|---|---|
-| `j` / Down | `handleActiveKey` `src/manager.ts:330` → `handle_active_key` `rust/src/manager.rs:478`; increment/clamp active selection, changing list highlight and detail. | `handleArchiveKey` `src/manager.ts:366` → `handle_archive_key` `rust/src/manager.rs:527`; increment/clamp archive selection and detail. | `active-j`, `active-arrow-down`, `archives-j`, `archives-arrow-down`. |
-| `k` / Up | `src/manager.ts:328` → `rust/src/manager.rs:475`; decrement/saturate. | `src/manager.ts:364` → `rust/src/manager.rs:524`; decrement/saturate. | Corresponding `k` and arrow-up steps. |
-| `y` | `copy` at `src/manager.ts:218` receives the selected annotation → `ManagerApp::copy` at `rust/src/manager.rs:547`; exact one-item Markdown is copied; success exits 0, an empty selection displays status. | Same helpers receive the selected archive's annotations in newest-first order (`src/manager.ts:368`, `rust/src/manager.rs:529`). | Success sessions `store.manager.active-y-success` / `archives-y-success` / `osc52-remote-copy`, and empty-actions. |
-| `c` | `src/manager.ts:337` copies the displayed newest-first active list → `rust/src/manager.rs:492`; success exits. | Not handled by `src/manager.ts:346` or `rust/src/manager.rs:503`; clears any prior confirmation/status through normal dispatch, otherwise store/view unchanged. | `store.manager.active-c-success`, `osc52-remote-copy-all`, and `archives-c-ignored`. |
-| `C` | `copyAndArchive` at `src/manager.ts:227` → `copyAndArchiveAnnotations` at `src/archive-workflow.ts:29`; Rust `rust/src/manager.rs:554` → `copy_and_archive_annotations` at `rust/src/archive_workflow.rs:27`. Order is load → copy → append archive → remove captured active IDs. Success exits; partial failure is reported without data loss. | Not handled; same no-op/confirmation-clear behavior as archive `c`. | Success and byte-diff in `store.manager.copy-archive`; `osc52-remote-copy-archive` proves the archive still runs when only the terminal copy landed; `archives-C-ignored`; workflow failure ordering has paired TS/Rust unit specs. |
-| `d` | `deleteSelectedAnnotation` at `src/manager.ts:245` → `rust/src/manager.rs:575`; remove selected ID through locked atomic rewrite, reload, status `Annotation deleted.` | First press records the selected archive id and renders `Press d again…`; second matching press calls `deleteSelectedArchive` (`src/manager.ts:297`, `rust/src/manager.rs:639`) and atomically removes it. No selection displays `No archive selected.` Esc or another ordinary key cancels confirmation. | Active delete, archive confirm/cancel/double-confirm in all-views; empty-actions. |
-| `D` | `src/manager.ts:321` / `rust/src/manager.rs:467`: first press renders `Press Shift+D again…`; second calls `clearActive` (`src/manager.ts:257`, `rust/src/manager.rs:589`), rewrites active JSONL empty, reloads, and displays `All active annotations cleared.` | Uppercase `D` is not an archive action; it cancels a pending archive confirmation like any non-`d` archive key, otherwise no store effect. | Active confirm/cancel/double-confirm and `archives-D-ignored` in all-views. |
-| `r` | `reloadActive` (`src/manager.ts:55`, `rust/src/manager.rs:80`); success status `Reloaded.`, failure status is the store error. | `reloadArchives` (`src/manager.ts:66`, `rust/src/manager.rs:95`) with the same status rule. | Both reload steps in all-views; invalid/busy store process cases cover propagated store errors. |
-| `u` | Not handled in active view; clears transient status/confirmation, leaves selection and stores unchanged. | `restoreSelectedArchive` at `src/manager.ts:271` → `restoreArchivedSet` at `src/archive-workflow.ts:71`; Rust `rust/src/manager.rs:606` → `restore_archived_set` at `rust/src/archive_workflow.rs:96`. Order is merge missing annotation IDs, then remove archive; partial removal failure keeps the archive and reports it. | `active-u-ignored`; archive restore in all-views; no-selection in empty-actions; paired workflow unit specs cover partial failures and concurrent active records. |
-| Tab | `switchView` at `src/manager.ts:308` → `rust/src/manager.rs:651`; clear confirmation/status, switch view, reload destination store. | Same in reverse. | Both Tab directions in all-views and all archive exit sessions. |
-| Esc | `src/manager.ts:406`: if confirming, clear confirmation/status and stay; otherwise cleanly exit. Rust `rust/src/manager.rs:440` is identical. | Same. | Active confirmation cancel and active exit; archive confirmation cancel and archive exit. |
-| `q` | Common dispatcher exits 0 through cleanup. | Same. | Active `q` in all-views; `screen.manager.q-archives`. |
-| Ctrl+C | Common dispatcher exits 0 through cleanup. | Same. | `screen.manager.control-c-active` and `control-c-archives`. |
-
-## Rendered and exported products
-
-| Product | TypeScript call path | Rust call path | Observable proof |
-|---|---|---|---|
-| Markdown export | `formatAnnotations` at `src/format.ts:44`; `fenceFor` at `:38`. | `format_annotations` at `rust/src/format.rs:67`; `fence_for` at `:52`. | Both emit `# Annotated context`, then newest-first `## Annotation N` sections. Optional source is `workspace_label / tab_label`; selected text and comment retain line breaks; selected text uses a backtick fence one longer than its longest run (minimum three); duplicate blank lines are collapsed; the document ends in exactly one `\n`. Populated/single copy and cross-read compare raw clipboard bytes using backticks, multiline text, and wide characters. |
-| Terminal-safe text | `sanitizeTerminalText`/`wrapText` at `src/format.ts:5`/`:12`. | `sanitize_terminal_text`/`wrap_text` at `rust/src/format.rs:7`/`:26`. | Control characters are removed except newline/tab, tabs become four spaces, CRLF becomes LF, explicit newlines are preserved, and wrapping uses terminal cells. Editor and both manager views compare resulting cells. |
-| Width and clipping | `charWidth`, `stringWidth`, `truncateToWidth` at `src/width.ts:37`/`:48`/`:55`; manager `clipped` at `src/manager.ts:77`. | `char_width`, `string_width`, `truncate_to_width` at `rust/src/width.rs:35`/`:47`/`:52`; manager `clipped` at `rust/src/manager.rs:681`. | Same zero-width controls/combining ranges and same wide ranges; truncation never splits a wide glyph and adds exactly one ellipsis cell. Wide input occurs in editor, list, detail, metadata, and archive snapshots. |
-| Editor geometry | `render` at `src/editor.ts:77` and `layoutComment` at `src/layout.ts:9`. | `EditorApp::draw` at `rust/src/editor.rs:67` and `layout_comment` at `rust/src/layout.rs:14`. | At 86×22, identical selected-text cap/overflow marker, comment viewport, cursor cell, footer/status placement, and full clears. Every editor input has a post-step grid diff. |
-| Manager geometry and labels | `render`/active/archive/footer at `src/manager.ts:198`/`:95`/`:138`/`:184`. | `ManagerApp::draw`/active/archive/footer at `rust/src/manager.rs:110`/`:162`/`:300`/`:409`. | At 98×28, identical 36%-clamped list, divider, selected marker/reverse cell region, detail width, newest-first labels, counts, metadata line, preview overflow, empty-state text, confirmation footer, help footer, and transient status. The archive clipping boundary that exposed the earlier one-cell defect is in every seeded archive screen. |
-
-## Filesystem effects
-
-| Observable | TypeScript call path | Rust call path | Exact product and evidence |
-|---|---|---|---|
-| State directory creation | `fs.mkdirSync(..., {recursive:true})` at `src/capture.ts:42` and `withStoreLock` at `src/store.ts:196`. | `create_dir_all` at `rust/src/cli.rs:84` and `create_private_dir_all` at `rust/src/store.rs:392`. | Process-default directory mode (0755 under harness umask 022), not forced 0700. `process.copy.empty` compares the directory mode. |
-| Pending file | `src/capture.ts:44`/`:49`/`:52`. | `rust/src/cli.rs:85`/`:92` and `write_pending` at `:247`. | Name `pending--.json`; mode 0600 on creation; bytes are one JSON object plus `\n`; property order `selectedText`, `context`, `capturedAt`. Capture cases byte/mode-diff it. |
-| Pending consumption | `src/editor.ts:34` reads/parses, then `fs.rmSync(...,{force:true})` at `:39`. | `pending_from_env` at `rust/src/editor.rs:345`, then `remove_pending_file` at `:362`. | Delete only after successful read and semantic parse; missing-at-delete is ignored; other deletion errors fail startup. `screen.editor.pending-file-save` compares the consumed tree and saved JSONL; Rust regression `pending_removal_is_forceful_like_typescript` pins the delete race. |
-| Handoff take | `handoffPath`/`takeHandoff` at `src/handoff.ts:17`/`:24`. | `handoff_path`/`take_handoff` at `rust/src/handoff.rs:12`/`:32`. | `$XDG_RUNTIME_DIR` else temp + `herdr-annotate-/selection`; missing/stat failure means absent; regular files ≤15 s old are decoded as UTF-8 with replacement; stale and blank values are rejected; every found node is removed; non-NotFound removal failure propagates. Context-skipped handoff remains. Capture handoff cases diff pending bytes and the runtime tree. |
-| Active append | `appendAnnotation` at `src/store.ts:42`. | `append_annotation` / `append_annotation_context_first` at `rust/src/store.rs:77`/`:82`, sharing `append_annotation_record` at `:97`. | Append one compact JSON object plus `\n`; mode 0600 on creation. Capture-file editor order is `selectedText,capturedAt,context,id,comment,createdAt`; direct invocation fallback preserves TypeScript's distinct `selectedText,context,capturedAt,id,comment,createdAt`. `screen.editor.pending-file-save.state` and `screen.editor.edit-save.state` byte-diff both orders; two Rust regressions pin them. |
-| JSONL read | `loadJsonLines` at `src/store.ts:150` and parsers at `src/types.ts:92`/`:103`. | `load_json_lines` at `rust/src/store.rs:227` and parsers at `rust/src/types.rs:133`/`:145`. | Missing file = empty; empty lines skipped; any malformed/nonconforming non-empty line rejects the whole store; unknown fields tolerated by parsers. Invalid-store and paired unit cases cover both stores. |
-| Active rewrite | Remove/merge at `src/store.ts:54`/`:70` → `replaceJsonLines` at `:175`. | `remove_annotations_by_id`/`merge_annotations` at `rust/src/store.rs:120`/`:136` → `replace_json_lines` at `:253`. | Retained append order; merge by id without duplicates; canonical saved field order; temporary `.--.tmp`, mode 0600, one newline per record (zero bytes when empty), then rename; temp removed on failure. Manager state byte diffs prove final files and absence of leftovers. |
-| Archive rewrite | Append/remove at `src/store.ts:99`/`:111` → `replaceJsonLines`. | `append_archived_set`/`remove_archived_set` at `rust/src/store.rs:173`/`:182` → `replace_json_lines`. | Entire archives store is atomically replaced. Outer order is `version,id,archivedAt,annotations`; inner annotations are canonical; trailing newline and 0600 mode match. Copy/archive, restore, and permanent-delete scripts byte-diff the products. |
-| Lock acquire | `withStoreLock`/`acquireStoreLock`/`createStoreLock` at `src/store.ts:196`/`:217`/`:241`. | `with_store_lock`/`acquire_store_lock`/`create_store_lock` at `rust/src/store.rs:285`/`:296`/`:333`. | Per-store `.annotations.lock` or `.archives.lock`; directory mode 0700; `owner` mode 0600 with `:\n`; exclusive directory creation. A fresh existing lock returns the exact busy error without mutation. |
-| Lock steal | Staleness check/removal/retry at `src/store.ts:223`–`:238`, `isStaleLock` at `:261`. | Corresponding branch `rust/src/store.rs:313`–`:330`, `is_stale_lock` at `:345`. | Age ≥30 s is removed then acquired once; a competing recreation reports busy. `process.copy.busy-lock` and `stale-lock` compare exits, errors, and final lock trees. |
-| Lock release | `releaseStoreLock` at `src/store.ts:269`, called in `finally` at `:210`. | `StoreLockLease::drop` at `rust/src/store.rs:52`. | Read current owner; remove recursively only if token still matches; ignore cleanup errors. Every state-compared completed store case asserts no owned lock remains; paired contention tests cover recovery. |
-
-## Process effects
-
-The editor pane argv is identical, including order:
-
-```text
-herdr plugin pane open --cwd --plugin annotate --entrypoint editor \
- --placement popup --width 88 --height 24 \
- --env HERDR_ANNOTATE_PENDING= --focus
-```
-
-It is constructed at `src/capture.ts:54` and `rust/src/cli.rs:97` and compared by every successful
-capture case. The manager argv is constructed at `src/open-manager.ts:13` and `rust/src/cli.rs:216`:
-
-```text
-herdr plugin pane open --cwd --plugin annotate --entrypoint manager \
- --placement popup --width 100 --height 30 --focus
-```
-
-`runHerdr` (`src/herdr.ts:11`) and `run_herdr` (`rust/src/herdr.rs:22`) ignore stdin/stdout, capture
-stderr, and use `HERDR_BIN_PATH` or `herdr`. Nonzero child status returns trimmed child stderr; if it
-is empty or spawning fails, the exact fallback is `herdr failed`. Manage success,
-stderr failure, and empty-stderr failure are differential cases.
-
-Notifications are best effort and never alter the primary exit. Both sides call:
-
-```text
-herdr notification show [--body ]
-```
-
-The compared title/body pairs are `Nothing to annotate`, `Annotate failed`, `No annotations`,
-`Annotations copied`, `Copy failed`, `Annotations copied and archived`, `Copy and archive failed`,
-`Copy and archive incomplete`, and `Unable to open annotations`; their bodies are listed in the
-error/reporting table below or generated from the exact annotation count.
-
-Clipboard candidates and arguments are defined at `src/clipboard.ts:13`/`:30` and
-`rust/src/clipboard.rs:12`/`:46`:
-
-| Platform | Read order | Write order |
-|---|---|---|
-| macOS | `pbpaste` | `pbcopy` |
-| Windows | `powershell.exe -NoProfile -NonInteractive -Command "Get-Clipboard -Raw"` | `powershell.exe -NoProfile -NonInteractive -Command "$input | Set-Clipboard"` |
-| Linux/other Unix | `wl-paste --no-newline`; `xclip -selection clipboard -out`; `xsel --clipboard --output` | `wl-copy`; `xclip -selection clipboard -in`; `xsel --clipboard --input` |
-
-### Pane copies and OSC 52
-
-Herdr 0.9.0 forwards OSC 52 sequences emitted by pane output to the viewing client, so a copy made
-inside a pane can reach the clipboard of the machine the person is sitting at even when the plugin
-runs on a different server. Only the manager pane can use this path: the global `copy-context` and
-`copy-archive` actions run with piped stdout and no terminal, so their behavior is unchanged.
-
-| Behavior | TypeScript | Rust | Proof |
-|---|---|---|---|
-| Sequence encoding | `osc52ClipboardSequence` at `src/pane-clipboard.ts:18` builds `ESC ] 52 ; c ; BEL`. | `osc52_clipboard_sequence` at `rust/src/pane_clipboard.rs:42` over the same bytes, with a hand-rolled base64 at `:17`. | `test/pane-clipboard.test.ts` and `pane_clipboard::tests` assert the exact bytes for `hi` (`\x1b]52;c;aGk=\x07`), an empty string, and multi-byte UTF-8; `store.manager.*` compares the sequences observed on real PTYs. |
-| Emission point | `paneClipboardWriter` at `src/pane-clipboard.ts:49` wraps `writeClipboard`; `src/manager.ts:41` injects it into both manager copy call sites. | `write_pane_clipboard` at `rust/src/pane_clipboard.rs:68` wrapped by `pane_clipboard_write` at `rust/src/manager.rs:703` and injected at `:548`/`:558`. | Both runtimes emit exactly once per writer call, after the native attempt and before the next frame, so the emitted sequence lists match step for step. |
-| Native failure | Either destination landing is a successful copy (`src/pane-clipboard.ts:56`). A remote server commonly has no clipboard tool at all, and the sequence is the copy that actually reached the person, so it is not reported as a failure and does not stop a copy-and-archive before its archive step. | Identical rule at `rust/src/pane_clipboard.rs:75`. | The three `store.manager.osc52-remote-*` cases run with the native writer failing: the copy exits 0, `C` still writes its archive and clears the active list, and both runtimes emit the same sequence. |
-| Neither destination | Only a copy that reached neither the clipboard nor the terminal fails, carrying the unchanged native error. | Same. | Paired unit specs; unreachable from a pane whose stdout is a live terminal. |
-| Oversized payloads | `exceedsCommonOsc52Limit` at `src/pane-clipboard.ts:26`. | `exceeds_common_osc52_limit` at `rust/src/pane_clipboard.rs:47`. | Terminals commonly refuse a base64 payload over 74994 bytes. Both runtimes emit the full sequence anyway and never truncate a copy; the predicate exists so the limit is stated rather than silently applied. |
-
-Replacing one runtime's emitter with a no-op and rerunning the harness fails every manager copy
-case, so these comparisons are not vacuously green.
-
-Because a pane's stdout is a live terminal, a manager copy no longer fails there. `No supported
-clipboard writer is available` therefore remains reachable from the global actions, which have no
-terminal, but not from the manager; the manager's copy-failure frames were removed from
-`screen.manager.all-views` for that reason.
-
-Readers return the first exit-0 stdout, decoded with UTF-8 replacement. Writers pipe the exact UTF-8
-Markdown to stdin and accept the first exit-0 adapter. Child stdout/stderr is suppressed. The fake
-adapters log every attempt and capture writer stdin; macOS and Ubuntu CI together prove both Unix
-branches. Windows arguments are source-mapped and build-checked, not run by this harness.
-
-### Exit codes
-
-| Path | Exit |
-|---|---|
-| Successful capture, empty capture after notification, successful/empty copy, successful/empty copy-archive, successful manage pane open | 0 |
-| Missing required env, no clipboard adapter, store parse/lock/access failure, or pane-open failure | 1 after one stderr line; action commands also attempt their failure notification |
-| Editor/manager initialization error | 1 with stderr, no action-level notification |
-| Editor Esc/Ctrl+C, manager Esc/q/Ctrl+C, editor SIGTERM, manager SIGHUP | 0 after terminal restoration |
-| Successful editor save | 0 after final `Saved.` frame and 250 ms delay |
-| Manager copy/copy+archive success | 0 after clipboard/store completion; a pane copy succeeds whenever the clipboard write or the OSC 52 emission lands |
-
-## Error and failure strings
-
-These are all Lite-authored error strings and templates. A `` is the operating-system code
-projected by `safeFileError` (`src/store.ts:285`, `rust/src/store.rs:396`); omitting the code leaves
-the prefix exactly as shown.
-
-| Exact string or template | TypeScript emitter | Rust emitter | Proof |
-|---|---|---|---|
-| `HERDR_PLUGIN_STATE_DIR is not set` | `src/capture.ts:22`, `src/export.ts:9`, `src/export-archive.ts:48`, `src/manager.ts:33` | `rust/src/cli.rs:69`/`:125`/`:185`, `rust/src/manager.rs:725` | Missing-state process cases. |
-| `HERDR_PLUGIN_ROOT is not set` | `src/capture.ts:24`, `src/open-manager.ts:7` | `rust/src/cli.rs:70`/`:215` | Missing-root cases. |
-| `No supported clipboard reader is available` | `src/clipboard.ts:59` | `rust/src/clipboard.rs:106` | `process.capture.no-clipboard`. |
-| `Missing pending annotation` | `src/editor.ts:32` | `rust/src/editor.rs:351` | `process.editor.missing-pending`. |
-| `Pending annotation is invalid` | `src/editor.ts:37` | `rust/src/editor.rs:357` | `process.editor.invalid-pending`. |
-| `Write a comment before saving.` | `src/editor.ts:125` | `rust/src/editor.rs:281` | Editor empty-save frame. |
-| `Plugin state directory is unavailable.` | `src/editor.ts:131` | `rust/src/editor.rs:285` | Editor missing-state frame. |
-| `Unable to save annotation[ ()]` | `src/store.ts:48` | `rust/src/store.rs:100` | Store error catalog and paired store/editor tests. |
-| `Unable to read annotations (invalid data)` / `Unable to read archives (invalid data)` | `src/store.ts:163`/`:166` | `rust/src/store.rs:245`/`:247` | Invalid active process case and paired active/archive tests. |
-| `Unable to read annotations ()` / `Unable to read archives ()` | `src/store.ts:171` | `rust/src/store.rs:235`/`:240` | Source/error catalog; OS-specific access cases remain unit-level. |
-| `Unable to access annotations[ ()]` / `Unable to access archives[ ()]` | `src/store.ts:204` | `rust/src/store.rs:290` | Static catalog; normal missing-dir creation is differential. |
-| `Unable to lock annotations[ ()]` / `Unable to lock archives[ ()]` | `src/store.ts:230`/`:257` | `rust/src/store.rs:306`/`:326` | Static catalog and contention tests. |
-| `Annotations are busy; try again.` / `Archives are busy; try again.` | `src/store.ts:224`/`:235` | `rust/src/store.rs:314`/`:324` | Active busy-lock differential plus paired per-store tests. |
-| `Unable to update annotations[ ()]` / `Unable to update archives[ ()]` | prefixes supplied at `src/store.ts:135`/`:147` | `rust/src/store.rs:202`/`:223` | Static catalog and rewrite failure unit paths. |
-| `Nothing to copy.` | `src/manager-copy.ts:18` | `rust/src/manager_copy.rs:20` | `screen.manager.empty-actions`. |
-| `Nothing to copy and archive.` | `src/archive-workflow.ts:35` | `rust/src/archive_workflow.rs:44` | `screen.manager.empty-actions`. |
-| `No archive selected.` | `src/manager.ts:274`/`:351` | `rust/src/manager.rs:505`/`:609` | `screen.manager.empty-actions`. |
-| `Copied and archived, but active annotations remain: ` | `src/manager.ts:239`, `src/export-archive.ts:35` | `rust/src/manager.rs:570`, `rust/src/cli.rs:167` | Paired workflow partial-failure tests plus static catalog. |
-| `Annotations restored, but the archive remains: ` | `src/manager.ts:289` | `rust/src/manager.rs:628` | Paired workflow partial-failure tests plus static catalog. |
-| Child `herdr` stderr, or `herdr failed` | `src/herdr.ts:20` | `rust/src/herdr.rs:32`–`:39` | Manage child-stderr and empty-stderr cases; capture pane failure. |
-
-`Unable to save annotation.` (with a period) at `src/editor.ts:146` is specifically a Bun dynamic
-module-loader failure. There is no corresponding runtime condition in the statically linked binary;
-actual store-open/write failures take the mapped `Unable to save annotation[ ()]` path on both
-sides. JSON-parser and raw filesystem diagnostics emitted before a Lite-defined wrapper are supplied
-by Bun or the Rust standard library, not authored stable strings; both processes fail and surface the
-native diagnostic, but those platform/runtime wordings are not claimed as a portable Lite contract.
-
-## Test and harness coverage
-
-The existing behavior-spec mapping remains in `docs/rust-lite-parity.md`. The Rust unit modules mirror
-all TypeScript spec files: `types`, `paths`, `handoff`, `format`, `width`, `layout`, `store`,
-`archive-workflow`, and `manager-copy`. Rust additionally has `TestBackend` editor/manager frames and
-subprocess command tests. The differential harness is independent of those expected-value tests: its
-oracle is the current TypeScript implementation running on the same fixture.
-
-Notable differential groups:
-
-- `process.capture.*`: context > handoff > clipboard precedence; stale/blank/lossy-UTF-8 handoff;
- invalid context; empty; missing adapter/env; pane success/failure and pending cleanup.
-- `process.copy.*`: missing/empty/single/plural/invalid stores; writer failure; fresh/stale locks.
-- `process.copy-archive.*`: missing/empty stores; a complete archive byte-compared afterwards; writer
- failure leaving both stores untouched.
-- `manifest.*`: both Lite manifests declare the same action and pane ids, titles, descriptions,
- contexts, placements, geometry, platform gates, and per-runtime argv, and the harness drives every
- declared entrypoint.
-- `process.manage.*`, `process.editor.*`, `process.manager.*`: argv/error fallback and initialization.
-- `screen.editor.*`: both pending sources, every requested edit key, save branches, cancel keys, and SIGTERM.
- `screen.editor.word-line-moves` and `screen.editor.word-line-kills` send the raw byte sequence for
- each word/line key (`\x1b[1;3D`, `\x1b[1;3C`, `\x1b[1;5D`, `\x1b[1;5C`, `\x1b[1;9D`, `\x1b[1;9C`,
- `\x1bb`, `\x1bf`, `\x1b\x7f`, `\x17`, `\x15`, `\x01`, `\x05`) and type a marker character after
- each one, because a compared screen holds cells and not the cursor. The saved store bytes are the
- record of where both runtimes put the cursor.
-- `screen.manager.*`: both views, every view-valid key, ignored cross-view keys by handler mapping,
- both confirmation flows, empty actions, exit keys, and SIGHUP.
-- `store.manager.*`: successful clipboard-only and copy/archive products; the three
- `store.manager.osc52-remote-*` cases copy with the native writer failing, which is the
- remote-server shape of issue #40; `store.cross-read` proves each implementation parses and exports
- the other's editor-written record.
-
-The harness itself asserts the requested key-coverage set before it can print green.
-
-## Findings fixed while building the proof
-
-Each observed mismatch was fixed in a separate commit whose message cites this map section:
-
-- `b50f388`: missing store directories now use the process-default mode, matching Bun.
-- `28e3131`: direct invocation fallback preserves its distinct TypeScript JSON property order.
-- `1d75160`: handoff UTF-8 decoding uses replacement characters, matching Bun.
-- `89e41d8`: SIGTERM/SIGHUP exits restore the terminal and return 0.
-- `53ef811`: pending deletion ignores a missing-at-delete race like `{ force: true }`.
-- `3cb49ec`: non-NotFound handoff deletion failures now propagate instead of silently falling back.
-
-No TypeScript source changed.
-
-## Deliberate divergences
-
-There is one. TypeScript delegates manager timestamps to `Date.prototype.toLocaleString()` and the
-host's locale database (`src/manager.ts:134`, `:160`, `:171`). Rust parses into local time and emits
-the en-US shape explicitly (`rust/src/manager.rs:691`). The persisted ISO timestamp, ordering, export,
-and en-US display are identical. A non-en-US host can display localized punctuation/order in
-TypeScript while Rust stays en-US. The harness pins UTC and en-US and compares the literal seeded
-timestamp cells; it does not normalize them.
-
-Windows remains unexecuted by this Unix PTY harness, as required. It is a verification gap for the
-separate promotion track, not an intentional product behavior difference.
diff --git a/docs/rust-lite-parity.md b/docs/rust-lite-parity.md
deleted file mode 100644
index 6039de3..0000000
--- a/docs/rust-lite-parity.md
+++ /dev/null
@@ -1,124 +0,0 @@
-# Rust Lite parity checklist
-
-This document maps the TypeScript Lite implementation at `origin/main` to the native evaluation
-crate. Checked items have a Rust implementation and automated coverage. Live checks are recorded
-separately so test coverage is not confused with behavior observed inside Herdr.
-
-## Entrypoints and plugin contract
-
-- [x] `capture.ts` → `herdr-annotate capture`: selection precedence is invocation context, fresh
- one-shot handoff file, then platform clipboard; blank selections notify without failing; pending
- JSON is mode 0600; pane-open failure removes it.
-- [x] `export.ts` → `herdr-annotate copy-context`: loads newest first, formats Markdown, copies, and
- sends the same singular/plural notifications and errors.
-- [x] `export-archive.ts` → `herdr-annotate copy-archive`: injects the dependencies `manager.ts` passes
- to `copyAndArchiveAnnotations`, maps the three outcomes to the same notifications, and exits 0 on an
- empty store and 1 on a real failure.
-- [x] `open-manager.ts` → `herdr-annotate manage`: requires `HERDR_PLUGIN_ROOT` and opens the same
- focused 100×30 popup.
-- [x] `editor.ts` → `herdr-annotate editor`: pending-file and invocation-context fallback, delete-on-
- successful-read behavior, multiline Unicode editing, wide-cell cursor layout, Ctrl+S validation,
- save delay, Esc/Ctrl+C cancellation, raw terminal restoration.
-- [x] `edit-keys.ts` → `rust/src/edit_keys.rs`: the same `wordStart`/`wordEnd`/`lineStart`/`lineEnd`
- boundaries, including JavaScript's `\s` class, one-step newline crossing, and CJK runs as single
- words; the same key map for Option/Alt and Ctrl arrows, Command/Super arrows, Alt+B/Alt+F,
- Ctrl+A/Ctrl+E, and the Ctrl+W/Alt+Backspace/Ctrl+U kills.
-- [x] `manager.ts` → `herdr-annotate manager`: active/archive views, newest-first lists, detail panes,
- navigation, copy one/all, copy-and-archive, delete, double-confirm clear, restore, double-confirm
- permanent archive deletion, reload, status messages, Esc/Tab/q/Ctrl+C behavior. Every pane copy also
- emits OSC 52 so Herdr 0.9.0 forwards it to the viewing client's terminal.
-- [x] `lite-rs/herdr-plugin.toml` preserves plugin id `annotate`, action ids, pane ids, placements,
- dimensions, contexts, and supported platform declarations. All commands are the one native binary.
- `scripts/parity-lite.py` compares those declarations against `lite/herdr-plugin.toml` and the root
- Full manifest field by field.
-
-## Modules and compatibility boundaries
-
-- [x] `types.ts`: retained Herdr fields, permissive context projection, required pending fields,
- non-empty saved fields, complete version-1 archive validation, unknown-field tolerance.
-- [x] `paths.ts`: state/root environment variables, Windows extended drive and UNC normalization,
- `annotations.jsonl` and `archives.jsonl` names.
-- [x] `store.ts`: append-order JSONL, exact camelCase field names and distinct TypeScript pending and
- saved-record field orders, trailing newline, mode 0600, whole-store invalid-data rejection, ID
- merge/remove, atomic temporary replace, per-store directory locks, owner tokens, 30-second stale
- recovery, ownership-checked release.
-- [x] `format.ts`: control sanitization, four-space tabs, CRLF normalization, explicit-newline and
- terminal-cell wrapping, Markdown headings/source/fences/blank-line shape, safe longer backtick fence.
-- [x] `width.ts` and `layout.ts`: the same wide ranges, zero-width controls/combining marks, non-
- splitting truncation/wrapping, terminal-cell cursor coordinates.
-- [x] `handoff.ts`: per-user runtime/temp path, 15-second freshness, read-once removal, blank rejection.
-- [x] `clipboard.ts`: `pbpaste`/`pbcopy`; PowerShell raw get/set; Linux Wayland then xclip then xsel.
-- [x] `pane-clipboard.ts`: identical `ESC ] 52 ; c ; BEL` bytes over the raw UTF-8 text,
- emitted after the native write at the same two manager call sites, the same rule that either
- destination landing is a successful copy, and the same advisory 74994-byte payload limit with no
- truncation.
-- [x] `herdr.ts`: `HERDR_BIN_PATH` override, stderr projection, best-effort notifications.
-- [x] `archive-workflow.ts` and `manager-copy.ts`: operation order and partial-failure states, including
- preserving a concurrently saved annotation.
-
-`plannotator-tui-schema` is intentionally not a dependency: its `Annotation` is the Full product's
-document-anchor/API wire shape, not Lite's existing terminal-selection JSONL shape.
-
-## TypeScript test mapping
-
-| TypeScript spec | Rust counterpart |
-|---|---|
-| `test/types.test.ts` | `types::tests` (8 tests, including JSON-number and JS-trim edges) |
-| `test/paths.test.ts` | `paths::tests` (2 tests; all five TS path cases covered) |
-| `test/handoff.test.ts` | `handoff::tests` (4 tests, including future-clock skew) |
-| `test/format.test.ts` | `format::tests` plus malformed-boundary store/type tests |
-| `test/width.test.ts` | `width::tests` (3 grouped tests covering every assertion) |
-| `test/layout.test.ts` | `layout::tests` (3 grouped tests covering every assertion) |
-| `test/edit-keys.test.ts` | `edit_keys::tests` (14 tests, including the JavaScript whitespace class) |
-| `test/store.test.ts` | `store::tests` (6 tests) |
-| `test/archive-workflow.test.ts` | `archive_workflow::tests` (8 tests) |
-| `test/export-archive.test.ts` | `cli::tests::copy_archive_maps_every_outcome_to_its_notification_and_exit_status` |
-| `test/manager-copy.test.ts` | `manager_copy::tests` (3 tests) |
-| `test/pane-clipboard.test.ts` | `pane_clipboard::tests` (8 tests, including base64 chunk padding) |
-
-Additional Rust-only coverage:
-
-- `editor::tests`: headless 86×22 `TestBackend` frame, Unicode edit keys, word/line moves and kills
- wired through `handle_key`, empty-save validation, quit.
-- `manager::tests`: headless 98×28 active/archive frames, newest-first detail, exact TypeScript detail
- width at the clipping boundary, confirmation, real clear.
-- `rust/tests/commands.rs`: subprocess-level capture/manage/copy-context commands with a fake Herdr,
- including exact pane argv, pending JSON, notifications, and pending cleanup on pane-open failure.
-- `types::tests::serialization_matches_the_typescript_pending_and_saved_field_order`: literal pending
- and saved JSON byte shapes.
-
-## Distribution and verification
-
-- [x] Rust 2024, Rust 1.96, repository lint policy, pedantic Clippy with `-D warnings`, no production
- `unwrap()`, release LTO/strip, and `cargo fmt`.
-- [x] Checksummed release assets for macOS (Intel/Apple Silicon), Linux (x86_64/aarch64), and Windows
- are defined by `.github/workflows/rust-lite-release.yml`.
-- [x] Unix and PowerShell installers verify `SHA256SUMS`; `lite-rs/scripts/stage-local.sh` builds and
- stages the binary before `herdr plugin link` (link intentionally does not run manifest build hooks).
-- [x] `scripts/smoke-rust-lite.sh` refuses the default session, preserves/restores the globally
- installed `annotate` plugin, checks the native manifest/binary, and renders/closes the manager
- entrypoint through Herdr.
-- [ ] Release download mode is defined and tested structurally, but cannot be exercised before an
- unmerged evaluation tag/release exists.
-- [ ] Windows behavior is covered by platform-specific code and Windows CI, but was not live-tested
- on this macOS development host.
-
-## Live verification
-
-On 2026-08-29, `scripts/smoke-rust-lite.sh` completed with zero failures against only the disposable
-`rust-lite-test` session on macOS. It built and staged the release binary, linked `lite-rs`, verified
-all three action commands and the bundled version, rendered the native manager popup through Herdr,
-and closed it. The previously installed GitHub `annotate` plugin was restored at its exact commit,
-and the disposable session was stopped afterward.
-
-The capture/editor save flow, system clipboard adapters, archive mutations, and every manager key
-path were verified by automated unit, subprocess, and `TestBackend` tests rather than mutating live
-annotation or clipboard data. Windows remains CI-only until it is exercised on a Windows host.
-
-## Deliberate display-level difference
-
-JavaScript's `Date.toLocaleString()` delegates to the host's full locale database. The Rust manager
-uses the same local timezone but an en-US-style `M/D/YYYY, h:mm:ss AM/PM` string. Persisted timestamps,
-sorting, and export do not change; only manager timestamp presentation can differ for non-en-US users.
-
-No TypeScript source was changed.
diff --git a/docs/windows-full-acceptance.md b/docs/windows-full-acceptance.md
index e7bc10e..67ea213 100644
--- a/docs/windows-full-acceptance.md
+++ b/docs/windows-full-acceptance.md
@@ -1,8 +1,9 @@
# Windows Full manifest acceptance checklist
Decision: add `windows-full/herdr-plugin.toml`. Keep the root manifest unchanged.
-The new variant requires Herdr 0.9.0 and starts the native TUI directly. The Bun
-launcher is rejected. `q` quits the TUI; Escape is not a quit binding.
+The new variant requires Herdr 0.9.0 and starts the native TUI directly. No
+launcher process may sit between Herdr and the TUI. `q` quits the TUI; Escape is
+not a quit binding.
Prepared 2026-09-07 against plugin commit
`46cbf5dab1746dfeb46eb160c33990b0e0de15b0`, with TUI pin `0.6.0`.
@@ -15,8 +16,8 @@ requirements, not completed verification. Every box starts unchecked.
- [ ] Add a GitHub-installable `windows-full/` subdirectory with plugin id
`annotate`, name `Annotate`, the root plugin version, `platforms = ["windows"]`,
and `min_herdr_version = "0.9.0"`. Do not rewrite the manifest during build.
-- [ ] Leave `herdr-plugin.toml`, `lite/herdr-plugin.toml`, and
- `lite-rs/herdr-plugin.toml` unchanged, including their `0.8.0` minimums. Preserve
+- [ ] Leave `herdr-plugin.toml` and `lite/herdr-plugin.toml` unchanged, including
+ their `0.8.0` minimums. Preserve
the root Unix wrapper and its platform gates. Do not change the TUI development
manifest as part of this addition.
- [ ] Compare parsed action ids, titles, descriptions, and contexts with the root
@@ -36,15 +37,15 @@ requirements, not completed verification. Every box starts unchecked.
- [ ] Require exactly three panes: `editor` (Annotate, popup, 88×24), `manager`
(Annotations, popup, 100×30), and one `doc` (Annotate, overlay). The `doc`
command must be exactly `["./bin/plannotator-tui.exe", "herdr", "pane"]`.
- No shell, Bun, PowerShell, command-string interpolation, or fallback launcher
- may sit between Herdr's pane process and the TUI.
+ No shell, PowerShell, command-string interpolation, or fallback launcher may sit
+ between Herdr's pane process and the TUI.
- [ ] Keep `open` and `open-link` as direct argv ending in `herdr open`, and
`last` as direct argv ending in `herdr last`, all using
`./bin/plannotator-tui.exe`. Preserve the `markdown-file` link handler's title,
pattern, and `open-link` action. All actions, panes, the handler, and the build
must be effective on Windows; no inherited Unix gate may disable them.
-- [ ] Reuse the existing Bun Lite sources with paths valid from `windows-full/`
- (as `lite/` does with `../src/`). Verify those paths from an installed checkout,
+- [ ] Reuse the shared native runtime with paths valid from `windows-full/`
+ (as `lite/` does with `../bin/`). Verify those paths from an installed checkout,
not just the repository root. Preserve shared annotation state and archive
formats; changing variants must not migrate or clear them.
- [ ] On isolated installs, Herdr 0.8.2 rejects the new variant with
@@ -55,8 +56,8 @@ requirements, not completed verification. Every box starts unchecked.
- [ ] Extend manifest checks and CI path filters to include `windows-full/**`.
Preserve existing root/Lite/development-manifest assertions rather than
changing them to expect Windows Full everywhere. Run onboarding section 8's
- applicable static, unit, manifest, and Lite parity checks with zero unexpected
- divergences, plus Unix fetcher regression checks.
+ applicable static, unit, manifest, and Lite regression checks with zero
+ unexpected failures, plus Unix fetcher regression checks.
## 2. PowerShell fetch contract
@@ -132,10 +133,10 @@ does not establish that a real Herdr pane works.
unchanged. Exercise `open`, `open-link` (including a percent-encoded `file://`
path), and `last` with controlled context/transcript fixtures. Record fixture
evidence separately from real-agent integration.
-- [ ] Open Full with Bun absent from pane `PATH`, and with inert review-folder
- `bunfig.toml`/`.env` fixtures that would change a Bun launcher. Full must render
- the intended document without loading those files. With Bun restored, smoke
- the reused Lite action/pane paths from the installed subdirectory.
+- [ ] Open Full with inert review-folder `.env` fixtures that would change a
+ launcher process. Full must render the intended document without loading those
+ files. Smoke the reused Lite action and pane paths from the installed
+ subdirectory.
- [ ] Send `q` through ConPTY input to the normal viewer. Assert TUI exit code
zero, closure of the review pane, and a responsive original pane/client. Repeat
in the default overlay and supported popup/split placements. Record Escape
@@ -223,7 +224,8 @@ for its specific assertions; it does not qualify the complete user experience.
> The installer downloads the pinned native Windows TUI and verifies its
> SHA-256 checksum. Document and agent-reply review starts that executable
- > directly. Bun must still be on `PATH` for the Lite annotation tools.
+ > directly. The Lite annotation tools run the native `herdr-annotate` binary the
+ > same install downloads.
> Press `q` to close the TUI. Escape is not a TUI quit key.
>
> Native terminal interaction, real-agent delivery, outer clipboard behavior,
diff --git a/lite-rs/herdr-annotate.version b/herdr-annotate.version
similarity index 100%
rename from lite-rs/herdr-annotate.version
rename to herdr-annotate.version
diff --git a/herdr-plugin.toml b/herdr-plugin.toml
index 9148924..48d2136 100644
--- a/herdr-plugin.toml
+++ b/herdr-plugin.toml
@@ -1,37 +1,52 @@
id = "annotate"
name = "Annotate"
-version = "0.3.0"
+version = "0.4.0"
min_herdr_version = "0.8.0"
description = "Comment on terminal selections and copy annotations as agent context."
platforms = ["linux", "macos", "windows"]
+# ---------------------------------------------------------------------------------------
+# Terminal annotations, served by the native herdr-annotate runtime. A prebuilt binary is
+# fetched into bin/ at build time on all three platforms. Herdr resolves explicit relative
+# programs against $HERDR_PLUGIN_ROOT, and the two panes below are opened by the plugin itself
+# with --cwd set to that root, so one argv-only command works everywhere. The `.exe` suffix is
+# an ordinary executable filename on macOS and Linux and the required suffix on Windows.
+
+[[build]]
+platforms = ["macos", "linux"]
+command = ["bash", "scripts/fetch-herdr-annotate.sh"]
+
+[[build]]
+platforms = ["windows"]
+command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "scripts/fetch-herdr-annotate.ps1"]
+
[[actions]]
id = "capture"
title = "Annotate selection"
description = "Open a comment dialog for the current terminal selection."
contexts = ["pane"]
-command = ["bun", "src/capture.ts"]
+command = ["./bin/herdr-annotate.exe", "capture"]
[[actions]]
id = "copy-context"
title = "Copy annotations as context"
description = "Copy all saved annotations to the clipboard as Markdown."
contexts = ["global"]
-command = ["bun", "src/export.ts"]
+command = ["./bin/herdr-annotate.exe", "copy-context"]
[[actions]]
id = "copy-archive"
title = "Copy annotations as context and archive them"
description = "Copy all saved annotations to the clipboard as Markdown, then archive them."
contexts = ["global"]
-command = ["bun", "src/export-archive.ts"]
+command = ["./bin/herdr-annotate.exe", "copy-archive"]
[[actions]]
id = "manage"
title = "Manage annotations"
description = "Browse, copy, archive, restore, and delete annotations."
contexts = ["global"]
-command = ["bun", "src/open-manager.ts"]
+command = ["./bin/herdr-annotate.exe", "manage"]
[[panes]]
id = "editor"
@@ -39,7 +54,7 @@ title = "Annotate"
placement = "popup"
width = 88
height = 24
-command = ["bun", "src/editor.ts"]
+command = ["./bin/herdr-annotate.exe", "editor"]
[[panes]]
id = "manager"
@@ -47,7 +62,7 @@ title = "Annotations"
placement = "popup"
width = 100
height = 30
-command = ["bun", "src/manager.ts"]
+command = ["./bin/herdr-annotate.exe", "manager"]
# ---------------------------------------------------------------------------------------
# Document review with plannotator-tui (https://github.com/plannotator/plannotator-tui).
diff --git a/lite-rs/bin/.gitkeep b/lite-rs/bin/.gitkeep
deleted file mode 100644
index 8b13789..0000000
--- a/lite-rs/bin/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/lite-rs/herdr-plugin.toml b/lite-rs/herdr-plugin.toml
deleted file mode 100644
index bf3f1c7..0000000
--- a/lite-rs/herdr-plugin.toml
+++ /dev/null
@@ -1,64 +0,0 @@
-# Herdr Annotate Lite, native Rust evaluation build.
-# Link locally after staging a build with scripts/stage-local.sh.
-
-id = "annotate"
-name = "Annotate"
-version = "0.3.0-rust.1"
-min_herdr_version = "0.8.0"
-description = "Comment on terminal selections and copy annotations as agent context. Native Rust evaluation build."
-platforms = ["linux", "macos", "windows"]
-
-[[build]]
-platforms = ["macos", "linux"]
-command = ["bash", "scripts/fetch-herdr-annotate.sh"]
-
-[[build]]
-platforms = ["windows"]
-command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "scripts/fetch-herdr-annotate.ps1"]
-
-# Herdr resolves explicit relative programs against $HERDR_PLUGIN_ROOT. The common `.exe`
-# suffix lets one argv-only manifest work on all three platforms; it is an ordinary executable
-# filename on macOS/Linux and the required executable suffix on Windows.
-[[actions]]
-id = "capture"
-title = "Annotate selection"
-description = "Open a comment dialog for the current terminal selection."
-contexts = ["pane"]
-command = ["./bin/herdr-annotate.exe", "capture"]
-
-[[actions]]
-id = "copy-context"
-title = "Copy annotations as context"
-description = "Copy all saved annotations to the clipboard as Markdown."
-contexts = ["global"]
-command = ["./bin/herdr-annotate.exe", "copy-context"]
-
-[[actions]]
-id = "copy-archive"
-title = "Copy annotations as context and archive them"
-description = "Copy all saved annotations to the clipboard as Markdown, then archive them."
-contexts = ["global"]
-command = ["./bin/herdr-annotate.exe", "copy-archive"]
-
-[[actions]]
-id = "manage"
-title = "Manage annotations"
-description = "Browse, copy, archive, restore, and delete annotations."
-contexts = ["global"]
-command = ["./bin/herdr-annotate.exe", "manage"]
-
-[[panes]]
-id = "editor"
-title = "Annotate"
-placement = "popup"
-width = 88
-height = 24
-command = ["./bin/herdr-annotate.exe", "editor"]
-
-[[panes]]
-id = "manager"
-title = "Annotations"
-placement = "popup"
-width = 100
-height = 30
-command = ["./bin/herdr-annotate.exe", "manager"]
diff --git a/lite-rs/scripts/stage-local.sh b/lite-rs/scripts/stage-local.sh
deleted file mode 100755
index 7ea9727..0000000
--- a/lite-rs/scripts/stage-local.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env bash
-# Build and stage the native runtime for `herdr plugin link`, which intentionally skips
-# manifest build hooks. Run from any directory in the checkout.
-set -euo pipefail
-
-plugin_root="$(cd "$(dirname "$0")/.." && pwd)"
-repository_root="$(cd "$plugin_root/.." && pwd)"
-cargo build --manifest-path "$repository_root/rust/Cargo.toml" --release
-HERDR_ANNOTATE_BIN="$repository_root/rust/target/release/herdr-annotate" \
- bash "$plugin_root/scripts/fetch-herdr-annotate.sh"
-echo "staged $plugin_root/bin/herdr-annotate.exe"
-echo "link with: herdr plugin link $plugin_root"
diff --git a/lite/herdr-plugin.toml b/lite/herdr-plugin.toml
index e6d5374..7337920 100644
--- a/lite/herdr-plugin.toml
+++ b/lite/herdr-plugin.toml
@@ -1,41 +1,57 @@
-# Herdr Annotate, lite: the TypeScript tools only, no plannotator-tui binary download.
+# Herdr Annotate, lite: the terminal annotation tools only, no plannotator-tui binary download.
# Install with: herdr plugin install plannotator/herdr-annotate/lite
# The full plugin at the repository root adds document review with plannotator-tui.
+#
+# The build hook and every command reach one directory up, so the Lite variant shares the
+# repository's single staged binary the way it used to share its TypeScript sources. Link a local
+# build with scripts/stage-local.sh, which `herdr plugin link` cannot run for you.
id = "annotate"
name = "Annotate"
-version = "0.3.0"
+version = "0.4.0"
min_herdr_version = "0.8.0"
description = "Comment on terminal selections and copy annotations as agent context."
platforms = ["linux", "macos", "windows"]
+[[build]]
+platforms = ["macos", "linux"]
+command = ["bash", "../scripts/fetch-herdr-annotate.sh"]
+
+[[build]]
+platforms = ["windows"]
+command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "../scripts/fetch-herdr-annotate.ps1"]
+
+# Herdr resolves explicit relative programs against $HERDR_PLUGIN_ROOT, and the two panes below
+# are opened by the plugin itself with --cwd set to that root. The common `.exe` suffix lets one
+# argv-only manifest work on all three platforms; it is an ordinary executable filename on
+# macOS/Linux and the required executable suffix on Windows.
[[actions]]
id = "capture"
title = "Annotate selection"
description = "Open a comment dialog for the current terminal selection."
contexts = ["pane"]
-command = ["bun", "../src/capture.ts"]
+command = ["../bin/herdr-annotate.exe", "capture"]
[[actions]]
id = "copy-context"
title = "Copy annotations as context"
description = "Copy all saved annotations to the clipboard as Markdown."
contexts = ["global"]
-command = ["bun", "../src/export.ts"]
+command = ["../bin/herdr-annotate.exe", "copy-context"]
[[actions]]
id = "copy-archive"
title = "Copy annotations as context and archive them"
description = "Copy all saved annotations to the clipboard as Markdown, then archive them."
contexts = ["global"]
-command = ["bun", "../src/export-archive.ts"]
+command = ["../bin/herdr-annotate.exe", "copy-archive"]
[[actions]]
id = "manage"
title = "Manage annotations"
description = "Browse, copy, archive, restore, and delete annotations."
contexts = ["global"]
-command = ["bun", "../src/open-manager.ts"]
+command = ["../bin/herdr-annotate.exe", "manage"]
[[panes]]
id = "editor"
@@ -43,7 +59,7 @@ title = "Annotate"
placement = "popup"
width = 88
height = 24
-command = ["bun", "../src/editor.ts"]
+command = ["../bin/herdr-annotate.exe", "editor"]
[[panes]]
id = "manager"
@@ -51,4 +67,4 @@ title = "Annotations"
placement = "popup"
width = 100
height = 30
-command = ["bun", "../src/manager.ts"]
+command = ["../bin/herdr-annotate.exe", "manager"]
diff --git a/package.json b/package.json
deleted file mode 100644
index b1971bc..0000000
--- a/package.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "name": "herdr-annotate",
- "version": "0.3.0",
- "private": true,
- "type": "module",
- "scripts": {
- "test": "bun test",
- "typecheck": "bunx tsc --noEmit"
- },
- "devDependencies": {
- "@types/bun": "latest",
- "typescript": "^5.9.2"
- }
-}
diff --git a/rust/src/edit_keys.rs b/rust/src/edit_keys.rs
index 94726de..391cf80 100644
--- a/rust/src/edit_keys.rs
+++ b/rust/src/edit_keys.rs
@@ -1,7 +1,8 @@
//! Word and line editing keys for the comment editor.
//!
-//! This mirrors `src/edit-keys.ts` character for character so both runtimes move, and kill, by the
-//! same boundaries. The helpers work on the editor's `Vec` buffer and its scalar cursor.
+//! This mirrors the retired Bun runtime's `edit-keys.ts` character for character, so the editor
+//! still moves, and kills, by the same boundaries. The helpers work on the editor's `Vec`
+//! buffer and its scalar cursor.
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
diff --git a/rust/src/editor.rs b/rust/src/editor.rs
index 81860d2..8d56ff8 100644
--- a/rust/src/editor.rs
+++ b/rust/src/editor.rs
@@ -224,7 +224,7 @@ impl EditorApp {
false
}
- /// Apply a word or line action, mirroring the `resolveEditKey` branches in `src/editor.ts`.
+ /// Apply a word or line action, mirroring the retired Bun editor's `resolveEditKey` branches.
fn apply_edit_action(&mut self, action: EditAction) {
match action {
EditAction::WordLeft => self.cursor = word_start(&self.comment, self.cursor),
diff --git a/lite-rs/scripts/fetch-herdr-annotate.ps1 b/scripts/fetch-herdr-annotate.ps1
similarity index 72%
rename from lite-rs/scripts/fetch-herdr-annotate.ps1
rename to scripts/fetch-herdr-annotate.ps1
index 01d5a09..37a6a06 100644
--- a/lite-rs/scripts/fetch-herdr-annotate.ps1
+++ b/scripts/fetch-herdr-annotate.ps1
@@ -1,12 +1,14 @@
$ErrorActionPreference = "Stop"
Set-Location (Join-Path $PSScriptRoot "..")
-$version = (Get-Content "herdr-annotate.version" -Raw).Trim()
+$versionContents = Get-Content -LiteralPath "herdr-annotate.version" -Raw
+$version = if ($null -eq $versionContents) { "" } else { [string]$versionContents }
+$version = $version.Trim()
if (-not $version) { throw "herdr-annotate.version is empty" }
New-Item -ItemType Directory -Force "bin" | Out-Null
$destination = Join-Path "bin" "herdr-annotate.exe"
$stamp = Join-Path "bin" "herdr-annotate.version"
-$installed = if (Test-Path $stamp) { (Get-Content $stamp -Raw).Trim() } else { "" }
+$installed = if (Test-Path -LiteralPath $stamp -PathType Leaf) { ([string](Get-Content -LiteralPath $stamp -Raw)).Trim() } else { "" }
if ((Test-Path $destination) -and $installed -eq $version -and -not $env:HERDR_ANNOTATE_BIN) {
Write-Output "herdr-annotate $version already installed"
@@ -17,9 +19,9 @@ if ($env:HERDR_ANNOTATE_BIN) {
if (-not (Test-Path $env:HERDR_ANNOTATE_BIN -PathType Leaf)) {
throw "HERDR_ANNOTATE_BIN is not a file: $env:HERDR_ANNOTATE_BIN"
}
- Copy-Item -Force $env:HERDR_ANNOTATE_BIN "$destination.tmp"
- Move-Item -Force "$destination.tmp" $destination
- Set-Content -NoNewline $stamp $version
+ Copy-Item -Force -LiteralPath $env:HERDR_ANNOTATE_BIN -Destination "$destination.tmp"
+ Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination
+ Set-Content -LiteralPath $stamp -NoNewline -Value $version
Write-Output "installed herdr-annotate from $env:HERDR_ANNOTATE_BIN (local build, stamped $version)"
exit 0
}
@@ -41,10 +43,10 @@ try {
if (-not $line) { throw "$asset is not listed in $base/SHA256SUMS" }
$expected = ($line -split "\s+")[0].ToLowerInvariant()
$actual = (Get-FileHash -Algorithm SHA256 (Join-Path $temporary $asset)).Hash.ToLowerInvariant()
- if ($actual -ne $expected) { throw "sha256 mismatch for $asset: expected $expected, got $actual" }
+ if ($actual -ne $expected) { throw "sha256 mismatch for ${asset}: expected $expected, got $actual" }
Copy-Item -Force (Join-Path $temporary $asset) "$destination.tmp"
- Move-Item -Force "$destination.tmp" $destination
- Set-Content -NoNewline $stamp $version
+ Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination
+ Set-Content -LiteralPath $stamp -NoNewline -Value $version
Write-Output "installed herdr-annotate $version ($target)"
}
finally {
diff --git a/lite-rs/scripts/fetch-herdr-annotate.sh b/scripts/fetch-herdr-annotate.sh
similarity index 100%
rename from lite-rs/scripts/fetch-herdr-annotate.sh
rename to scripts/fetch-herdr-annotate.sh
diff --git a/scripts/lite-goldens/fixtures/bun-editor-annotations.jsonl b/scripts/lite-goldens/fixtures/bun-editor-annotations.jsonl
new file mode 100644
index 0000000..c6f1a20
--- /dev/null
+++ b/scripts/lite-goldens/fixtures/bun-editor-annotations.jsonl
@@ -0,0 +1 @@
+{"selectedText":"selected wide 한글 text\nwith a second line","context":{"workspace_label":"api 한","tab_label":"server"},"capturedAt":"2026-09-10T05:23:55.654Z","id":"72a4a0d4-f9c9-4e02-afe0-a189fbdf3a6a","comment":"alpha 한글 é\nbt","createdAt":"2026-09-10T05:23:56.623Z"}
diff --git a/scripts/lite-goldens/process.capture.blank-handoff.json b/scripts/lite-goldens/process.capture.blank-handoff.json
new file mode 100644
index 0000000..dd0e2c8
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.blank-handoff.json
@@ -0,0 +1,26 @@
+{
+ "process.capture.blank-handoff.artifact": [
+ "{",
+ " \"pending\": {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"clipboard after blank handoff\\\",\\\"context\\\":{},\\\"capturedAt\\\":\\\"\\\"}\\n\",",
+ " \"mode\": \"0600\",",
+ " \"name\": \"pending--.json\"",
+ " },",
+ " \"runtime\": \"[\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\".\\\"\\n },\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\"herdr-annotate-\\\"\\n }\\n]\\n\"",
+ "}"
+ ],
+ "process.capture.blank-handoff.exit": [
+ "0"
+ ],
+ "process.capture.blank-handoff.processes": [
+ "{\"args\":[],\"command\":\"clipboard-read\"}",
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"editor\",\"--placement\",\"popup\",\"--width\",\"88\",\"--height\",\"24\",\"--env\",\"HERDR_ANNOTATE_PENDING=/pending--.json\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.blank-handoff.stderr": [
+ ""
+ ],
+ "process.capture.blank-handoff.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.clipboard.json b/scripts/lite-goldens/process.capture.clipboard.json
new file mode 100644
index 0000000..5d57212
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.clipboard.json
@@ -0,0 +1,23 @@
+{
+ "process.capture.clipboard.artifact": [
+ "{",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"clipboard 한 selection\\\",\\\"context\\\":{},\\\"capturedAt\\\":\\\"\\\"}\\n\",",
+ " \"mode\": \"0600\",",
+ " \"name\": \"pending--.json\"",
+ "}"
+ ],
+ "process.capture.clipboard.exit": [
+ "0"
+ ],
+ "process.capture.clipboard.processes": [
+ "{\"args\":[],\"command\":\"clipboard-read\"}",
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"editor\",\"--placement\",\"popup\",\"--width\",\"88\",\"--height\",\"24\",\"--env\",\"HERDR_ANNOTATE_PENDING=/pending--.json\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.clipboard.stderr": [
+ ""
+ ],
+ "process.capture.clipboard.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.context.json b/scripts/lite-goldens/process.capture.context.json
new file mode 100644
index 0000000..acc1a68
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.context.json
@@ -0,0 +1,25 @@
+{
+ "process.capture.context.artifact": [
+ "{",
+ " \"pending\": {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"context selection 한글\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"capturedAt\\\":\\\"\\\"}\\n\",",
+ " \"mode\": \"0600\",",
+ " \"name\": \"pending--.json\"",
+ " },",
+ " \"runtime\": \"[\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\".\\\"\\n },\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\"herdr-annotate-\\\"\\n },\\n {\\n \\\"bytes\\\": \\\"lower-priority handoff\\\",\\n \\\"kind\\\": \\\"file\\\",\\n \\\"mode\\\": \\\"0644\\\",\\n \\\"path\\\": \\\"herdr-annotate-/selection\\\"\\n }\\n]\\n\"",
+ "}"
+ ],
+ "process.capture.context.exit": [
+ "0"
+ ],
+ "process.capture.context.processes": [
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"editor\",\"--placement\",\"popup\",\"--width\",\"88\",\"--height\",\"24\",\"--env\",\"HERDR_ANNOTATE_PENDING=/pending--.json\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.context.stderr": [
+ ""
+ ],
+ "process.capture.context.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.empty.json b/scripts/lite-goldens/process.capture.empty.json
new file mode 100644
index 0000000..0a820ab
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.empty.json
@@ -0,0 +1,19 @@
+{
+ "process.capture.empty.artifact": [
+ "[]"
+ ],
+ "process.capture.empty.exit": [
+ "0"
+ ],
+ "process.capture.empty.processes": [
+ "{\"args\":[],\"command\":\"clipboard-read\"}",
+ "{\"args\":[\"notification\",\"show\",\"Nothing to annotate\",\"--body\",\"Select text in Herdr or copy text to the clipboard.\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.empty.stderr": [
+ ""
+ ],
+ "process.capture.empty.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.handoff.json b/scripts/lite-goldens/process.capture.handoff.json
new file mode 100644
index 0000000..e009aa7
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.handoff.json
@@ -0,0 +1,25 @@
+{
+ "process.capture.handoff.artifact": [
+ "{",
+ " \"pending\": {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"handoff selection\\\\n\\\",\\\"context\\\":{},\\\"capturedAt\\\":\\\"\\\"}\\n\",",
+ " \"mode\": \"0600\",",
+ " \"name\": \"pending--.json\"",
+ " },",
+ " \"runtime\": \"[\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\".\\\"\\n },\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\"herdr-annotate-\\\"\\n }\\n]\\n\"",
+ "}"
+ ],
+ "process.capture.handoff.exit": [
+ "0"
+ ],
+ "process.capture.handoff.processes": [
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"editor\",\"--placement\",\"popup\",\"--width\",\"88\",\"--height\",\"24\",\"--env\",\"HERDR_ANNOTATE_PENDING=/pending--.json\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.handoff.stderr": [
+ ""
+ ],
+ "process.capture.handoff.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.invalid-context.json b/scripts/lite-goldens/process.capture.invalid-context.json
new file mode 100644
index 0000000..5400121
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.invalid-context.json
@@ -0,0 +1,23 @@
+{
+ "process.capture.invalid-context.artifact": [
+ "{",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"clipboard after invalid context\\\",\\\"context\\\":{},\\\"capturedAt\\\":\\\"\\\"}\\n\",",
+ " \"mode\": \"0600\",",
+ " \"name\": \"pending--.json\"",
+ "}"
+ ],
+ "process.capture.invalid-context.exit": [
+ "0"
+ ],
+ "process.capture.invalid-context.processes": [
+ "{\"args\":[],\"command\":\"clipboard-read\"}",
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"editor\",\"--placement\",\"popup\",\"--width\",\"88\",\"--height\",\"24\",\"--env\",\"HERDR_ANNOTATE_PENDING=/pending--.json\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.invalid-context.stderr": [
+ ""
+ ],
+ "process.capture.invalid-context.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.invalid-utf8-handoff.json b/scripts/lite-goldens/process.capture.invalid-utf8-handoff.json
new file mode 100644
index 0000000..14440fa
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.invalid-utf8-handoff.json
@@ -0,0 +1,25 @@
+{
+ "process.capture.invalid-utf8-handoff.artifact": [
+ "{",
+ " \"pending\": {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"invalid-�-handoff\\\",\\\"context\\\":{},\\\"capturedAt\\\":\\\"\\\"}\\n\",",
+ " \"mode\": \"0600\",",
+ " \"name\": \"pending--.json\"",
+ " },",
+ " \"runtime\": \"[\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\".\\\"\\n },\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\"herdr-annotate-\\\"\\n }\\n]\\n\"",
+ "}"
+ ],
+ "process.capture.invalid-utf8-handoff.exit": [
+ "0"
+ ],
+ "process.capture.invalid-utf8-handoff.processes": [
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"editor\",\"--placement\",\"popup\",\"--width\",\"88\",\"--height\",\"24\",\"--env\",\"HERDR_ANNOTATE_PENDING=/pending--.json\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.invalid-utf8-handoff.stderr": [
+ ""
+ ],
+ "process.capture.invalid-utf8-handoff.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.missing-root.json b/scripts/lite-goldens/process.capture.missing-root.json
new file mode 100644
index 0000000..24c7332
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.missing-root.json
@@ -0,0 +1,16 @@
+{
+ "process.capture.missing-root.exit": [
+ "1"
+ ],
+ "process.capture.missing-root.processes": [
+ "{\"args\":[\"notification\",\"show\",\"Annotate failed\",\"--body\",\"HERDR_PLUGIN_ROOT is not set\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.missing-root.stderr": [
+ "HERDR_PLUGIN_ROOT is not set",
+ ""
+ ],
+ "process.capture.missing-root.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.missing-state.json b/scripts/lite-goldens/process.capture.missing-state.json
new file mode 100644
index 0000000..22335f8
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.missing-state.json
@@ -0,0 +1,16 @@
+{
+ "process.capture.missing-state.exit": [
+ "1"
+ ],
+ "process.capture.missing-state.processes": [
+ "{\"args\":[\"notification\",\"show\",\"Annotate failed\",\"--body\",\"HERDR_PLUGIN_STATE_DIR is not set\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.missing-state.stderr": [
+ "HERDR_PLUGIN_STATE_DIR is not set",
+ ""
+ ],
+ "process.capture.missing-state.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.no-clipboard.json b/scripts/lite-goldens/process.capture.no-clipboard.json
new file mode 100644
index 0000000..cb685f0
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.no-clipboard.json
@@ -0,0 +1,20 @@
+{
+ "process.capture.no-clipboard.artifact": [
+ "[]"
+ ],
+ "process.capture.no-clipboard.exit": [
+ "1"
+ ],
+ "process.capture.no-clipboard.processes": [
+ "{\"args\":[],\"command\":\"clipboard-read\"}",
+ "{\"args\":[\"notification\",\"show\",\"Annotate failed\",\"--body\",\"No supported clipboard reader is available\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.no-clipboard.stderr": [
+ "No supported clipboard reader is available",
+ ""
+ ],
+ "process.capture.no-clipboard.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.open-failure.json b/scripts/lite-goldens/process.capture.open-failure.json
new file mode 100644
index 0000000..1c661f0
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.open-failure.json
@@ -0,0 +1,20 @@
+{
+ "process.capture.open-failure.artifact": [
+ "[]"
+ ],
+ "process.capture.open-failure.exit": [
+ "1"
+ ],
+ "process.capture.open-failure.processes": [
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"editor\",\"--placement\",\"popup\",\"--width\",\"88\",\"--height\",\"24\",\"--env\",\"HERDR_ANNOTATE_PENDING=/pending--.json\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ "{\"args\":[\"notification\",\"show\",\"Annotate failed\",\"--body\",\"pane open failed\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.open-failure.stderr": [
+ "pane open failed",
+ ""
+ ],
+ "process.capture.open-failure.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.capture.stale-handoff.json b/scripts/lite-goldens/process.capture.stale-handoff.json
new file mode 100644
index 0000000..350df90
--- /dev/null
+++ b/scripts/lite-goldens/process.capture.stale-handoff.json
@@ -0,0 +1,26 @@
+{
+ "process.capture.stale-handoff.artifact": [
+ "{",
+ " \"pending\": {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"clipboard after stale handoff\\\",\\\"context\\\":{},\\\"capturedAt\\\":\\\"\\\"}\\n\",",
+ " \"mode\": \"0600\",",
+ " \"name\": \"pending--.json\"",
+ " },",
+ " \"runtime\": \"[\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\".\\\"\\n },\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\"herdr-annotate-\\\"\\n }\\n]\\n\"",
+ "}"
+ ],
+ "process.capture.stale-handoff.exit": [
+ "0"
+ ],
+ "process.capture.stale-handoff.processes": [
+ "{\"args\":[],\"command\":\"clipboard-read\"}",
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"editor\",\"--placement\",\"popup\",\"--width\",\"88\",\"--height\",\"24\",\"--env\",\"HERDR_ANNOTATE_PENDING=/pending--.json\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.capture.stale-handoff.stderr": [
+ ""
+ ],
+ "process.capture.stale-handoff.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy-archive.empty.json b/scripts/lite-goldens/process.copy-archive.empty.json
new file mode 100644
index 0000000..fb75b97
--- /dev/null
+++ b/scripts/lite-goldens/process.copy-archive.empty.json
@@ -0,0 +1,21 @@
+{
+ "process.copy-archive.empty.artifact": [
+ "{",
+ " \"clipboard\": \"\",",
+ " \"state\": \"[\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0755\\\",\\n \\\"path\\\": \\\".\\\"\\n }\\n]\\n\"",
+ "}"
+ ],
+ "process.copy-archive.empty.exit": [
+ "0"
+ ],
+ "process.copy-archive.empty.processes": [
+ "{\"args\":[\"notification\",\"show\",\"No annotations\",\"--body\",\"There is nothing to copy yet.\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy-archive.empty.stderr": [
+ ""
+ ],
+ "process.copy-archive.empty.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy-archive.missing-state.json b/scripts/lite-goldens/process.copy-archive.missing-state.json
new file mode 100644
index 0000000..095b1cc
--- /dev/null
+++ b/scripts/lite-goldens/process.copy-archive.missing-state.json
@@ -0,0 +1,16 @@
+{
+ "process.copy-archive.missing-state.exit": [
+ "1"
+ ],
+ "process.copy-archive.missing-state.processes": [
+ "{\"args\":[\"notification\",\"show\",\"Copy and archive failed\",\"--body\",\"HERDR_PLUGIN_STATE_DIR is not set\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy-archive.missing-state.stderr": [
+ "HERDR_PLUGIN_STATE_DIR is not set",
+ ""
+ ],
+ "process.copy-archive.missing-state.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy-archive.no-clipboard.json b/scripts/lite-goldens/process.copy-archive.no-clipboard.json
new file mode 100644
index 0000000..64839d3
--- /dev/null
+++ b/scripts/lite-goldens/process.copy-archive.no-clipboard.json
@@ -0,0 +1,23 @@
+{
+ "process.copy-archive.no-clipboard.artifact": [
+ "{",
+ " \"clipboard\": \"# Annotated context\\n\\n## Annotation 1\\n\\nSource: api 한 / server\\n\\nSelected text:\\n\\n```\\nthird selection\\n```\\n\\nComment:\\n\\nthird comment\\n\\n## Annotation 2\\n\\nSource: api 한 / server\\n\\nSelected text:\\n\\n```\\nwide 한글 selection and enough text to exercise clipping at the detail edge\\n```\\n\\nComment:\\n\\nsecond comment\\n\\n## Annotation 3\\n\\nSource: api 한 / server\\n\\nSelected text:\\n\\n````\\nfirst selection with ``` ticks\\nand a second line\\n````\\n\\nComment:\\n\\nfirst comment\\nwith two lines\\n\",",
+ " \"state\": \"[\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0700\\\",\\n \\\"path\\\": \\\".\\\"\\n },\\n {\\n \\\"bytes\\\": \\\"{\\\\\\\"selectedText\\\\\\\":\\\\\\\"first selection with ``` ticks\\\\\\\\nand a second line\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-08T00:00:00.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-one\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"first comment\\\\\\\\nwith two lines\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-08T00:00:01.000Z\\\\\\\"}\\\\n{\\\\\\\"selectedText\\\\\\\":\\\\\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-09T10:11:12.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-two\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"second comment\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-09T10:11:13.000Z\\\\\\\"}\\\\n{\\\\\\\"selectedText\\\\\\\":\\\\\\\"third selection\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-10T20:21:22.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-three\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"third comment\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-10T20:21:23.000Z\\\\\\\"}\\\\n\\\",\\n \\\"kind\\\": \\\"file\\\",\\n \\\"mode\\\": \\\"0600\\\",\\n \\\"path\\\": \\\"annotations.jsonl\\\"\\n },\\n {\\n \\\"bytes\\\": \\\"{\\\\\\\"version\\\\\\\":1,\\\\\\\"id\\\\\\\":\\\\\\\"archive-old\\\\\\\",\\\\\\\"archivedAt\\\\\\\":\\\\\\\"2026-08-20T01:02:03.000Z\\\\\\\",\\\\\\\"annotations\\\\\\\":[{\\\\\\\"selectedText\\\\\\\":\\\\\\\"first selection with ``` ticks\\\\\\\\nand a second line\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-08T00:00:00.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-one\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"first comment\\\\\\\\nwith two lines\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-08T00:00:01.000Z\\\\\\\"}]}\\\\n{\\\\\\\"version\\\\\\\":1,\\\\\\\"id\\\\\\\":\\\\\\\"archive-new\\\\\\\",\\\\\\\"archivedAt\\\\\\\":\\\\\\\"2026-08-21T01:02:03.000Z\\\\\\\",\\\\\\\"annotations\\\\\\\":[{\\\\\\\"selectedText\\\\\\\":\\\\\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-09T10:11:12.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-two\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"second comment\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-09T10:11:13.000Z\\\\\\\"},{\\\\\\\"selectedText\\\\\\\":\\\\\\\"third selection\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-10T20:21:22.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-three\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"third comment\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-10T20:21:23.000Z\\\\\\\"}]}\\\\n\\\",\\n \\\"kind\\\": \\\"file\\\",\\n \\\"mode\\\": \\\"0600\\\",\\n \\\"path\\\": \\\"archives.jsonl\\\"\\n }\\n]\\n\"",
+ "}"
+ ],
+ "process.copy-archive.no-clipboard.exit": [
+ "1"
+ ],
+ "process.copy-archive.no-clipboard.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ "{\"args\":[\"notification\",\"show\",\"Copy and archive failed\",\"--body\",\"No supported clipboard writer is available\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy-archive.no-clipboard.stderr": [
+ "No supported clipboard writer is available",
+ ""
+ ],
+ "process.copy-archive.no-clipboard.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy-archive.populated.json b/scripts/lite-goldens/process.copy-archive.populated.json
new file mode 100644
index 0000000..468bcd2
--- /dev/null
+++ b/scripts/lite-goldens/process.copy-archive.populated.json
@@ -0,0 +1,22 @@
+{
+ "process.copy-archive.populated.artifact": [
+ "{",
+ " \"clipboard\": \"# Annotated context\\n\\n## Annotation 1\\n\\nSource: api 한 / server\\n\\nSelected text:\\n\\n```\\nthird selection\\n```\\n\\nComment:\\n\\nthird comment\\n\\n## Annotation 2\\n\\nSource: api 한 / server\\n\\nSelected text:\\n\\n```\\nwide 한글 selection and enough text to exercise clipping at the detail edge\\n```\\n\\nComment:\\n\\nsecond comment\\n\\n## Annotation 3\\n\\nSource: api 한 / server\\n\\nSelected text:\\n\\n````\\nfirst selection with ``` ticks\\nand a second line\\n````\\n\\nComment:\\n\\nfirst comment\\nwith two lines\\n\",",
+ " \"state\": \"[\\n {\\n \\\"kind\\\": \\\"dir\\\",\\n \\\"mode\\\": \\\"0700\\\",\\n \\\"path\\\": \\\".\\\"\\n },\\n {\\n \\\"bytes\\\": \\\"\\\",\\n \\\"kind\\\": \\\"file\\\",\\n \\\"mode\\\": \\\"0600\\\",\\n \\\"path\\\": \\\"annotations.jsonl\\\"\\n },\\n {\\n \\\"bytes\\\": \\\"{\\\\\\\"version\\\\\\\":1,\\\\\\\"id\\\\\\\":\\\\\\\"archive-old\\\\\\\",\\\\\\\"archivedAt\\\\\\\":\\\\\\\"2026-08-20T01:02:03.000Z\\\\\\\",\\\\\\\"annotations\\\\\\\":[{\\\\\\\"selectedText\\\\\\\":\\\\\\\"first selection with ``` ticks\\\\\\\\nand a second line\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-08T00:00:00.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-one\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"first comment\\\\\\\\nwith two lines\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-08T00:00:01.000Z\\\\\\\"}]}\\\\n{\\\\\\\"version\\\\\\\":1,\\\\\\\"id\\\\\\\":\\\\\\\"archive-new\\\\\\\",\\\\\\\"archivedAt\\\\\\\":\\\\\\\"2026-08-21T01:02:03.000Z\\\\\\\",\\\\\\\"annotations\\\\\\\":[{\\\\\\\"selectedText\\\\\\\":\\\\\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-09T10:11:12.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-two\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"second comment\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-09T10:11:13.000Z\\\\\\\"},{\\\\\\\"selectedText\\\\\\\":\\\\\\\"third selection\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-10T20:21:22.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-three\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"third comment\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-10T20:21:23.000Z\\\\\\\"}]}\\\\n{\\\\\\\"version\\\\\\\":1,\\\\\\\"id\\\\\\\":\\\\\\\"\\\\\\\",\\\\\\\"archivedAt\\\\\\\":\\\\\\\"\\\\\\\",\\\\\\\"annotations\\\\\\\":[{\\\\\\\"selectedText\\\\\\\":\\\\\\\"first selection with ``` ticks\\\\\\\\nand a second line\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-08T00:00:00.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-one\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"first comment\\\\\\\\nwith two lines\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-08T00:00:01.000Z\\\\\\\"},{\\\\\\\"selectedText\\\\\\\":\\\\\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-09T10:11:12.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-two\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"second comment\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-09T10:11:13.000Z\\\\\\\"},{\\\\\\\"selectedText\\\\\\\":\\\\\\\"third selection\\\\\\\",\\\\\\\"capturedAt\\\\\\\":\\\\\\\"2026-08-10T20:21:22.000Z\\\\\\\",\\\\\\\"context\\\\\\\":{\\\\\\\"workspace_id\\\\\\\":\\\\\\\"workspace-1\\\\\\\",\\\\\\\"workspace_label\\\\\\\":\\\\\\\"api 한\\\\\\\",\\\\\\\"tab_id\\\\\\\":\\\\\\\"tab-1\\\\\\\",\\\\\\\"tab_label\\\\\\\":\\\\\\\"server\\\\\\\",\\\\\\\"focused_pane_id\\\\\\\":\\\\\\\"pane-1\\\\\\\",\\\\\\\"focused_pane_cwd\\\\\\\":\\\\\\\"/workspace\\\\\\\",\\\\\\\"focused_pane_agent\\\\\\\":\\\\\\\"codex\\\\\\\"},\\\\\\\"id\\\\\\\":\\\\\\\"ann-three\\\\\\\",\\\\\\\"comment\\\\\\\":\\\\\\\"third comment\\\\\\\",\\\\\\\"createdAt\\\\\\\":\\\\\\\"2026-08-10T20:21:23.000Z\\\\\\\"}]}\\\\n\\\",\\n \\\"kind\\\": \\\"file\\\",\\n \\\"mode\\\": \\\"0600\\\",\\n \\\"path\\\": \\\"archives.jsonl\\\"\\n }\\n]\\n\"",
+ "}"
+ ],
+ "process.copy-archive.populated.exit": [
+ "0"
+ ],
+ "process.copy-archive.populated.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ "{\"args\":[\"notification\",\"show\",\"Annotations copied and archived\",\"--body\",\"3 annotations copied as Markdown and archived.\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy-archive.populated.stderr": [
+ ""
+ ],
+ "process.copy-archive.populated.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy.busy-lock.json b/scripts/lite-goldens/process.copy.busy-lock.json
new file mode 100644
index 0000000..3ae150e
--- /dev/null
+++ b/scripts/lite-goldens/process.copy.busy-lock.json
@@ -0,0 +1,31 @@
+{
+ "process.copy.busy-lock.artifact": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0755\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0755\",",
+ " \"path\": \".annotations.lock\"",
+ " }",
+ "]",
+ ""
+ ],
+ "process.copy.busy-lock.exit": [
+ "1"
+ ],
+ "process.copy.busy-lock.processes": [
+ "{\"args\":[\"notification\",\"show\",\"Copy failed\",\"--body\",\"Annotations are busy; try again.\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy.busy-lock.stderr": [
+ "Annotations are busy; try again.",
+ ""
+ ],
+ "process.copy.busy-lock.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy.empty.json b/scripts/lite-goldens/process.copy.empty.json
new file mode 100644
index 0000000..97c539b
--- /dev/null
+++ b/scripts/lite-goldens/process.copy.empty.json
@@ -0,0 +1,25 @@
+{
+ "process.copy.empty.artifact": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0755\",",
+ " \"path\": \".\"",
+ " }",
+ "]",
+ ""
+ ],
+ "process.copy.empty.exit": [
+ "0"
+ ],
+ "process.copy.empty.processes": [
+ "{\"args\":[\"notification\",\"show\",\"No annotations\",\"--body\",\"There is nothing to copy yet.\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy.empty.stderr": [
+ ""
+ ],
+ "process.copy.empty.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy.invalid-store.json b/scripts/lite-goldens/process.copy.invalid-store.json
new file mode 100644
index 0000000..15e24df
--- /dev/null
+++ b/scripts/lite-goldens/process.copy.invalid-store.json
@@ -0,0 +1,16 @@
+{
+ "process.copy.invalid-store.exit": [
+ "1"
+ ],
+ "process.copy.invalid-store.processes": [
+ "{\"args\":[\"notification\",\"show\",\"Copy failed\",\"--body\",\"Unable to read annotations (invalid data)\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy.invalid-store.stderr": [
+ "Unable to read annotations (invalid data)",
+ ""
+ ],
+ "process.copy.invalid-store.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy.missing-state.json b/scripts/lite-goldens/process.copy.missing-state.json
new file mode 100644
index 0000000..5cb18ee
--- /dev/null
+++ b/scripts/lite-goldens/process.copy.missing-state.json
@@ -0,0 +1,16 @@
+{
+ "process.copy.missing-state.exit": [
+ "1"
+ ],
+ "process.copy.missing-state.processes": [
+ "{\"args\":[\"notification\",\"show\",\"Copy failed\",\"--body\",\"HERDR_PLUGIN_STATE_DIR is not set\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy.missing-state.stderr": [
+ "HERDR_PLUGIN_STATE_DIR is not set",
+ ""
+ ],
+ "process.copy.missing-state.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy.no-clipboard.json b/scripts/lite-goldens/process.copy.no-clipboard.json
new file mode 100644
index 0000000..f73a89e
--- /dev/null
+++ b/scripts/lite-goldens/process.copy.no-clipboard.json
@@ -0,0 +1,65 @@
+{
+ "process.copy.no-clipboard.artifact": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "third selection",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "third comment",
+ "",
+ "## Annotation 2",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "wide 한글 selection and enough text to exercise clipping at the detail edge",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "second comment",
+ "",
+ "## Annotation 3",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "````",
+ "first selection with ``` ticks",
+ "and a second line",
+ "````",
+ "",
+ "Comment:",
+ "",
+ "first comment",
+ "with two lines",
+ ""
+ ],
+ "process.copy.no-clipboard.exit": [
+ "1"
+ ],
+ "process.copy.no-clipboard.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ "{\"args\":[\"notification\",\"show\",\"Copy failed\",\"--body\",\"No supported clipboard writer is available\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy.no-clipboard.stderr": [
+ "No supported clipboard writer is available",
+ ""
+ ],
+ "process.copy.no-clipboard.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy.populated.json b/scripts/lite-goldens/process.copy.populated.json
new file mode 100644
index 0000000..6eccba4
--- /dev/null
+++ b/scripts/lite-goldens/process.copy.populated.json
@@ -0,0 +1,64 @@
+{
+ "process.copy.populated.artifact": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "third selection",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "third comment",
+ "",
+ "## Annotation 2",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "wide 한글 selection and enough text to exercise clipping at the detail edge",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "second comment",
+ "",
+ "## Annotation 3",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "````",
+ "first selection with ``` ticks",
+ "and a second line",
+ "````",
+ "",
+ "Comment:",
+ "",
+ "first comment",
+ "with two lines",
+ ""
+ ],
+ "process.copy.populated.exit": [
+ "0"
+ ],
+ "process.copy.populated.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ "{\"args\":[\"notification\",\"show\",\"Annotations copied\",\"--body\",\"3 annotations copied as Markdown.\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy.populated.stderr": [
+ ""
+ ],
+ "process.copy.populated.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy.single.json b/scripts/lite-goldens/process.copy.single.json
new file mode 100644
index 0000000..9dc238a
--- /dev/null
+++ b/scripts/lite-goldens/process.copy.single.json
@@ -0,0 +1,36 @@
+{
+ "process.copy.single.artifact": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "````",
+ "first selection with ``` ticks",
+ "and a second line",
+ "````",
+ "",
+ "Comment:",
+ "",
+ "first comment",
+ "with two lines",
+ ""
+ ],
+ "process.copy.single.exit": [
+ "0"
+ ],
+ "process.copy.single.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ "{\"args\":[\"notification\",\"show\",\"Annotations copied\",\"--body\",\"1 annotation copied as Markdown.\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy.single.stderr": [
+ ""
+ ],
+ "process.copy.single.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.copy.stale-lock.json b/scripts/lite-goldens/process.copy.stale-lock.json
new file mode 100644
index 0000000..24b0078
--- /dev/null
+++ b/scripts/lite-goldens/process.copy.stale-lock.json
@@ -0,0 +1,25 @@
+{
+ "process.copy.stale-lock.artifact": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0755\",",
+ " \"path\": \".\"",
+ " }",
+ "]",
+ ""
+ ],
+ "process.copy.stale-lock.exit": [
+ "0"
+ ],
+ "process.copy.stale-lock.processes": [
+ "{\"args\":[\"notification\",\"show\",\"No annotations\",\"--body\",\"There is nothing to copy yet.\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.copy.stale-lock.stderr": [
+ ""
+ ],
+ "process.copy.stale-lock.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.editor.invalid-pending.json b/scripts/lite-goldens/process.editor.invalid-pending.json
new file mode 100644
index 0000000..8feca4d
--- /dev/null
+++ b/scripts/lite-goldens/process.editor.invalid-pending.json
@@ -0,0 +1,15 @@
+{
+ "process.editor.invalid-pending.exit": [
+ "1"
+ ],
+ "process.editor.invalid-pending.processes": [
+ ""
+ ],
+ "process.editor.invalid-pending.stderr": [
+ "Pending annotation is invalid",
+ ""
+ ],
+ "process.editor.invalid-pending.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.editor.missing-pending.json b/scripts/lite-goldens/process.editor.missing-pending.json
new file mode 100644
index 0000000..89b1213
--- /dev/null
+++ b/scripts/lite-goldens/process.editor.missing-pending.json
@@ -0,0 +1,15 @@
+{
+ "process.editor.missing-pending.exit": [
+ "1"
+ ],
+ "process.editor.missing-pending.processes": [
+ ""
+ ],
+ "process.editor.missing-pending.stderr": [
+ "Missing pending annotation",
+ ""
+ ],
+ "process.editor.missing-pending.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.manage.failure-without-stderr.json b/scripts/lite-goldens/process.manage.failure-without-stderr.json
new file mode 100644
index 0000000..95c0098
--- /dev/null
+++ b/scripts/lite-goldens/process.manage.failure-without-stderr.json
@@ -0,0 +1,17 @@
+{
+ "process.manage.failure-without-stderr.exit": [
+ "1"
+ ],
+ "process.manage.failure-without-stderr.processes": [
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"manager\",\"--placement\",\"popup\",\"--width\",\"100\",\"--height\",\"30\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ "{\"args\":[\"notification\",\"show\",\"Unable to open annotations\",\"--body\",\"herdr plugin pane open --cwd --plugin annotate --entrypoint manager --placement popup --width 100 --height 30 --focus failed\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.manage.failure-without-stderr.stderr": [
+ "herdr plugin pane open --cwd --plugin annotate --entrypoint manager --placement popup --width 100 --height 30 --focus failed",
+ ""
+ ],
+ "process.manage.failure-without-stderr.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.manage.failure.json b/scripts/lite-goldens/process.manage.failure.json
new file mode 100644
index 0000000..b16cb70
--- /dev/null
+++ b/scripts/lite-goldens/process.manage.failure.json
@@ -0,0 +1,17 @@
+{
+ "process.manage.failure.exit": [
+ "1"
+ ],
+ "process.manage.failure.processes": [
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"manager\",\"--placement\",\"popup\",\"--width\",\"100\",\"--height\",\"30\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ "{\"args\":[\"notification\",\"show\",\"Unable to open annotations\",\"--body\",\"manager open failed\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.manage.failure.stderr": [
+ "manager open failed",
+ ""
+ ],
+ "process.manage.failure.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.manage.missing-root.json b/scripts/lite-goldens/process.manage.missing-root.json
new file mode 100644
index 0000000..4094749
--- /dev/null
+++ b/scripts/lite-goldens/process.manage.missing-root.json
@@ -0,0 +1,16 @@
+{
+ "process.manage.missing-root.exit": [
+ "1"
+ ],
+ "process.manage.missing-root.processes": [
+ "{\"args\":[\"notification\",\"show\",\"Unable to open annotations\",\"--body\",\"HERDR_PLUGIN_ROOT is not set\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.manage.missing-root.stderr": [
+ "HERDR_PLUGIN_ROOT is not set",
+ ""
+ ],
+ "process.manage.missing-root.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.manage.success.json b/scripts/lite-goldens/process.manage.success.json
new file mode 100644
index 0000000..560fcbf
--- /dev/null
+++ b/scripts/lite-goldens/process.manage.success.json
@@ -0,0 +1,15 @@
+{
+ "process.manage.success.exit": [
+ "0"
+ ],
+ "process.manage.success.processes": [
+ "{\"args\":[\"plugin\",\"pane\",\"open\",\"--cwd\",\"\",\"--plugin\",\"annotate\",\"--entrypoint\",\"manager\",\"--placement\",\"popup\",\"--width\",\"100\",\"--height\",\"30\",\"--focus\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "process.manage.success.stderr": [
+ ""
+ ],
+ "process.manage.success.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/process.manager.missing-state.json b/scripts/lite-goldens/process.manager.missing-state.json
new file mode 100644
index 0000000..a3ad2be
--- /dev/null
+++ b/scripts/lite-goldens/process.manager.missing-state.json
@@ -0,0 +1,15 @@
+{
+ "process.manager.missing-state.exit": [
+ "1"
+ ],
+ "process.manager.missing-state.processes": [
+ ""
+ ],
+ "process.manager.missing-state.stderr": [
+ "HERDR_PLUGIN_STATE_DIR is not set",
+ ""
+ ],
+ "process.manager.missing-state.stdout": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/screen.editor.control-c.json b/scripts/lite-goldens/screen.editor.control-c.json
new file mode 100644
index 0000000..2914131
--- /dev/null
+++ b/scripts/lite-goldens/screen.editor.control-c.json
@@ -0,0 +1,62 @@
+{
+ "screen.editor.control-c.exit": [
+ "0"
+ ],
+ "screen.editor.control-c.osc52": [
+ "[]"
+ ],
+ "screen.editor.control-c.processes": [
+ ""
+ ],
+ "screen.editor.control-c.screen-count": [
+ "2"
+ ],
+ "screen.editor.control-c.screen.control-c": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.editor.control-c.screen.initial": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ]
+}
diff --git a/scripts/lite-goldens/screen.editor.edit-save.json b/scripts/lite-goldens/screen.editor.edit-save.json
new file mode 100644
index 0000000..39f25ca
--- /dev/null
+++ b/scripts/lite-goldens/screen.editor.edit-save.json
@@ -0,0 +1,342 @@
+{
+ "screen.editor.edit-save.exit": [
+ "0"
+ ],
+ "screen.editor.edit-save.osc52": [
+ "[]"
+ ],
+ "screen.editor.edit-save.processes": [
+ ""
+ ],
+ "screen.editor.edit-save.screen-count": [
+ "13"
+ ],
+ "screen.editor.edit-save.screen.backspace": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " bt ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.chars": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.chars-second-line": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " beta ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.delete": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " bta ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.down": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " bt ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.end": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " bta ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.enter": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.home": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " beta ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.initial": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.left": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " bt ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.right": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " beta ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.screen.save": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " bt ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Saved. "
+ ],
+ "screen.editor.edit-save.screen.up": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha 한·글· é ",
+ " bt ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.edit-save.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"selected wide 한글 text\\\\nwith a second line\\\",\\\"context\\\":{\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_label\\\":\\\"server\\\"},\\\"capturedAt\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"comment\\\":\\\"alpha 한글 é\\\\nbt\\\",\\\"createdAt\\\":\\\"\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/screen.editor.empty-save-escape.json b/scripts/lite-goldens/screen.editor.empty-save-escape.json
new file mode 100644
index 0000000..f734a66
--- /dev/null
+++ b/scripts/lite-goldens/screen.editor.empty-save-escape.json
@@ -0,0 +1,86 @@
+{
+ "screen.editor.empty-save-escape.exit": [
+ "0"
+ ],
+ "screen.editor.empty-save-escape.osc52": [
+ "[]"
+ ],
+ "screen.editor.empty-save-escape.processes": [
+ ""
+ ],
+ "screen.editor.empty-save-escape.screen-count": [
+ "3"
+ ],
+ "screen.editor.empty-save-escape.screen.empty-save": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Write a comment before saving. "
+ ],
+ "screen.editor.empty-save-escape.screen.escape": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.editor.empty-save-escape.screen.initial": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ]
+}
diff --git a/scripts/lite-goldens/screen.editor.missing-state.json b/scripts/lite-goldens/screen.editor.missing-state.json
new file mode 100644
index 0000000..a98f3ed
--- /dev/null
+++ b/scripts/lite-goldens/screen.editor.missing-state.json
@@ -0,0 +1,110 @@
+{
+ "screen.editor.missing-state.exit": [
+ "0"
+ ],
+ "screen.editor.missing-state.osc52": [
+ "[]"
+ ],
+ "screen.editor.missing-state.processes": [
+ ""
+ ],
+ "screen.editor.missing-state.screen-count": [
+ "4"
+ ],
+ "screen.editor.missing-state.screen.char": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " x ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.missing-state.screen.escape": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.editor.missing-state.screen.initial": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.missing-state.screen.save": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " x ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Plugin state directory is unavailable. "
+ ]
+}
diff --git a/scripts/lite-goldens/screen.editor.pending-file-save.json b/scripts/lite-goldens/screen.editor.pending-file-save.json
new file mode 100644
index 0000000..0916ef5
--- /dev/null
+++ b/scripts/lite-goldens/screen.editor.pending-file-save.json
@@ -0,0 +1,102 @@
+{
+ "screen.editor.pending-file-save.exit": [
+ "0"
+ ],
+ "screen.editor.pending-file-save.osc52": [
+ "[]"
+ ],
+ "screen.editor.pending-file-save.processes": [
+ ""
+ ],
+ "screen.editor.pending-file-save.screen-count": [
+ "3"
+ ],
+ "screen.editor.pending-file-save.screen.chars": [
+ " ",
+ " Selected text ",
+ " selection from pending file ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " pending comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.pending-file-save.screen.initial": [
+ " ",
+ " Selected text ",
+ " selection from pending file ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.pending-file-save.screen.save": [
+ " ",
+ " Selected text ",
+ " selection from pending file ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " pending comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Saved. "
+ ],
+ "screen.editor.pending-file-save.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"selection from pending file\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_label\\\":\\\"pending workspace\\\",\\\"tab_label\\\":\\\"pending tab\\\"},\\\"id\\\":\\\"\\\",\\\"comment\\\":\\\"pending comment\\\",\\\"createdAt\\\":\\\"\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/screen.editor.sigterm.json b/scripts/lite-goldens/screen.editor.sigterm.json
new file mode 100644
index 0000000..2e763bb
--- /dev/null
+++ b/scripts/lite-goldens/screen.editor.sigterm.json
@@ -0,0 +1,62 @@
+{
+ "screen.editor.sigterm.exit": [
+ "0"
+ ],
+ "screen.editor.sigterm.osc52": [
+ "[]"
+ ],
+ "screen.editor.sigterm.processes": [
+ ""
+ ],
+ "screen.editor.sigterm.screen-count": [
+ "2"
+ ],
+ "screen.editor.sigterm.screen.initial": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.sigterm.screen.sigterm": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ]
+}
diff --git a/scripts/lite-goldens/screen.editor.word-line-kills.json b/scripts/lite-goldens/screen.editor.word-line-kills.json
new file mode 100644
index 0000000..4c645f0
--- /dev/null
+++ b/scripts/lite-goldens/screen.editor.word-line-kills.json
@@ -0,0 +1,462 @@
+{
+ "screen.editor.word-line-kills.exit": [
+ "0"
+ ],
+ "screen.editor.word-line-kills.osc52": [
+ "[]"
+ ],
+ "screen.editor.word-line-kills.processes": [
+ ""
+ ],
+ "screen.editor.word-line-kills.screen-count": [
+ "18"
+ ],
+ "screen.editor.word-line-kills.screen.alt-backspace-marker": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.alt-backspace-newline": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gammaY ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.alt-backspace-word": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.chars": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.chars-second-line": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta epsilon ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.ctrl-a-line-start": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gammaZY ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.ctrl-u": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.ctrl-u-at-line-start": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gammaZY ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.ctrl-w": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.enter": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.initial": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.mark-tail": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " tail alpha beta gammaZY ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.mark-w": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta W ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.mark-x": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· X ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.mark-y": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " Y ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.mark-z": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gammaZY ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.screen.save": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " tail alpha beta gammaZY ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Saved. "
+ ],
+ "screen.editor.word-line-kills.screen.super-left-to-line-start": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " Y ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-kills.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"selected wide 한글 text\\\\nwith a second line\\\",\\\"context\\\":{\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_label\\\":\\\"server\\\"},\\\"capturedAt\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"comment\\\":\\\"tail alpha beta gammaZY\\\",\\\"createdAt\\\":\\\"\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/screen.editor.word-line-moves.json b/scripts/lite-goldens/screen.editor.word-line-moves.json
new file mode 100644
index 0000000..0e9dbb3
--- /dev/null
+++ b/scripts/lite-goldens/screen.editor.word-line-moves.json
@@ -0,0 +1,822 @@
+{
+ "screen.editor.word-line-moves.exit": [
+ "0"
+ ],
+ "screen.editor.word-line-moves.osc52": [
+ "[]"
+ ],
+ "screen.editor.word-line-moves.processes": [
+ ""
+ ],
+ "screen.editor.word-line-moves.screen-count": [
+ "33"
+ ],
+ "screen.editor.word-line-moves.screen.alt-b": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 1epsilon ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.alt-f": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 21epsilon ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.alt-left": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta epsilon ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.alt-left-line-first-word": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.alt-left-over-newline": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.alt-left-previous-word": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.alt-left-word": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.alt-right": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 21epsilon3 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.alt-right-next-word": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " 한·글· delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.alt-right-over-newline": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " 한·글· delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.chars": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.chars-second-line": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta epsilon ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.ctrl-a": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " 9한·글·8 delta 521epsilon346A ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.ctrl-e": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " B9한·글·8 delta 521epsilon346A ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.ctrl-left": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 21epsilon34 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.ctrl-right": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 521epsilon34 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.enter": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.initial": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-1": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 1epsilon ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-2": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 21epsilon ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-3": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 21epsilon3 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-4": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 21epsilon34 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-5": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 521epsilon34 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-6": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma ",
+ " 한·글· delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-7": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " 한·글· delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-8": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " 한·글·8 delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-9": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " 9한·글·8 delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-a": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " 9한·글·8 delta 521epsilon346A ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-b": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " B9한·글·8 delta 521epsilon346A ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.mark-c": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " B9한·글·8 delta 521epsilon346AC ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.save": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " B9한·글·8 delta 521epsilon346AC ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Saved. "
+ ],
+ "screen.editor.word-line-moves.screen.super-left": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " 한·글·8 delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.screen.super-right": [
+ " ",
+ " Selected text ",
+ " selected wide 한·글· text ",
+ " with a second line ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Comment ",
+ " alpha beta gamma7 ",
+ " 9한·글·8 delta 521epsilon346 ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " Ctrl+S save · Esc cancel · Enter new line "
+ ],
+ "screen.editor.word-line-moves.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"selected wide 한글 text\\\\nwith a second line\\\",\\\"context\\\":{\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_label\\\":\\\"server\\\"},\\\"capturedAt\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"comment\\\":\\\"alpha beta gamma7\\\\nB9한글8 delta 521epsilon346AC\\\",\\\"createdAt\\\":\\\"\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/screen.manager.all-views.json b/scripts/lite-goldens/screen.manager.all-views.json
new file mode 100644
index 0000000..06b6579
--- /dev/null
+++ b/scripts/lite-goldens/screen.manager.all-views.json
@@ -0,0 +1,879 @@
+{
+ "screen.manager.all-views.clipboard": [
+ ""
+ ],
+ "screen.manager.all-views.exit": [
+ "0"
+ ],
+ "screen.manager.all-views.osc52": [
+ "[]"
+ ],
+ "screen.manager.all-views.processes": [
+ ""
+ ],
+ "screen.manager.all-views.screen-count": [
+ "28"
+ ],
+ "screen.manager.all-views.screen.active-arrow-down": [
+ " Annotations (3) newest first ",
+ " third selection │ Selected text ",
+ " › wide 한·글· selection and enough… │ wide 한·글· selection and enough text to exercise clipping at ",
+ " first selection with ``` ticks… │ the detail edge ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ second comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/9/2026, 10:11:13 AM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.all-views.screen.active-arrow-up": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.all-views.screen.active-clear": [
+ " Annotations (0) newest first ",
+ " │ ",
+ " No active annotations. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " All active annotations cleared. "
+ ],
+ "screen.manager.all-views.screen.active-clear-cancel": [
+ " Annotations (2) newest first ",
+ " › wide 한·글· selection and enough… │ Selected text ",
+ " first selection with ``` ticks… │ wide 한·글· selection and enough text to exercise clipping at ",
+ " │ the detail edge ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ second comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/9/2026, 10:11:13 AM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.all-views.screen.active-clear-confirm": [
+ " Annotations (2) newest first ",
+ " › wide 한·글· selection and enough… │ Selected text ",
+ " first selection with ``` ticks… │ wide 한·글· selection and enough text to exercise clipping at ",
+ " │ the detail edge ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ second comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/9/2026, 10:11:13 AM ",
+ " │ ",
+ " Press Shift+D again to clear all active annotations · Esc cancel "
+ ],
+ "screen.manager.all-views.screen.active-clear-confirm-again": [
+ " Annotations (2) newest first ",
+ " › wide 한·글· selection and enough… │ Selected text ",
+ " first selection with ``` ticks… │ wide 한·글· selection and enough text to exercise clipping at ",
+ " │ the detail edge ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ second comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/9/2026, 10:11:13 AM ",
+ " │ ",
+ " Press Shift+D again to clear all active annotations · Esc cancel "
+ ],
+ "screen.manager.all-views.screen.active-delete": [
+ " Annotations (2) newest first ",
+ " › wide 한·글· selection and enough… │ Selected text ",
+ " first selection with ``` ticks… │ wide 한·글· selection and enough text to exercise clipping at ",
+ " │ the detail edge ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ second comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/9/2026, 10:11:13 AM ",
+ " │ ",
+ " Annotation deleted. "
+ ],
+ "screen.manager.all-views.screen.active-j": [
+ " Annotations (3) newest first ",
+ " third selection │ Selected text ",
+ " › wide 한·글· selection and enough… │ wide 한·글· selection and enough text to exercise clipping at ",
+ " first selection with ``` ticks… │ the detail edge ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ second comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/9/2026, 10:11:13 AM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.all-views.screen.active-k": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.all-views.screen.active-quit": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.manager.all-views.screen.active-reload": [
+ " Annotations (0) newest first ",
+ " │ ",
+ " No active annotations. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " Reloaded. "
+ ],
+ "screen.manager.all-views.screen.active-to-archives": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.all-views.screen.active-u-ignored": [
+ " Annotations (0) newest first ",
+ " │ ",
+ " No active annotations. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.all-views.screen.archives-C-ignored": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.all-views.screen.archives-D-ignored": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.all-views.screen.archives-arrow-down": [
+ " Archives (2) newest first ",
+ " 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " › 8/20/2026, 1:02:03 AM · 1 anno… │ 8/20/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 1 annotation ",
+ " │ 1. first selection with ``` ticks and a second line ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.all-views.screen.archives-arrow-up": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.all-views.screen.archives-c-ignored": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.all-views.screen.archives-delete": [
+ " Archives (0) newest first ",
+ " │ ",
+ " No archived sets. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " Archive permanently deleted. "
+ ],
+ "screen.manager.all-views.screen.archives-delete-cancel": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.all-views.screen.archives-delete-confirm": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " Press d again to permanently delete this archive · Esc cancel "
+ ],
+ "screen.manager.all-views.screen.archives-delete-confirm-again": [
+ " Archives (1) newest first ",
+ " › 8/20/2026, 1:02:03 AM · 1 anno… │ Archived set ",
+ " │ 8/20/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 1 annotation ",
+ " │ 1. first selection with ``` ticks and a second line ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " Press d again to permanently delete this archive · Esc cancel "
+ ],
+ "screen.manager.all-views.screen.archives-j": [
+ " Archives (2) newest first ",
+ " 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " › 8/20/2026, 1:02:03 AM · 1 anno… │ 8/20/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 1 annotation ",
+ " │ 1. first selection with ``` ticks and a second line ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.all-views.screen.archives-k": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.all-views.screen.archives-reload": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " Reloaded. "
+ ],
+ "screen.manager.all-views.screen.archives-restore": [
+ " Archives (1) newest first ",
+ " › 8/20/2026, 1:02:03 AM · 1 anno… │ Archived set ",
+ " │ 8/20/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 1 annotation ",
+ " │ 1. first selection with ``` ticks and a second line ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " 2 annotations restored. "
+ ],
+ "screen.manager.all-views.screen.archives-to-active": [
+ " Annotations (2) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.all-views.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.all-views.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"}\\n{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " },",
+ " {",
+ " \"bytes\": \"\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"archives.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/screen.manager.control-c-active.json b/scripts/lite-goldens/screen.manager.control-c-active.json
new file mode 100644
index 0000000..a0bb864
--- /dev/null
+++ b/scripts/lite-goldens/screen.manager.control-c-active.json
@@ -0,0 +1,74 @@
+{
+ "screen.manager.control-c-active.exit": [
+ "0"
+ ],
+ "screen.manager.control-c-active.osc52": [
+ "[]"
+ ],
+ "screen.manager.control-c-active.processes": [
+ ""
+ ],
+ "screen.manager.control-c-active.screen-count": [
+ "2"
+ ],
+ "screen.manager.control-c-active.screen.control-c": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.manager.control-c-active.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ]
+}
diff --git a/scripts/lite-goldens/screen.manager.control-c-archives.json b/scripts/lite-goldens/screen.manager.control-c-archives.json
new file mode 100644
index 0000000..3f37fd9
--- /dev/null
+++ b/scripts/lite-goldens/screen.manager.control-c-archives.json
@@ -0,0 +1,104 @@
+{
+ "screen.manager.control-c-archives.exit": [
+ "0"
+ ],
+ "screen.manager.control-c-archives.osc52": [
+ "[]"
+ ],
+ "screen.manager.control-c-archives.processes": [
+ ""
+ ],
+ "screen.manager.control-c-archives.screen-count": [
+ "3"
+ ],
+ "screen.manager.control-c-archives.screen.control-c": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.manager.control-c-archives.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.control-c-archives.screen.to-archives": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ]
+}
diff --git a/scripts/lite-goldens/screen.manager.empty-actions.json b/scripts/lite-goldens/screen.manager.empty-actions.json
new file mode 100644
index 0000000..f3c1fa5
--- /dev/null
+++ b/scripts/lite-goldens/screen.manager.empty-actions.json
@@ -0,0 +1,297 @@
+{
+ "screen.manager.empty-actions.clipboard": [
+ ""
+ ],
+ "screen.manager.empty-actions.exit": [
+ "0"
+ ],
+ "screen.manager.empty-actions.osc52": [
+ "[]"
+ ],
+ "screen.manager.empty-actions.processes": [
+ ""
+ ],
+ "screen.manager.empty-actions.screen-count": [
+ "9"
+ ],
+ "screen.manager.empty-actions.screen.active-c": [
+ " Annotations (0) newest first ",
+ " │ ",
+ " No active annotations. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " Nothing to copy. "
+ ],
+ "screen.manager.empty-actions.screen.active-copy-archive": [
+ " Annotations (0) newest first ",
+ " │ ",
+ " No active annotations. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " Nothing to copy and archive. "
+ ],
+ "screen.manager.empty-actions.screen.active-y": [
+ " Annotations (0) newest first ",
+ " │ ",
+ " No active annotations. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " Nothing to copy. "
+ ],
+ "screen.manager.empty-actions.screen.archives-d": [
+ " Archives (0) newest first ",
+ " │ ",
+ " No archived sets. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " No archive selected. "
+ ],
+ "screen.manager.empty-actions.screen.archives-u": [
+ " Archives (0) newest first ",
+ " │ ",
+ " No archived sets. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " No archive selected. "
+ ],
+ "screen.manager.empty-actions.screen.archives-y": [
+ " Archives (0) newest first ",
+ " │ ",
+ " No archived sets. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " Nothing to copy. "
+ ],
+ "screen.manager.empty-actions.screen.initial": [
+ " Annotations (0) newest first ",
+ " │ ",
+ " No active annotations. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.empty-actions.screen.quit": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.manager.empty-actions.screen.to-archives": [
+ " Archives (0) newest first ",
+ " │ ",
+ " No archived sets. │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "screen.manager.empty-actions.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/screen.manager.escape-active.json b/scripts/lite-goldens/screen.manager.escape-active.json
new file mode 100644
index 0000000..d0d6ba8
--- /dev/null
+++ b/scripts/lite-goldens/screen.manager.escape-active.json
@@ -0,0 +1,74 @@
+{
+ "screen.manager.escape-active.exit": [
+ "0"
+ ],
+ "screen.manager.escape-active.osc52": [
+ "[]"
+ ],
+ "screen.manager.escape-active.processes": [
+ ""
+ ],
+ "screen.manager.escape-active.screen-count": [
+ "2"
+ ],
+ "screen.manager.escape-active.screen.active-escape": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.manager.escape-active.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ]
+}
diff --git a/scripts/lite-goldens/screen.manager.escape-exit.json b/scripts/lite-goldens/screen.manager.escape-exit.json
new file mode 100644
index 0000000..8881ef1
--- /dev/null
+++ b/scripts/lite-goldens/screen.manager.escape-exit.json
@@ -0,0 +1,104 @@
+{
+ "screen.manager.escape-exit.exit": [
+ "0"
+ ],
+ "screen.manager.escape-exit.osc52": [
+ "[]"
+ ],
+ "screen.manager.escape-exit.processes": [
+ ""
+ ],
+ "screen.manager.escape-exit.screen-count": [
+ "3"
+ ],
+ "screen.manager.escape-exit.screen.archives-escape": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.manager.escape-exit.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.escape-exit.screen.to-archives": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ]
+}
diff --git a/scripts/lite-goldens/screen.manager.q-archives.json b/scripts/lite-goldens/screen.manager.q-archives.json
new file mode 100644
index 0000000..5094eea
--- /dev/null
+++ b/scripts/lite-goldens/screen.manager.q-archives.json
@@ -0,0 +1,104 @@
+{
+ "screen.manager.q-archives.exit": [
+ "0"
+ ],
+ "screen.manager.q-archives.osc52": [
+ "[]"
+ ],
+ "screen.manager.q-archives.processes": [
+ ""
+ ],
+ "screen.manager.q-archives.screen-count": [
+ "3"
+ ],
+ "screen.manager.q-archives.screen.archives-q": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "screen.manager.q-archives.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.q-archives.screen.to-archives": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ]
+}
diff --git a/scripts/lite-goldens/screen.manager.sighup.json b/scripts/lite-goldens/screen.manager.sighup.json
new file mode 100644
index 0000000..7ad315c
--- /dev/null
+++ b/scripts/lite-goldens/screen.manager.sighup.json
@@ -0,0 +1,74 @@
+{
+ "screen.manager.sighup.exit": [
+ "0"
+ ],
+ "screen.manager.sighup.osc52": [
+ "[]"
+ ],
+ "screen.manager.sighup.processes": [
+ ""
+ ],
+ "screen.manager.sighup.screen-count": [
+ "2"
+ ],
+ "screen.manager.sighup.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "screen.manager.sighup.screen.sighup": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ]
+}
diff --git a/scripts/lite-goldens/store.cross-read.json b/scripts/lite-goldens/store.cross-read.json
new file mode 100644
index 0000000..818c3d5
--- /dev/null
+++ b/scripts/lite-goldens/store.cross-read.json
@@ -0,0 +1,33 @@
+{
+ "store.cross-read.exit": [
+ "0"
+ ],
+ "store.cross-read.markdown": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "selected wide 한글 text",
+ "with a second line",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "alpha 한글 é",
+ "bt",
+ ""
+ ],
+ "store.cross-read.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ "{\"args\":[\"notification\",\"show\",\"Annotations copied\",\"--body\",\"1 annotation copied as Markdown.\"],\"command\":\"herdr-fake\"}",
+ ""
+ ],
+ "store.cross-read.stderr": [
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/store.manager.active-c-success.json b/scripts/lite-goldens/store.manager.active-c-success.json
new file mode 100644
index 0000000..6f50912
--- /dev/null
+++ b/scripts/lite-goldens/store.manager.active-c-success.json
@@ -0,0 +1,147 @@
+{
+ "store.manager.active-c-success.clipboard": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "third selection",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "third comment",
+ "",
+ "## Annotation 2",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "wide 한글 selection and enough text to exercise clipping at the detail edge",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "second comment",
+ "",
+ "## Annotation 3",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "````",
+ "first selection with ``` ticks",
+ "and a second line",
+ "````",
+ "",
+ "Comment:",
+ "",
+ "first comment",
+ "with two lines",
+ ""
+ ],
+ "store.manager.active-c-success.exit": [
+ "0"
+ ],
+ "store.manager.active-c-success.osc52": [
+ "[",
+ " \"\\u001b]52;c;IyBBbm5vdGF0ZWQgY29udGV4dAoKIyMgQW5ub3RhdGlvbiAxCgpTb3VyY2U6IGFwaSDtlZwgLyBzZXJ2ZXIKClNlbGVjdGVkIHRleHQ6CgpgYGAKdGhpcmQgc2VsZWN0aW9uCmBgYAoKQ29tbWVudDoKCnRoaXJkIGNvbW1lbnQKCiMjIEFubm90YXRpb24gMgoKU291cmNlOiBhcGkg7ZWcIC8gc2VydmVyCgpTZWxlY3RlZCB0ZXh0OgoKYGBgCndpZGUg7ZWc6riAIHNlbGVjdGlvbiBhbmQgZW5vdWdoIHRleHQgdG8gZXhlcmNpc2UgY2xpcHBpbmcgYXQgdGhlIGRldGFpbCBlZGdlCmBgYAoKQ29tbWVudDoKCnNlY29uZCBjb21tZW50CgojIyBBbm5vdGF0aW9uIDMKClNvdXJjZTogYXBpIO2VnCAvIHNlcnZlcgoKU2VsZWN0ZWQgdGV4dDoKCmBgYGAKZmlyc3Qgc2VsZWN0aW9uIHdpdGggYGBgIHRpY2tzCmFuZCBhIHNlY29uZCBsaW5lCmBgYGAKCkNvbW1lbnQ6CgpmaXJzdCBjb21tZW50CndpdGggdHdvIGxpbmVzCg==\\u0007\"",
+ "]"
+ ],
+ "store.manager.active-c-success.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ ""
+ ],
+ "store.manager.active-c-success.screen-count": [
+ "2"
+ ],
+ "store.manager.active-c-success.screen.copy-all": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "store.manager.active-c-success.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "store.manager.active-c-success.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}\\n{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"}\\n{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"version\\\":1,\\\"id\\\":\\\"archive-old\\\",\\\"archivedAt\\\":\\\"2026-08-20T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}]}\\n{\\\"version\\\":1,\\\"id\\\":\\\"archive-new\\\",\\\"archivedAt\\\":\\\"2026-08-21T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"},{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}]}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"archives.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/store.manager.active-y-success.json b/scripts/lite-goldens/store.manager.active-y-success.json
new file mode 100644
index 0000000..1fc56d1
--- /dev/null
+++ b/scripts/lite-goldens/store.manager.active-y-success.json
@@ -0,0 +1,117 @@
+{
+ "store.manager.active-y-success.clipboard": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "third selection",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "third comment",
+ ""
+ ],
+ "store.manager.active-y-success.exit": [
+ "0"
+ ],
+ "store.manager.active-y-success.osc52": [
+ "[",
+ " \"\\u001b]52;c;IyBBbm5vdGF0ZWQgY29udGV4dAoKIyMgQW5ub3RhdGlvbiAxCgpTb3VyY2U6IGFwaSDtlZwgLyBzZXJ2ZXIKClNlbGVjdGVkIHRleHQ6CgpgYGAKdGhpcmQgc2VsZWN0aW9uCmBgYAoKQ29tbWVudDoKCnRoaXJkIGNvbW1lbnQK\\u0007\"",
+ "]"
+ ],
+ "store.manager.active-y-success.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ ""
+ ],
+ "store.manager.active-y-success.screen-count": [
+ "2"
+ ],
+ "store.manager.active-y-success.screen.copy-one": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "store.manager.active-y-success.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "store.manager.active-y-success.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}\\n{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"}\\n{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"version\\\":1,\\\"id\\\":\\\"archive-old\\\",\\\"archivedAt\\\":\\\"2026-08-20T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}]}\\n{\\\"version\\\":1,\\\"id\\\":\\\"archive-new\\\",\\\"archivedAt\\\":\\\"2026-08-21T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"},{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}]}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"archives.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/store.manager.archives-y-success.json b/scripts/lite-goldens/store.manager.archives-y-success.json
new file mode 100644
index 0000000..a52d6e0
--- /dev/null
+++ b/scripts/lite-goldens/store.manager.archives-y-success.json
@@ -0,0 +1,161 @@
+{
+ "store.manager.archives-y-success.clipboard": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "third selection",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "third comment",
+ "",
+ "## Annotation 2",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "wide 한글 selection and enough text to exercise clipping at the detail edge",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "second comment",
+ ""
+ ],
+ "store.manager.archives-y-success.exit": [
+ "0"
+ ],
+ "store.manager.archives-y-success.osc52": [
+ "[",
+ " \"\\u001b]52;c;IyBBbm5vdGF0ZWQgY29udGV4dAoKIyMgQW5ub3RhdGlvbiAxCgpTb3VyY2U6IGFwaSDtlZwgLyBzZXJ2ZXIKClNlbGVjdGVkIHRleHQ6CgpgYGAKdGhpcmQgc2VsZWN0aW9uCmBgYAoKQ29tbWVudDoKCnRoaXJkIGNvbW1lbnQKCiMjIEFubm90YXRpb24gMgoKU291cmNlOiBhcGkg7ZWcIC8gc2VydmVyCgpTZWxlY3RlZCB0ZXh0OgoKYGBgCndpZGUg7ZWc6riAIHNlbGVjdGlvbiBhbmQgZW5vdWdoIHRleHQgdG8gZXhlcmNpc2UgY2xpcHBpbmcgYXQgdGhlIGRldGFpbCBlZGdlCmBgYAoKQ29tbWVudDoKCnNlY29uZCBjb21tZW50Cg==\\u0007\"",
+ "]"
+ ],
+ "store.manager.archives-y-success.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ ""
+ ],
+ "store.manager.archives-y-success.screen-count": [
+ "3"
+ ],
+ "store.manager.archives-y-success.screen.copy-archive": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "store.manager.archives-y-success.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "store.manager.archives-y-success.screen.to-archives": [
+ " Archives (2) newest first ",
+ " › 8/21/2026, 1:02:03 AM · 2 anno… │ Archived set ",
+ " 8/20/2026, 1:02:03 AM · 1 anno… │ 8/21/2026, 1:02:03 AM ",
+ " │ ",
+ " │ 2 annotations ",
+ " │ 1. third selection ",
+ " │ 2. wide 한·글· selection and enough text to exercise clippin… ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " j/k · y copy · u restore · d twice delete · Tab active · q "
+ ],
+ "store.manager.archives-y-success.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}\\n{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"}\\n{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"version\\\":1,\\\"id\\\":\\\"archive-old\\\",\\\"archivedAt\\\":\\\"2026-08-20T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}]}\\n{\\\"version\\\":1,\\\"id\\\":\\\"archive-new\\\",\\\"archivedAt\\\":\\\"2026-08-21T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"},{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}]}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"archives.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/store.manager.copy-archive.json b/scripts/lite-goldens/store.manager.copy-archive.json
new file mode 100644
index 0000000..aba6700
--- /dev/null
+++ b/scripts/lite-goldens/store.manager.copy-archive.json
@@ -0,0 +1,147 @@
+{
+ "store.manager.copy-archive.clipboard": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "third selection",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "third comment",
+ "",
+ "## Annotation 2",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "wide 한글 selection and enough text to exercise clipping at the detail edge",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "second comment",
+ "",
+ "## Annotation 3",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "````",
+ "first selection with ``` ticks",
+ "and a second line",
+ "````",
+ "",
+ "Comment:",
+ "",
+ "first comment",
+ "with two lines",
+ ""
+ ],
+ "store.manager.copy-archive.exit": [
+ "0"
+ ],
+ "store.manager.copy-archive.osc52": [
+ "[",
+ " \"\\u001b]52;c;IyBBbm5vdGF0ZWQgY29udGV4dAoKIyMgQW5ub3RhdGlvbiAxCgpTb3VyY2U6IGFwaSDtlZwgLyBzZXJ2ZXIKClNlbGVjdGVkIHRleHQ6CgpgYGAKdGhpcmQgc2VsZWN0aW9uCmBgYAoKQ29tbWVudDoKCnRoaXJkIGNvbW1lbnQKCiMjIEFubm90YXRpb24gMgoKU291cmNlOiBhcGkg7ZWcIC8gc2VydmVyCgpTZWxlY3RlZCB0ZXh0OgoKYGBgCndpZGUg7ZWc6riAIHNlbGVjdGlvbiBhbmQgZW5vdWdoIHRleHQgdG8gZXhlcmNpc2UgY2xpcHBpbmcgYXQgdGhlIGRldGFpbCBlZGdlCmBgYAoKQ29tbWVudDoKCnNlY29uZCBjb21tZW50CgojIyBBbm5vdGF0aW9uIDMKClNvdXJjZTogYXBpIO2VnCAvIHNlcnZlcgoKU2VsZWN0ZWQgdGV4dDoKCmBgYGAKZmlyc3Qgc2VsZWN0aW9uIHdpdGggYGBgIHRpY2tzCmFuZCBhIHNlY29uZCBsaW5lCmBgYGAKCkNvbW1lbnQ6CgpmaXJzdCBjb21tZW50CndpdGggdHdvIGxpbmVzCg==\\u0007\"",
+ "]"
+ ],
+ "store.manager.copy-archive.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ ""
+ ],
+ "store.manager.copy-archive.screen-count": [
+ "2"
+ ],
+ "store.manager.copy-archive.screen.copy-archive": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "store.manager.copy-archive.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "store.manager.copy-archive.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"version\\\":1,\\\"id\\\":\\\"archive-old\\\",\\\"archivedAt\\\":\\\"2026-08-20T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}]}\\n{\\\"version\\\":1,\\\"id\\\":\\\"archive-new\\\",\\\"archivedAt\\\":\\\"2026-08-21T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"},{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}]}\\n{\\\"version\\\":1,\\\"id\\\":\\\"\\\",\\\"archivedAt\\\":\\\"\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"},{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"},{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}]}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"archives.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/store.manager.osc52-remote-copy-all.json b/scripts/lite-goldens/store.manager.osc52-remote-copy-all.json
new file mode 100644
index 0000000..4f32f07
--- /dev/null
+++ b/scripts/lite-goldens/store.manager.osc52-remote-copy-all.json
@@ -0,0 +1,147 @@
+{
+ "store.manager.osc52-remote-copy-all.clipboard": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "third selection",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "third comment",
+ "",
+ "## Annotation 2",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "wide 한글 selection and enough text to exercise clipping at the detail edge",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "second comment",
+ "",
+ "## Annotation 3",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "````",
+ "first selection with ``` ticks",
+ "and a second line",
+ "````",
+ "",
+ "Comment:",
+ "",
+ "first comment",
+ "with two lines",
+ ""
+ ],
+ "store.manager.osc52-remote-copy-all.exit": [
+ "0"
+ ],
+ "store.manager.osc52-remote-copy-all.osc52": [
+ "[",
+ " \"\\u001b]52;c;IyBBbm5vdGF0ZWQgY29udGV4dAoKIyMgQW5ub3RhdGlvbiAxCgpTb3VyY2U6IGFwaSDtlZwgLyBzZXJ2ZXIKClNlbGVjdGVkIHRleHQ6CgpgYGAKdGhpcmQgc2VsZWN0aW9uCmBgYAoKQ29tbWVudDoKCnRoaXJkIGNvbW1lbnQKCiMjIEFubm90YXRpb24gMgoKU291cmNlOiBhcGkg7ZWcIC8gc2VydmVyCgpTZWxlY3RlZCB0ZXh0OgoKYGBgCndpZGUg7ZWc6riAIHNlbGVjdGlvbiBhbmQgZW5vdWdoIHRleHQgdG8gZXhlcmNpc2UgY2xpcHBpbmcgYXQgdGhlIGRldGFpbCBlZGdlCmBgYAoKQ29tbWVudDoKCnNlY29uZCBjb21tZW50CgojIyBBbm5vdGF0aW9uIDMKClNvdXJjZTogYXBpIO2VnCAvIHNlcnZlcgoKU2VsZWN0ZWQgdGV4dDoKCmBgYGAKZmlyc3Qgc2VsZWN0aW9uIHdpdGggYGBgIHRpY2tzCmFuZCBhIHNlY29uZCBsaW5lCmBgYGAKCkNvbW1lbnQ6CgpmaXJzdCBjb21tZW50CndpdGggdHdvIGxpbmVzCg==\\u0007\"",
+ "]"
+ ],
+ "store.manager.osc52-remote-copy-all.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ ""
+ ],
+ "store.manager.osc52-remote-copy-all.screen-count": [
+ "2"
+ ],
+ "store.manager.osc52-remote-copy-all.screen.copy-all": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "store.manager.osc52-remote-copy-all.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "store.manager.osc52-remote-copy-all.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}\\n{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"}\\n{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"version\\\":1,\\\"id\\\":\\\"archive-old\\\",\\\"archivedAt\\\":\\\"2026-08-20T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}]}\\n{\\\"version\\\":1,\\\"id\\\":\\\"archive-new\\\",\\\"archivedAt\\\":\\\"2026-08-21T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"},{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}]}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"archives.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/store.manager.osc52-remote-copy-archive.json b/scripts/lite-goldens/store.manager.osc52-remote-copy-archive.json
new file mode 100644
index 0000000..356b347
--- /dev/null
+++ b/scripts/lite-goldens/store.manager.osc52-remote-copy-archive.json
@@ -0,0 +1,147 @@
+{
+ "store.manager.osc52-remote-copy-archive.clipboard": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "third selection",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "third comment",
+ "",
+ "## Annotation 2",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "wide 한글 selection and enough text to exercise clipping at the detail edge",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "second comment",
+ "",
+ "## Annotation 3",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "````",
+ "first selection with ``` ticks",
+ "and a second line",
+ "````",
+ "",
+ "Comment:",
+ "",
+ "first comment",
+ "with two lines",
+ ""
+ ],
+ "store.manager.osc52-remote-copy-archive.exit": [
+ "0"
+ ],
+ "store.manager.osc52-remote-copy-archive.osc52": [
+ "[",
+ " \"\\u001b]52;c;IyBBbm5vdGF0ZWQgY29udGV4dAoKIyMgQW5ub3RhdGlvbiAxCgpTb3VyY2U6IGFwaSDtlZwgLyBzZXJ2ZXIKClNlbGVjdGVkIHRleHQ6CgpgYGAKdGhpcmQgc2VsZWN0aW9uCmBgYAoKQ29tbWVudDoKCnRoaXJkIGNvbW1lbnQKCiMjIEFubm90YXRpb24gMgoKU291cmNlOiBhcGkg7ZWcIC8gc2VydmVyCgpTZWxlY3RlZCB0ZXh0OgoKYGBgCndpZGUg7ZWc6riAIHNlbGVjdGlvbiBhbmQgZW5vdWdoIHRleHQgdG8gZXhlcmNpc2UgY2xpcHBpbmcgYXQgdGhlIGRldGFpbCBlZGdlCmBgYAoKQ29tbWVudDoKCnNlY29uZCBjb21tZW50CgojIyBBbm5vdGF0aW9uIDMKClNvdXJjZTogYXBpIO2VnCAvIHNlcnZlcgoKU2VsZWN0ZWQgdGV4dDoKCmBgYGAKZmlyc3Qgc2VsZWN0aW9uIHdpdGggYGBgIHRpY2tzCmFuZCBhIHNlY29uZCBsaW5lCmBgYGAKCkNvbW1lbnQ6CgpmaXJzdCBjb21tZW50CndpdGggdHdvIGxpbmVzCg==\\u0007\"",
+ "]"
+ ],
+ "store.manager.osc52-remote-copy-archive.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ ""
+ ],
+ "store.manager.osc52-remote-copy-archive.screen-count": [
+ "2"
+ ],
+ "store.manager.osc52-remote-copy-archive.screen.copy-archive": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "store.manager.osc52-remote-copy-archive.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "store.manager.osc52-remote-copy-archive.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"version\\\":1,\\\"id\\\":\\\"archive-old\\\",\\\"archivedAt\\\":\\\"2026-08-20T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}]}\\n{\\\"version\\\":1,\\\"id\\\":\\\"archive-new\\\",\\\"archivedAt\\\":\\\"2026-08-21T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"},{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}]}\\n{\\\"version\\\":1,\\\"id\\\":\\\"\\\",\\\"archivedAt\\\":\\\"\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"},{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"},{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}]}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"archives.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/lite-goldens/store.manager.osc52-remote-copy.json b/scripts/lite-goldens/store.manager.osc52-remote-copy.json
new file mode 100644
index 0000000..1b73ccf
--- /dev/null
+++ b/scripts/lite-goldens/store.manager.osc52-remote-copy.json
@@ -0,0 +1,117 @@
+{
+ "store.manager.osc52-remote-copy.clipboard": [
+ "# Annotated context",
+ "",
+ "## Annotation 1",
+ "",
+ "Source: api 한 / server",
+ "",
+ "Selected text:",
+ "",
+ "```",
+ "third selection",
+ "```",
+ "",
+ "Comment:",
+ "",
+ "third comment",
+ ""
+ ],
+ "store.manager.osc52-remote-copy.exit": [
+ "0"
+ ],
+ "store.manager.osc52-remote-copy.osc52": [
+ "[",
+ " \"\\u001b]52;c;IyBBbm5vdGF0ZWQgY29udGV4dAoKIyMgQW5ub3RhdGlvbiAxCgpTb3VyY2U6IGFwaSDtlZwgLyBzZXJ2ZXIKClNlbGVjdGVkIHRleHQ6CgpgYGAKdGhpcmQgc2VsZWN0aW9uCmBgYAoKQ29tbWVudDoKCnRoaXJkIGNvbW1lbnQK\\u0007\"",
+ "]"
+ ],
+ "store.manager.osc52-remote-copy.processes": [
+ "{\"args\":[],\"command\":\"clipboard-write\"}",
+ ""
+ ],
+ "store.manager.osc52-remote-copy.screen-count": [
+ "2"
+ ],
+ "store.manager.osc52-remote-copy.screen.copy-one": [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ],
+ "store.manager.osc52-remote-copy.screen.initial": [
+ " Annotations (3) newest first ",
+ " › third selection │ Selected text ",
+ " wide 한·글· selection and enough… │ third selection ",
+ " first selection with ``` ticks… │ ",
+ " │ ",
+ " │ ",
+ " │ Comment ",
+ " │ third comment ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ ",
+ " │ api 한· / server · 8/10/2026, 8:21:23 PM ",
+ " │ ",
+ " j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q "
+ ],
+ "store.manager.osc52-remote-copy.state": [
+ "[",
+ " {",
+ " \"kind\": \"dir\",",
+ " \"mode\": \"0700\",",
+ " \"path\": \".\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}\\n{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"}\\n{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"annotations.jsonl\"",
+ " },",
+ " {",
+ " \"bytes\": \"{\\\"version\\\":1,\\\"id\\\":\\\"archive-old\\\",\\\"archivedAt\\\":\\\"2026-08-20T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"first selection with ``` ticks\\\\nand a second line\\\",\\\"capturedAt\\\":\\\"2026-08-08T00:00:00.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-one\\\",\\\"comment\\\":\\\"first comment\\\\nwith two lines\\\",\\\"createdAt\\\":\\\"2026-08-08T00:00:01.000Z\\\"}]}\\n{\\\"version\\\":1,\\\"id\\\":\\\"archive-new\\\",\\\"archivedAt\\\":\\\"2026-08-21T01:02:03.000Z\\\",\\\"annotations\\\":[{\\\"selectedText\\\":\\\"wide 한글 selection and enough text to exercise clipping at the detail edge\\\",\\\"capturedAt\\\":\\\"2026-08-09T10:11:12.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-two\\\",\\\"comment\\\":\\\"second comment\\\",\\\"createdAt\\\":\\\"2026-08-09T10:11:13.000Z\\\"},{\\\"selectedText\\\":\\\"third selection\\\",\\\"capturedAt\\\":\\\"2026-08-10T20:21:22.000Z\\\",\\\"context\\\":{\\\"workspace_id\\\":\\\"workspace-1\\\",\\\"workspace_label\\\":\\\"api 한\\\",\\\"tab_id\\\":\\\"tab-1\\\",\\\"tab_label\\\":\\\"server\\\",\\\"focused_pane_id\\\":\\\"pane-1\\\",\\\"focused_pane_cwd\\\":\\\"/workspace\\\",\\\"focused_pane_agent\\\":\\\"codex\\\"},\\\"id\\\":\\\"ann-three\\\",\\\"comment\\\":\\\"third comment\\\",\\\"createdAt\\\":\\\"2026-08-10T20:21:23.000Z\\\"}]}\\n\",",
+ " \"kind\": \"file\",",
+ " \"mode\": \"0600\",",
+ " \"path\": \"archives.jsonl\"",
+ " }",
+ "]",
+ ""
+ ]
+}
diff --git a/scripts/parity-lite.py b/scripts/lite-regression.py
similarity index 60%
rename from scripts/parity-lite.py
rename to scripts/lite-regression.py
index a074512..87ecb77 100755
--- a/scripts/parity-lite.py
+++ b/scripts/lite-regression.py
@@ -1,5 +1,10 @@
#!/usr/bin/env python3
-"""Deterministic differential harness for Herdr Annotate Lite."""
+"""Deterministic golden regression harness for Herdr Annotate Lite.
+
+The goldens under scripts/lite-goldens were recorded from the retired Bun runtime, so a green run
+is the standing proof that the native runtime still behaves the way the Bun one did. --record
+rewrites them from the current run and is only for a deliberate behavior change.
+"""
from __future__ import annotations
@@ -43,18 +48,38 @@
UUID_PATTERN = re.compile(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
)
-TYPESCRIPT_SCRIPTS = {
- "capture": "capture.ts",
- "copy-context": "export.ts",
- "copy-archive": "export-archive.ts",
- "manage": "open-manager.ts",
- "editor": "editor.ts",
- "manager": "manager.ts",
-}
+# The six entrypoints the Lite manifest declares, and the whole surface this harness drives.
+ENTRYPOINTS = ("capture", "copy-archive", "copy-context", "editor", "manage", "manager")
+# The Full manifest at the repository root starts the binary in its own bin/; the Lite variant
+# in lite/ shares that one staged binary, the way it shared the Bun sources.
NATIVE_PROGRAM = "./bin/herdr-annotate.exe"
+LITE_NATIVE_PROGRAM = "../bin/herdr-annotate.exe"
PENDING_PATTERN = re.compile(r"pending-\d+-\d+\.json")
TEMP_PATTERN = re.compile(r"\.(annotations|archives)-\d+-\d+\.tmp")
-DELIBERATE_DIVERGENCES = ("manager timestamp locale outside en-US",)
+HANDOFF_PATTERN = re.compile(r"herdr-annotate-\d+")
+DELIBERATE_DIFFERENCES = ("manager timestamp locale outside en-US",)
+
+# One clipboard operation walks its platform's candidate list until an adapter works. The
+# goldens record the role only, so they hold on every platform, and the chain itself is checked
+# against this table instead.
+CLIPBOARD_CANDIDATES = {
+ "darwin": {
+ "read": (("pbpaste", ()),),
+ "write": (("pbcopy", ()),),
+ },
+ "linux": {
+ "read": (
+ ("wl-paste", ("--no-newline",)),
+ ("xclip", ("-selection", "clipboard", "-out")),
+ ("xsel", ("--clipboard", "--output")),
+ ),
+ "write": (
+ ("wl-copy", ()),
+ ("xclip", ("-selection", "clipboard", "-in")),
+ ("xsel", ("--clipboard", "--input")),
+ ),
+ },
+}
WIDE_RANGES = (
(0x1100, 0x115F),
@@ -103,6 +128,10 @@ class Step:
data: bytes = b""
coverage: tuple[str, ...] = ()
process_signal: int | None = None
+ # The keystroke ends the process (a manager copy closes the pane). The screen is captured
+ # only after the exit has been observed and the output drained, so a slow runner cannot
+ # snapshot the frame before the terminal is restored.
+ exits: bool = False
@dataclass
@@ -112,51 +141,114 @@ class PtyResult:
osc52: list[str]
-class Proof:
- def __init__(self, artifacts: Path) -> None:
+class Goldens:
+ """Every recorded expectation, and the checks the harness states in its own source."""
+
+ def __init__(self, directory: Path, artifacts: Path, *, record: bool) -> None:
+ self.directory = directory
self.artifacts = artifacts
- self.observables = 0
+ self.record = record
+ self.groups: dict[str, dict[str, list[str]]] = {}
+ self.seen: dict[str, set[str]] = {}
+ self.checked = 0
self.screens = 0
self.failures: list[str] = []
self.coverage: set[str] = set()
@staticmethod
- def _display(value: object) -> list[str]:
+ def _render(value: object, *, screen: bool = False) -> list[str]:
+ if screen:
+ return ["".join(cell or "·" for cell in row) for row in value]
if isinstance(value, bytes):
- return value.decode("utf-8", "backslashreplace").splitlines(keepends=True)
- if isinstance(value, tuple) and value and isinstance(value[0], tuple):
- return ["".join(cell or "·" for cell in row).rstrip() + "\n" for row in value]
- return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").splitlines(
- keepends=True
- )
-
- def compare(self, name: str, typescript: object, rust: object, *, screen: bool = False) -> None:
- self.observables += 1
+ return value.decode("utf-8", "backslashreplace").split("\n")
+ if isinstance(value, str):
+ return value.split("\n")
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True).split("\n")
+
+ def _group(self, group: str) -> dict[str, list[str]]:
+ if group not in self.groups:
+ path = self.directory / f"{group}.json"
+ recorded: dict[str, list[str]] = {}
+ if not self.record and path.exists():
+ recorded = json.loads(path.read_text(encoding="utf-8"))
+ self.groups[group] = recorded
+ self.seen[group] = set()
+ return self.groups[group]
+
+ def check(self, group: str, name: str, value: object, *, screen: bool = False) -> None:
+ """Compare one observable with its recording."""
+ self.checked += 1
if screen:
self.screens += 1
- if typescript == rust:
+ rendered = self._render(value, screen=screen)
+ recorded = self._group(group)
+ self.seen[group].add(name)
+ if self.record:
+ recorded[name] = rendered
return
- self.failures.append(name)
- diff = "".join(
+ if name not in recorded:
+ self.fail(name, f"no golden is recorded in {group}.json\n")
+ return
+ if recorded[name] != rendered:
+ self.fail(name, self._diff(name, recorded[name], rendered, "golden", "actual"))
+
+ def equal(self, name: str, expected: object, actual: object) -> None:
+ """Check one expectation this harness states itself, which is never recorded."""
+ self.checked += 1
+ if expected == actual:
+ return
+ self.fail(
+ name,
+ self._diff(name, self._render(expected), self._render(actual), "expected", "actual"),
+ )
+
+ @staticmethod
+ def _diff(name: str, left: list[str], right: list[str], left_name: str, right_name: str) -> str:
+ return "".join(
difflib.unified_diff(
- self._display(typescript),
- self._display(rust),
- fromfile=f"{name}.typescript",
- tofile=f"{name}.rust",
+ [f"{line}\n" for line in left],
+ [f"{line}\n" for line in right],
+ fromfile=f"{name}.{left_name}",
+ tofile=f"{name}.{right_name}",
)
)
- target = self.artifacts / "divergences" / f"{safe_name(name)}.diff"
+
+ def fail(self, name: str, detail: str) -> None:
+ self.failures.append(name)
+ target = self.artifacts / "failures" / f"{safe_name(name)}.diff"
target.parent.mkdir(parents=True, exist_ok=True)
- target.write_text(diff or f"TypeScript: {typescript!r}\nRust: {rust!r}\n", encoding="utf-8")
- print(f"DIVERGENCE {name}\n{diff}", file=sys.stderr)
+ target.write_text(detail or f"{name} differs\n", encoding="utf-8")
+ print(f"FAILURE {name}\n{detail}", file=sys.stderr)
def require_coverage(self, required: Iterable[str]) -> None:
- missing = sorted(set(required) - self.coverage)
- self.compare("screen.key-coverage", [], missing)
+ self.equal("screen.key-coverage", [], sorted(set(required) - self.coverage))
+
+ def finish(self) -> None:
+ """Write the recording, or report goldens this run never exercised."""
+ if self.record:
+ self.directory.mkdir(parents=True, exist_ok=True)
+ for group, entries in self.groups.items():
+ (self.directory / f"{group}.json").write_text(
+ json.dumps(entries, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ for path in sorted(self.directory.glob("*.json")):
+ if path.stem not in self.groups:
+ path.unlink()
+ return
+ for group, entries in self.groups.items():
+ unused = sorted(set(entries) - self.seen[group])
+ if unused:
+ self.fail(f"{group}.unused-goldens", "\n".join(unused) + "\n")
+ unused_groups = sorted(
+ path.stem for path in self.directory.glob("*.json") if path.stem not in self.groups
+ )
+ if unused_groups:
+ self.fail("goldens.unused-groups", "\n".join(unused_groups) + "\n")
class TerminalGrid:
- """Small ANSI terminal model for the sequences emitted by Bun and Ratatui."""
+ """Small ANSI terminal model for the sequences a Ratatui pane emits."""
def __init__(self, rows: int, cols: int) -> None:
self.rows = rows
@@ -362,6 +454,15 @@ def send(self, data: bytes) -> None:
os.write(self.master, data)
self.drain()
+ def wait_exit(self, timeout: float = 5.0) -> None:
+ deadline = time.monotonic() + timeout
+ while self.process.poll() is None and time.monotonic() < deadline:
+ self.drain(quiet=0.05, maximum=0.25)
+ if self.process.poll() is None:
+ raise RuntimeError("PTY command did not exit after an exiting step")
+ # Everything the process wrote on its way out, up to EOF.
+ self.drain(quiet=0.1, maximum=1.0)
+
def send_signal(self, process_signal: int) -> None:
os.killpg(self.process.pid, process_signal)
self.drain()
@@ -487,6 +588,7 @@ def normalize_text(value: str, roots: Iterable[Path]) -> str:
normalized = normalized.replace(root, "")
normalized = PENDING_PATTERN.sub("pending--.json", normalized)
normalized = TEMP_PATTERN.sub(r".\1--.tmp", normalized)
+ normalized = HANDOFF_PATTERN.sub("herdr-annotate-", normalized)
normalized = UUID_PATTERN.sub("", normalized)
def timestamp(match: re.Match[str]) -> str:
@@ -503,12 +605,30 @@ def read_process_log(path: Path, roots: Iterable[Path]) -> bytes:
if not path.exists():
return b""
entries = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
+ collapsed: list[dict[str, object]] = []
+ for entry in entries:
+ # A clipboard operation is one entry however long this platform's candidate chain is.
+ role = entry.get("command")
+ if (
+ collapsed
+ and isinstance(role, str)
+ and role.startswith("clipboard-")
+ and collapsed[-1].get("command") == role
+ ):
+ continue
+ collapsed.append(entry)
raw = "\n".join(
- json.dumps(entry, ensure_ascii=False, separators=(",", ":"), sort_keys=True) for entry in entries
+ json.dumps(entry, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
+ for entry in collapsed
)
return normalize_bytes((raw + ("\n" if raw else "")).encode("utf-8"), roots)
+def adapter_log(process_log: Path) -> Path:
+ """Where the fake clipboard tools record the adapter chain each operation walked."""
+ return process_log.with_suffix(".adapters.jsonl")
+
+
def state_snapshot(path: Path, roots: Iterable[Path]) -> bytes:
if not path.exists():
return b"\n"
@@ -535,25 +655,37 @@ def create_fakes(directory: Path) -> Path:
"""#!/usr/bin/env python3
import json, os, pathlib, sys
name = pathlib.Path(sys.argv[0]).name
-log = pathlib.Path(os.environ["PARITY_PROCESS_LOG"])
-log.parent.mkdir(parents=True, exist_ok=True)
-with log.open("a", encoding="utf-8") as output:
- output.write(json.dumps({"command": name, "args": sys.argv[1:]}, ensure_ascii=False, separators=(",", ":")) + "\\n")
+
+
+def append(variable, record):
+ path = os.environ.get(variable)
+ if not path:
+ return
+ target = pathlib.Path(path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ with target.open("a", encoding="utf-8") as output:
+ output.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\\n")
+
+
if name == "herdr-fake":
- if os.environ.get("PARITY_HERDR_FAIL") == "1" and sys.argv[1:2] == ["plugin"]:
- sys.stderr.write(os.environ.get("PARITY_HERDR_STDERR", "fake herdr failure") + "\\n")
+ append("LITE_PROCESS_LOG", {"command": name, "args": sys.argv[1:]})
+ if os.environ.get("LITE_HERDR_FAIL") == "1" and sys.argv[1:2] == ["plugin"]:
+ sys.stderr.write(os.environ.get("LITE_HERDR_STDERR", "fake herdr failure") + "\\n")
raise SystemExit(7)
raise SystemExit(0)
-reads = {"pbpaste", "wl-paste", "xclip-read", "xsel-read"}
-writes = {"pbcopy", "wl-copy", "xclip-write", "xsel-write"}
+reads = {"pbpaste", "wl-paste"}
mode = "read" if name in reads or (name == "xclip" and "-out" in sys.argv) or (name == "xsel" and "--output" in sys.argv) else "write"
+# The process log keeps the role only, so it is identical on every platform; the adapter log keeps
+# the platform's real program and arguments for the chain check.
+append("LITE_PROCESS_LOG", {"command": "clipboard-" + mode, "args": []})
+append("LITE_ADAPTER_LOG", {"role": mode, "adapter": name, "args": sys.argv[1:]})
data = sys.stdin.buffer.read() if mode == "write" else b""
-if mode == "write" and os.environ.get("PARITY_CLIPBOARD_OUTPUT"):
- pathlib.Path(os.environ["PARITY_CLIPBOARD_OUTPUT"]).write_bytes(data)
-if os.environ.get("PARITY_CLIPBOARD_FAIL") in (mode, "all"):
+if mode == "write" and os.environ.get("LITE_CLIPBOARD_OUTPUT"):
+ pathlib.Path(os.environ["LITE_CLIPBOARD_OUTPUT"]).write_bytes(data)
+if os.environ.get("LITE_CLIPBOARD_FAIL") in (mode, "all"):
raise SystemExit(9)
if mode == "read":
- source = os.environ.get("PARITY_CLIPBOARD_INPUT")
+ source = os.environ.get("LITE_CLIPBOARD_INPUT")
if source:
sys.stdout.buffer.write(pathlib.Path(source).read_bytes())
raise SystemExit(0)
@@ -567,17 +699,19 @@ def create_fakes(directory: Path) -> Path:
class Harness:
- def __init__(self, root: Path, rust_binary: Path, workspace: Path, proof: Proof) -> None:
+ def __init__(self, root: Path, binary: Path, workspace: Path, goldens: Goldens) -> None:
self.root = root
- self.rust_binary = rust_binary
+ self.binary = binary
self.workspace = workspace
- self.proof = proof
+ self.goldens = goldens
self.fake_bin = create_fakes(workspace / "fake-bin")
+ self.chains: dict[str, set[tuple[tuple[str, tuple[str, ...]], ...]]] = {
+ "read": set(),
+ "write": set(),
+ }
- def command(self, implementation: str, entrypoint: str) -> list[str]:
- if implementation == "rust":
- return [str(self.rust_binary), entrypoint]
- return ["bun", str(self.root / "src" / TYPESCRIPT_SCRIPTS[entrypoint])]
+ def command(self, entrypoint: str) -> list[str]:
+ return [str(self.binary), entrypoint]
def environment(
self,
@@ -597,24 +731,25 @@ def environment(
"HERDR_BIN_PATH": str(self.fake_bin / "herdr-fake"),
"HERDR_PLUGIN_CONTEXT_JSON": "{}",
"XDG_RUNTIME_DIR": str(runtime),
- "PARITY_PROCESS_LOG": str(process_log),
- "PARITY_CLIPBOARD_INPUT": str(clipboard_input),
- "PARITY_CLIPBOARD_OUTPUT": str(clipboard_output),
+ "LITE_PROCESS_LOG": str(process_log),
+ "LITE_ADAPTER_LOG": str(adapter_log(process_log)),
+ "LITE_CLIPBOARD_INPUT": str(clipboard_input),
+ "LITE_CLIPBOARD_OUTPUT": str(clipboard_output),
"TZ": "UTC",
"LANG": "en_US.UTF-8",
"LC_ALL": "en_US.UTF-8",
"TERM": "xterm-256color",
}
)
- for key in ("PARITY_CLIPBOARD_FAIL", "PARITY_HERDR_FAIL", "PARITY_HERDR_STDERR", "HERDR_ANNOTATE_PENDING"):
+ for key in ("LITE_CLIPBOARD_FAIL", "LITE_HERDR_FAIL", "LITE_HERDR_STDERR", "HERDR_ANNOTATE_PENDING"):
env.pop(key, None)
if extra:
env.update(extra)
return env
- def run(self, implementation: str, entrypoint: str, env: Mapping[str, str]) -> CommandResult:
+ def run(self, entrypoint: str, env: Mapping[str, str]) -> CommandResult:
result = subprocess.run(
- self.command(implementation, entrypoint),
+ self.command(entrypoint),
cwd=self.root,
env=dict(env),
stdout=subprocess.PIPE,
@@ -623,50 +758,63 @@ def run(self, implementation: str, entrypoint: str, env: Mapping[str, str]) -> C
)
return CommandResult(result.returncode, result.stdout, result.stderr)
- def process_pair(
+ def record_chains(self, log: Path) -> None:
+ """Keep every adapter chain a case walked, for the platform chain check."""
+ if not log.exists():
+ return
+ entries = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines() if line]
+ chain: list[tuple[str, tuple[str, ...]]] = []
+ role = ""
+ for entry in entries:
+ if chain and entry["role"] != role:
+ self.chains[role].add(tuple(chain))
+ chain = []
+ role = entry["role"]
+ chain.append((entry["adapter"], tuple(entry["args"])))
+ if chain:
+ self.chains[role].add(tuple(chain))
+
+ def case_paths(self, name: str) -> tuple[Path, Path, Path, Path, Path]:
+ base = self.workspace / name
+ base.mkdir(parents=True)
+ runtime = base / "runtime"
+ runtime.mkdir()
+ clipboard_input = base / "clipboard-input"
+ clipboard_input.write_bytes(b"")
+ return (
+ base / "state",
+ runtime,
+ base / "process.jsonl",
+ clipboard_input,
+ base / "clipboard-output",
+ )
+
+ def process_case(
self,
name: str,
entrypoint: str,
- setup: Callable[[str, Path, Path, Path, Path, Path], Mapping[str, str] | None],
- inspect: Callable[[str, Path, Path, Path, Path, Path], object] | None = None,
- ) -> tuple[Path, Path]:
- case = self.workspace / name
- results: dict[str, tuple[CommandResult, Path, Path, Path, Path, Path]] = {}
- for implementation in ("typescript", "rust"):
- base = case / implementation
- state = base / "state"
- runtime = base / "runtime"
- log = base / "process.jsonl"
- clipboard_input = base / "clipboard-input"
- clipboard_output = base / "clipboard-output"
- base.mkdir(parents=True)
- runtime.mkdir()
- clipboard_input.write_bytes(b"")
- extra = setup(implementation, state, runtime, log, clipboard_input, clipboard_output) or {}
- env = self.environment(state, runtime, log, clipboard_input, clipboard_output, extra)
- results[implementation] = (
- self.run(implementation, entrypoint, env),
- state,
- runtime,
- log,
- clipboard_input,
- clipboard_output,
- )
- ts, rs = results["typescript"], results["rust"]
- roots = [ts[1], ts[2], rs[1], rs[2], self.workspace]
- self.proof.compare(f"{name}.exit", ts[0].exit_code, rs[0].exit_code)
- self.proof.compare(f"{name}.stdout", normalize_bytes(ts[0].stdout, roots), normalize_bytes(rs[0].stdout, roots))
- self.proof.compare(f"{name}.stderr", normalize_bytes(ts[0].stderr, roots), normalize_bytes(rs[0].stderr, roots))
- self.proof.compare(f"{name}.processes", read_process_log(ts[3], roots), read_process_log(rs[3], roots))
+ setup: Callable[[Path, Path, Path, Path, Path], Mapping[str, str] | None],
+ inspect: Callable[[Path, Path, Path, Path, Path], object] | None = None,
+ ) -> Path:
+ state, runtime, log, clipboard_input, clipboard_output = self.case_paths(name)
+ extra = setup(state, runtime, log, clipboard_input, clipboard_output) or {}
+ env = self.environment(state, runtime, log, clipboard_input, clipboard_output, extra)
+ result = self.run(entrypoint, env)
+ self.record_chains(adapter_log(log))
+ roots = [state, runtime, self.workspace, self.root]
+ self.goldens.check(name, f"{name}.exit", result.exit_code)
+ self.goldens.check(name, f"{name}.stdout", normalize_bytes(result.stdout, roots))
+ self.goldens.check(name, f"{name}.stderr", normalize_bytes(result.stderr, roots))
+ self.goldens.check(name, f"{name}.processes", read_process_log(log, roots))
if inspect:
- self.proof.compare(
+ self.goldens.check(
+ name,
f"{name}.artifact",
- inspect("typescript", *ts[1:]),
- inspect("rust", *rs[1:]),
+ inspect(state, runtime, log, clipboard_input, clipboard_output),
)
- return ts[1], rs[1]
+ return state
- def pty_pair(
+ def pty_case(
self,
name: str,
entrypoint: str,
@@ -675,79 +823,55 @@ def pty_pair(
rows: int,
cols: int,
seed: Callable[[Path], None],
- extra: Mapping[str, str]
- | Callable[[str, Path], Mapping[str, str]]
- | None = None,
+ extra: Mapping[str, str] | Callable[[Path], Mapping[str, str]] | None = None,
compare_state: bool = False,
compare_clipboard: bool = False,
- ) -> tuple[Path, Path]:
- case = self.workspace / name
- results: dict[str, tuple[PtyResult, Path, Path, Path, Path]] = {}
- for implementation in ("typescript", "rust"):
- base = case / implementation
- state = base / "state"
- runtime = base / "runtime"
- log = base / "process.jsonl"
- clipboard_input = base / "clipboard-input"
- clipboard_output = base / "clipboard-output"
- base.mkdir(parents=True)
- runtime.mkdir()
- clipboard_input.write_bytes(b"clipboard selection")
- seed(state)
- case_extra = extra(implementation, state) if callable(extra) else extra
- env = self.environment(
- state, runtime, log, clipboard_input, clipboard_output, case_extra
- )
- session = PtySession(self.command(implementation, entrypoint), env, self.root, rows, cols)
- session.wait_for(marker)
- screens = [("initial", session.grid.snapshot())]
- for step in steps:
- self.proof.coverage.update(step.coverage)
- if step.process_signal is None:
- session.send(step.data)
- else:
- session.send_signal(step.process_signal)
- screens.append((step.label, session.grid.snapshot()))
- code = session.finish()
- results[implementation] = (
- PtyResult(screens, code, osc52_sequences(bytes(session.raw))),
- state,
- runtime,
- log,
- clipboard_output,
- )
- ts, rs = results["typescript"], results["rust"]
- roots = [ts[1], ts[2], rs[1], rs[2], self.workspace]
- self.proof.compare(f"{name}.exit", ts[0].exit_code, rs[0].exit_code)
- self.proof.compare(f"{name}.screen-count", len(ts[0].screens), len(rs[0].screens))
- for (ts_label, ts_screen), (rs_label, rs_screen) in zip(ts[0].screens, rs[0].screens):
- self.proof.compare(f"{name}.screen.{ts_label}.label", ts_label, rs_label)
- self.proof.compare(f"{name}.screen.{ts_label}", ts_screen, rs_screen, screen=True)
- self.proof.compare(f"{name}.processes", read_process_log(ts[3], roots), read_process_log(rs[3], roots))
- self.proof.compare(f"{name}.osc52", ts[0].osc52, rs[0].osc52)
+ ) -> Path:
+ state, runtime, log, clipboard_input, clipboard_output = self.case_paths(name)
+ clipboard_input.write_bytes(b"clipboard selection")
+ seed(state)
+ case_extra = extra(state) if callable(extra) else extra
+ env = self.environment(state, runtime, log, clipboard_input, clipboard_output, case_extra)
+ session = PtySession(self.command(entrypoint), env, self.root, rows, cols)
+ session.wait_for(marker)
+ screens = [("initial", session.grid.snapshot())]
+ for step in steps:
+ self.goldens.coverage.update(step.coverage)
+ if step.process_signal is None:
+ session.send(step.data)
+ if step.exits:
+ session.wait_exit()
+ else:
+ session.send_signal(step.process_signal)
+ screens.append((step.label, session.grid.snapshot()))
+ exit_code = session.finish()
+ osc52 = osc52_sequences(bytes(session.raw))
+ self.record_chains(adapter_log(log))
+ roots = [state, runtime, self.workspace, self.root]
+ self.goldens.check(name, f"{name}.exit", exit_code)
+ self.goldens.check(name, f"{name}.screen-count", len(screens))
+ for label, screen in screens:
+ self.goldens.check(name, f"{name}.screen.{label}", screen, screen=True)
+ self.goldens.check(name, f"{name}.processes", read_process_log(log, roots))
+ self.goldens.check(name, f"{name}.osc52", osc52)
if compare_clipboard:
- ts_clipboard = normalize_bytes(ts[4].read_bytes() if ts[4].exists() else b"", roots)
- rs_clipboard = normalize_bytes(rs[4].read_bytes() if rs[4].exists() else b"", roots)
- self.proof.compare(f"{name}.clipboard", ts_clipboard, rs_clipboard)
+ clipboard = normalize_bytes(
+ clipboard_output.read_bytes() if clipboard_output.exists() else b"", roots
+ )
+ self.goldens.check(name, f"{name}.clipboard", clipboard)
# Every pane copy reaches the viewing client too: the payload the terminal received is
# byte-for-byte the text the native writer was handed.
- def readable(value: bytes) -> str:
- return value.decode("utf-8", "backslashreplace")
-
- self.proof.compare(
+ self.goldens.equal(
f"{name}.osc52-payload",
- [readable(ts_clipboard), readable(rs_clipboard)],
- [
- readable(normalize_bytes(osc52_payload(ts[0].osc52), roots)),
- readable(normalize_bytes(osc52_payload(rs[0].osc52), roots)),
- ],
+ clipboard,
+ normalize_bytes(osc52_payload(osc52), roots),
)
if compare_state:
- self.proof.compare(f"{name}.state", state_snapshot(ts[1], roots), state_snapshot(rs[1], roots))
- return ts[1], rs[1]
+ self.goldens.check(name, f"{name}.state", state_snapshot(state, roots))
+ return state
-def pending_artifact(_implementation: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object:
+def pending_artifact(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object:
del runtime, log, source, sink
files = sorted(state.glob("pending-*.json"))
if len(files) != 1:
@@ -760,27 +884,24 @@ def pending_artifact(_implementation: str, state: Path, runtime: Path, log: Path
}
-def pending_and_runtime_artifact(
- implementation: str,
- state: Path,
+def pending_and_runtime_artifact(state: Path,
runtime: Path,
log: Path,
source: Path,
sink: Path,
) -> object:
return {
- "pending": pending_artifact(implementation, state, runtime, log, source, sink),
+ "pending": pending_artifact(state, runtime, log, source, sink),
"runtime": state_snapshot(runtime, [runtime]).decode("utf-8"),
}
-def clipboard_artifact(_implementation: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object:
+def clipboard_artifact(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object:
del state, runtime, log, source
return sink.read_bytes() if sink.exists() else b""
-def clipboard_and_state_artifact(
- _implementation: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+def clipboard_and_state_artifact(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> object:
del runtime, log, source
return {
@@ -789,7 +910,7 @@ def clipboard_and_state_artifact(
}
-def no_pending_artifact(_implementation: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object:
+def no_pending_artifact(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object:
del runtime, log, source, sink
return sorted(path.name for path in state.glob("pending-*.json")) if state.exists() else []
@@ -814,7 +935,7 @@ def run_process_layer(harness: Harness) -> None:
def handoff_path(runtime: Path) -> Path:
return runtime / f"herdr-annotate-{os.getuid() if hasattr(os, 'getuid') else 'user'}" / "selection"
- def capture_context(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
+ def capture_context(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
del log, sink
state.mkdir()
handoff = handoff_path(runtime)
@@ -823,11 +944,11 @@ def capture_context(_impl: str, state: Path, runtime: Path, log: Path, source: P
source.write_text("lower-priority clipboard", encoding="utf-8")
return {"HERDR_PLUGIN_CONTEXT_JSON": context}
- harness.process_pair(
+ harness.process_case(
"process.capture.context", "capture", capture_context, pending_and_runtime_artifact
)
- def capture_handoff(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def capture_handoff(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del log, sink
state.mkdir()
handoff = handoff_path(runtime)
@@ -835,12 +956,11 @@ def capture_handoff(_impl: str, state: Path, runtime: Path, log: Path, source: P
handoff.write_text("handoff selection\n", encoding="utf-8")
source.write_text("lower-priority clipboard", encoding="utf-8")
- harness.process_pair(
+ harness.process_case(
"process.capture.handoff", "capture", capture_handoff, pending_and_runtime_artifact
)
- def capture_stale_handoff(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def capture_stale_handoff(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> None:
del log, sink
state.mkdir()
@@ -851,15 +971,14 @@ def capture_stale_handoff(
os.utime(handoff, (stale, stale))
source.write_text("clipboard after stale handoff", encoding="utf-8")
- harness.process_pair(
+ harness.process_case(
"process.capture.stale-handoff",
"capture",
capture_stale_handoff,
pending_and_runtime_artifact,
)
- def capture_blank_handoff(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def capture_blank_handoff(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> None:
del log, sink
state.mkdir()
@@ -868,15 +987,14 @@ def capture_blank_handoff(
handoff.write_text(" \n\t", encoding="utf-8")
source.write_text("clipboard after blank handoff", encoding="utf-8")
- harness.process_pair(
+ harness.process_case(
"process.capture.blank-handoff",
"capture",
capture_blank_handoff,
pending_and_runtime_artifact,
)
- def capture_invalid_utf8_handoff(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def capture_invalid_utf8_handoff(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> None:
del log, sink
state.mkdir()
@@ -885,234 +1003,226 @@ def capture_invalid_utf8_handoff(
handoff.write_bytes(b"invalid-\xff-handoff")
source.write_text("clipboard after invalid handoff", encoding="utf-8")
- harness.process_pair(
+ harness.process_case(
"process.capture.invalid-utf8-handoff",
"capture",
capture_invalid_utf8_handoff,
pending_and_runtime_artifact,
)
- def capture_clipboard(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def capture_clipboard(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del runtime, log, sink
state.mkdir()
source.write_bytes("clipboard 한 selection".encode("utf-8"))
- harness.process_pair("process.capture.clipboard", "capture", capture_clipboard, pending_artifact)
+ harness.process_case("process.capture.clipboard", "capture", capture_clipboard, pending_artifact)
- def capture_invalid_context(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def capture_invalid_context(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> Mapping[str, str]:
del runtime, log, sink
state.mkdir()
source.write_text("clipboard after invalid context", encoding="utf-8")
return {"HERDR_PLUGIN_CONTEXT_JSON": "{broken"}
- harness.process_pair(
+ harness.process_case(
"process.capture.invalid-context", "capture", capture_invalid_context, pending_artifact
)
- def capture_empty(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def capture_empty(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del runtime, log, sink
state.mkdir()
source.write_bytes(b" \n\t")
- harness.process_pair("process.capture.empty", "capture", capture_empty, no_pending_artifact)
+ harness.process_case("process.capture.empty", "capture", capture_empty, no_pending_artifact)
- def capture_no_clipboard(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
+ def capture_no_clipboard(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
del runtime, log, source, sink
state.mkdir()
- return {"PARITY_CLIPBOARD_FAIL": "read"}
+ return {"LITE_CLIPBOARD_FAIL": "read"}
- harness.process_pair("process.capture.no-clipboard", "capture", capture_no_clipboard, no_pending_artifact)
+ harness.process_case("process.capture.no-clipboard", "capture", capture_no_clipboard, no_pending_artifact)
- def capture_open_failure(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
+ def capture_open_failure(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
del runtime, log, source, sink
state.mkdir()
return {
"HERDR_PLUGIN_CONTEXT_JSON": context,
- "PARITY_HERDR_FAIL": "1",
- "PARITY_HERDR_STDERR": "pane open failed",
+ "LITE_HERDR_FAIL": "1",
+ "LITE_HERDR_STDERR": "pane open failed",
}
- harness.process_pair("process.capture.open-failure", "capture", capture_open_failure, no_pending_artifact)
+ harness.process_case("process.capture.open-failure", "capture", capture_open_failure, no_pending_artifact)
- def capture_missing_state(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def capture_missing_state(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> Mapping[str, str]:
del state, runtime, log, source, sink
return {"HERDR_PLUGIN_STATE_DIR": "", "HERDR_PLUGIN_CONTEXT_JSON": context}
- harness.process_pair("process.capture.missing-state", "capture", capture_missing_state)
+ harness.process_case("process.capture.missing-state", "capture", capture_missing_state)
- def capture_missing_root(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def capture_missing_root(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> Mapping[str, str]:
del runtime, log, source, sink
state.mkdir()
return {"HERDR_PLUGIN_ROOT": "", "HERDR_PLUGIN_CONTEXT_JSON": context}
- harness.process_pair("process.capture.missing-root", "capture", capture_missing_root)
+ harness.process_case("process.capture.missing-root", "capture", capture_missing_root)
- def copy_empty(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def copy_empty(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del state, runtime, log, source, sink
- harness.process_pair(
+ harness.process_case(
"process.copy.empty",
"copy-context",
copy_empty,
- lambda impl, state, runtime, log, source, sink: state_snapshot(state, [state]),
+ lambda state, runtime, log, source, sink: state_snapshot(state, [state]),
)
- def copy_populated(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def copy_populated(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del runtime, log, source, sink
seed_stores(state, archives=[])
- harness.process_pair("process.copy.populated", "copy-context", copy_populated, clipboard_artifact)
+ harness.process_case("process.copy.populated", "copy-context", copy_populated, clipboard_artifact)
- def copy_single(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def copy_single(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del runtime, log, source, sink
seed_stores(state, annotations=ANNOTATIONS[:1], archives=[])
- harness.process_pair("process.copy.single", "copy-context", copy_single, clipboard_artifact)
+ harness.process_case("process.copy.single", "copy-context", copy_single, clipboard_artifact)
- def copy_no_clipboard(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
+ def copy_no_clipboard(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
del runtime, log, source, sink
seed_stores(state, archives=[])
- return {"PARITY_CLIPBOARD_FAIL": "write"}
+ return {"LITE_CLIPBOARD_FAIL": "write"}
- harness.process_pair("process.copy.no-clipboard", "copy-context", copy_no_clipboard, clipboard_artifact)
+ harness.process_case("process.copy.no-clipboard", "copy-context", copy_no_clipboard, clipboard_artifact)
- def copy_invalid(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def copy_invalid(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del runtime, log, source, sink
state.mkdir()
(state / "annotations.jsonl").write_text("{broken\n", encoding="utf-8")
- harness.process_pair("process.copy.invalid-store", "copy-context", copy_invalid)
+ harness.process_case("process.copy.invalid-store", "copy-context", copy_invalid)
- def copy_missing_state(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def copy_missing_state(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> Mapping[str, str]:
del state, runtime, log, source, sink
return {"HERDR_PLUGIN_STATE_DIR": ""}
- harness.process_pair("process.copy.missing-state", "copy-context", copy_missing_state)
+ harness.process_case("process.copy.missing-state", "copy-context", copy_missing_state)
- def fresh_lock(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def fresh_lock(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del runtime, log, source, sink
state.mkdir()
(state / ".annotations.lock").mkdir()
- harness.process_pair(
+ harness.process_case(
"process.copy.busy-lock",
"copy-context",
fresh_lock,
- lambda impl, state, runtime, log, source, sink: state_snapshot(state, [state]),
+ lambda state, runtime, log, source, sink: state_snapshot(state, [state]),
)
- def stale_lock(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
- fresh_lock(_impl, state, runtime, log, source, sink)
+ def stale_lock(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ fresh_lock(state, runtime, log, source, sink)
stale = time.time() - 31
os.utime(state / ".annotations.lock", (stale, stale))
- harness.process_pair(
+ harness.process_case(
"process.copy.stale-lock",
"copy-context",
stale_lock,
- lambda impl, state, runtime, log, source, sink: state_snapshot(state, [state]),
+ lambda state, runtime, log, source, sink: state_snapshot(state, [state]),
)
- def copy_archive_empty(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def copy_archive_empty(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del state, runtime, log, source, sink
- harness.process_pair(
+ harness.process_case(
"process.copy-archive.empty",
"copy-archive",
copy_archive_empty,
clipboard_and_state_artifact,
)
- def copy_archive_populated(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def copy_archive_populated(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del runtime, log, source, sink
seed_stores(state)
- harness.process_pair(
+ harness.process_case(
"process.copy-archive.populated",
"copy-archive",
copy_archive_populated,
clipboard_and_state_artifact,
)
- def copy_archive_no_clipboard(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def copy_archive_no_clipboard(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> Mapping[str, str]:
del runtime, log, source, sink
seed_stores(state)
- return {"PARITY_CLIPBOARD_FAIL": "write"}
+ return {"LITE_CLIPBOARD_FAIL": "write"}
- harness.process_pair(
+ harness.process_case(
"process.copy-archive.no-clipboard",
"copy-archive",
copy_archive_no_clipboard,
clipboard_and_state_artifact,
)
- def copy_archive_missing_state(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def copy_archive_missing_state(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> Mapping[str, str]:
del state, runtime, log, source, sink
return {"HERDR_PLUGIN_STATE_DIR": ""}
- harness.process_pair("process.copy-archive.missing-state", "copy-archive", copy_archive_missing_state)
+ harness.process_case("process.copy-archive.missing-state", "copy-archive", copy_archive_missing_state)
- def manage_success(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def manage_success(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del state, runtime, log, source, sink
- harness.process_pair("process.manage.success", "manage", manage_success)
+ harness.process_case("process.manage.success", "manage", manage_success)
- def manage_failure(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
+ def manage_failure(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
del state, runtime, log, source, sink
- return {"PARITY_HERDR_FAIL": "1", "PARITY_HERDR_STDERR": "manager open failed"}
+ return {"LITE_HERDR_FAIL": "1", "LITE_HERDR_STDERR": "manager open failed"}
- harness.process_pair("process.manage.failure", "manage", manage_failure)
+ harness.process_case("process.manage.failure", "manage", manage_failure)
- def manage_failure_without_stderr(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def manage_failure_without_stderr(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> Mapping[str, str]:
del state, runtime, log, source, sink
- return {"PARITY_HERDR_FAIL": "1", "PARITY_HERDR_STDERR": ""}
+ return {"LITE_HERDR_FAIL": "1", "LITE_HERDR_STDERR": ""}
- harness.process_pair(
+ harness.process_case(
"process.manage.failure-without-stderr", "manage", manage_failure_without_stderr
)
- def manage_missing_root(
- _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path
+ def manage_missing_root(state: Path, runtime: Path, log: Path, source: Path, sink: Path
) -> Mapping[str, str]:
del state, runtime, log, source, sink
return {"HERDR_PLUGIN_ROOT": ""}
- harness.process_pair("process.manage.missing-root", "manage", manage_missing_root)
+ harness.process_case("process.manage.missing-root", "manage", manage_missing_root)
- def editor_missing(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
+ def editor_missing(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None:
del runtime, log, source, sink
state.mkdir()
- harness.process_pair("process.editor.missing-pending", "editor", editor_missing)
+ harness.process_case("process.editor.missing-pending", "editor", editor_missing)
- def editor_invalid(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
+ def editor_invalid(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
del runtime, log, source, sink
state.mkdir()
pending = state / "invalid-pending.json"
pending.write_text('{"selectedText":"only"}\n', encoding="utf-8")
return {"HERDR_ANNOTATE_PENDING": str(pending)}
- harness.process_pair("process.editor.invalid-pending", "editor", editor_invalid)
+ harness.process_case("process.editor.invalid-pending", "editor", editor_invalid)
- def manager_missing(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
+ def manager_missing(state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]:
del state, runtime, log, source, sink
return {"HERDR_PLUGIN_STATE_DIR": ""}
- harness.process_pair("process.manager.missing-state", "manager", manager_missing)
+ harness.process_case("process.manager.missing-state", "manager", manager_missing)
EDITOR_REQUIRED = {
@@ -1162,7 +1272,7 @@ def empty_manager_seed(state: Path) -> None:
state.mkdir(parents=True, mode=0o700)
-def pending_file_extra(_implementation: str, state: Path) -> Mapping[str, str]:
+def pending_file_extra(state: Path) -> Mapping[str, str]:
pending = state / "pending-input.json"
pending.write_text(
json.dumps(
@@ -1179,7 +1289,7 @@ def pending_file_extra(_implementation: str, state: Path) -> Mapping[str, str]:
return {"HERDR_ANNOTATE_PENDING": str(pending)}
-def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
+def run_screen_and_store_layer(harness: Harness) -> Path:
editor_context = {
"HERDR_PLUGIN_CONTEXT_JSON": json.dumps(
{
@@ -1205,7 +1315,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
Step("down", b"\x1b[B", ("editor:down",)),
Step("save", b"\x13", ("editor:ctrl-s",)),
]
- editor_ts, editor_rs = harness.pty_pair(
+ editor_state = harness.pty_case(
"screen.editor.edit-save",
"editor",
editor_steps,
@@ -1256,7 +1366,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
Step("mark-c", b"C"),
Step("save", b"\x13", ("editor:ctrl-s",)),
]
- harness.pty_pair(
+ harness.pty_case(
"screen.editor.word-line-moves",
"editor",
move_steps,
@@ -1288,7 +1398,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
Step("mark-tail", b"tail ", ("editor:chars",)),
Step("save", b"\x13", ("editor:ctrl-s",)),
]
- harness.pty_pair(
+ harness.pty_case(
"screen.editor.word-line-kills",
"editor",
kill_steps,
@@ -1299,7 +1409,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
editor_context,
compare_state=True,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.editor.pending-file-save",
"editor",
[Step("chars", b"pending comment"), Step("save", b"\x13")],
@@ -1310,7 +1420,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
pending_file_extra,
compare_state=True,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.editor.empty-save-escape",
"editor",
[
@@ -1323,7 +1433,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
editor_seed,
editor_context,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.editor.control-c",
"editor",
[Step("control-c", b"\x03", ("editor:ctrl-c",))],
@@ -1333,7 +1443,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
editor_seed,
editor_context,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.editor.missing-state",
"editor",
[Step("char", b"x"), Step("save", b"\x13"), Step("escape", b"\x1b")],
@@ -1343,7 +1453,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
editor_seed,
{**editor_context, "HERDR_PLUGIN_STATE_DIR": ""},
)
- harness.pty_pair(
+ harness.pty_case(
"screen.editor.sigterm",
"editor",
[Step("sigterm", process_signal=signal.SIGTERM)],
@@ -1383,7 +1493,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
Step("archives-to-active", b"\t", ("manager:archives:Tab",)),
Step("active-quit", b"q", ("manager:active:q",)),
]
- harness.pty_pair(
+ harness.pty_case(
"screen.manager.all-views",
"manager",
manager_steps,
@@ -1394,7 +1504,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
compare_state=True,
compare_clipboard=True,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.manager.escape-exit",
"manager",
[
@@ -1406,7 +1516,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
98,
manager_seed,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.manager.escape-active",
"manager",
[Step("active-escape", b"\x1b", ("manager:active:Esc",))],
@@ -1415,7 +1525,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
98,
manager_seed,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.manager.control-c-active",
"manager",
[Step("control-c", b"\x03", ("manager:active:Ctrl-C",))],
@@ -1424,7 +1534,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
98,
manager_seed,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.manager.control-c-archives",
"manager",
[
@@ -1436,7 +1546,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
98,
manager_seed,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.manager.q-archives",
"manager",
[
@@ -1448,7 +1558,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
98,
manager_seed,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.manager.sighup",
"manager",
[Step("sighup", process_signal=signal.SIGHUP)],
@@ -1457,7 +1567,7 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
98,
manager_seed,
)
- harness.pty_pair(
+ harness.pty_case(
"screen.manager.empty-actions",
"manager",
[
@@ -1479,17 +1589,17 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
)
for key, steps in (
- ("active-y-success", [Step("copy-one", b"y", ("manager:active:y",))]),
- ("active-c-success", [Step("copy-all", b"c", ("manager:active:c",))]),
+ ("active-y-success", [Step("copy-one", b"y", ("manager:active:y",), exits=True)]),
+ ("active-c-success", [Step("copy-all", b"c", ("manager:active:c",), exits=True)]),
(
"archives-y-success",
[
Step("to-archives", b"\t", ("manager:active:Tab",)),
- Step("copy-archive", b"y", ("manager:archives:y",)),
+ Step("copy-archive", b"y", ("manager:archives:y",), exits=True),
],
),
):
- harness.pty_pair(
+ harness.pty_case(
f"store.manager.{key}",
"manager",
steps,
@@ -1501,10 +1611,10 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
compare_clipboard=True,
)
- harness.pty_pair(
+ harness.pty_case(
"store.manager.copy-archive",
"manager",
- [Step("copy-archive", b"C", ("manager:active:C",))],
+ [Step("copy-archive", b"C", ("manager:active:C",), exits=True)],
"Annotations (",
28,
98,
@@ -1517,11 +1627,11 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
# issue #40: the native write fails, the OSC 52 sequence still reaches the viewing client, and the
# copy is a success. `C` must therefore still archive and clear the active list.
for key, steps in (
- ("osc52-remote-copy", [Step("copy-one", b"y", ("manager:active:y",))]),
- ("osc52-remote-copy-all", [Step("copy-all", b"c", ("manager:active:c",))]),
- ("osc52-remote-copy-archive", [Step("copy-archive", b"C", ("manager:active:C",))]),
+ ("osc52-remote-copy", [Step("copy-one", b"y", ("manager:active:y",), exits=True)]),
+ ("osc52-remote-copy-all", [Step("copy-all", b"c", ("manager:active:c",), exits=True)]),
+ ("osc52-remote-copy-archive", [Step("copy-archive", b"C", ("manager:active:C",), exits=True)]),
):
- states = harness.pty_pair(
+ state = harness.pty_case(
f"store.manager.{key}",
"manager",
steps,
@@ -1529,99 +1639,124 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]:
28,
98,
manager_seed,
- {"PARITY_CLIPBOARD_FAIL": "write"},
+ {"LITE_CLIPBOARD_FAIL": "write"},
compare_state=True,
compare_clipboard=True,
)
if key != "osc52-remote-copy-archive":
continue
+
# The copy succeeded on the OSC 52 path alone, so copy-and-archive must have gone on to
# write its archive (a third, beside the two seeded) and clear the active list, rather than
# stopping at the copy.
- for implementation, state in zip(("typescript", "rust"), states):
- def records(name: str, state: Path = state) -> int:
- path = state / name
- if not path.exists():
- return 0
- return len([line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()])
-
- harness.proof.compare(
- f"store.manager.{key}.archived.{implementation}",
- {"archives": 3, "active": 0},
- {"archives": records("archives.jsonl"), "active": records("annotations.jsonl")},
- )
+ def records(name: str, state: Path = state) -> int:
+ path = state / name
+ if not path.exists():
+ return 0
+ return len([line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()])
+
+ harness.goldens.equal(
+ f"store.manager.{key}.archived",
+ {"archives": 3, "active": 0},
+ {"archives": records("archives.jsonl"), "active": records("annotations.jsonl")},
+ )
- harness.proof.require_coverage(EDITOR_REQUIRED | MANAGER_REQUIRED)
- return editor_ts, editor_rs
+ harness.goldens.require_coverage(EDITOR_REQUIRED | MANAGER_REQUIRED)
+ return editor_state
-def cross_read(harness: Harness, typescript_state: Path, rust_state: Path) -> None:
- case = harness.workspace / "store.cross-read"
- outputs: dict[str, tuple[CommandResult, bytes, bytes]] = {}
- for name, implementation, state in (
- ("typescript-reads-rust", "typescript", rust_state),
- ("rust-reads-typescript", "rust", typescript_state),
- ):
- base = case / name
- runtime = base / "runtime"
- runtime.mkdir(parents=True)
- log = base / "process.jsonl"
- source = base / "clipboard-input"
- source.write_bytes(b"")
- sink = base / "clipboard-output"
- env = harness.environment(state, runtime, log, source, sink)
- result = harness.run(implementation, "copy-context", env)
- outputs[name] = (result, sink.read_bytes() if sink.exists() else b"", read_process_log(log, [state, runtime]))
- left = outputs["typescript-reads-rust"]
- right = outputs["rust-reads-typescript"]
- harness.proof.compare("store.cross-read.exit", left[0].exit_code, right[0].exit_code)
- harness.proof.compare("store.cross-read.stderr", left[0].stderr, right[0].stderr)
- harness.proof.compare("store.cross-read.markdown", left[1], right[1])
- harness.proof.compare("store.cross-read.processes", left[2], right[2])
-
-
-def verify_error_catalog(root: Path, proof: Proof) -> None:
- pairs = {
- "HERDR_PLUGIN_STATE_DIR is not set": ("src/capture.ts", "rust/src/cli.rs"),
- "HERDR_PLUGIN_ROOT is not set": ("src/capture.ts", "rust/src/cli.rs"),
- "No supported clipboard reader is available": ("src/clipboard.ts", "rust/src/clipboard.rs"),
- "No supported clipboard writer is available": ("src/clipboard.ts", "rust/src/clipboard.rs"),
- "Missing pending annotation": ("src/editor.ts", "rust/src/editor.rs"),
- "Pending annotation is invalid": ("src/editor.ts", "rust/src/editor.rs"),
- "Write a comment before saving.": ("src/editor.ts", "rust/src/editor.rs"),
- "Plugin state directory is unavailable.": ("src/editor.ts", "rust/src/editor.rs"),
- "Nothing to copy.": ("src/manager-copy.ts", "rust/src/manager_copy.rs"),
- "Nothing to copy and archive.": ("src/archive-workflow.ts", "rust/src/archive_workflow.rs"),
- "No archive selected.": ("src/manager.ts", "rust/src/manager.rs"),
- "Unable to save annotation": ("src/store.ts", "rust/src/store.rs"),
- "Unable to update annotations": ("src/store.ts", "rust/src/store.rs"),
- "Unable to update archives": ("src/store.ts", "rust/src/store.rs"),
- "Unable to read": ("src/store.ts", "rust/src/store.rs"),
- "Unable to access": ("src/store.ts", "rust/src/store.rs"),
- "Unable to lock": ("src/store.ts", "rust/src/store.rs"),
- "are busy; try again.": ("src/store.ts", "rust/src/store.rs"),
- "Copied and archived, but active annotations remain:": (
- "src/manager.ts",
- "rust/src/manager.rs",
- ),
- "Annotations copied and archived": ("src/export-archive.ts", "rust/src/cli.rs"),
- "Copy and archive failed": ("src/export-archive.ts", "rust/src/cli.rs"),
- "Copy and archive incomplete": ("src/export-archive.ts", "rust/src/cli.rs"),
- "copied as Markdown and archived.": ("src/export-archive.ts", "rust/src/cli.rs"),
- "Annotations restored, but the archive remains:": (
- "src/manager.ts",
- "rust/src/manager.rs",
- ),
- }
- for message, (typescript_file, rust_file) in pairs.items():
- present = (
- message in (root / typescript_file).read_text(encoding="utf-8"),
- message in (root / rust_file).read_text(encoding="utf-8"),
+def verify_cross_read(harness: Harness, editor_state: Path) -> None:
+ """The runtime still reads a store the retired Bun editor wrote.
+
+ The fixture beside the goldens is the exact `annotations.jsonl` the Bun editor produced in the
+ recorded `screen.editor.edit-save` run, so this stays a compatibility check after Bun is gone.
+ """
+ fixture = harness.goldens.directory / "fixtures" / "bun-editor-annotations.jsonl"
+ if harness.goldens.record:
+ fixture.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(editor_state / "annotations.jsonl", fixture)
+ state, runtime, log, clipboard_input, clipboard_output = harness.case_paths("store.cross-read")
+ state.mkdir(mode=0o700)
+ shutil.copyfile(fixture, state / "annotations.jsonl")
+ (state / "annotations.jsonl").chmod(0o600)
+ result = harness.run(
+ "copy-context",
+ harness.environment(state, runtime, log, clipboard_input, clipboard_output),
+ )
+ harness.record_chains(adapter_log(log))
+ roots = [state, runtime, harness.workspace, harness.root]
+ group = "store.cross-read"
+ harness.goldens.check(group, f"{group}.exit", result.exit_code)
+ harness.goldens.check(group, f"{group}.stderr", normalize_bytes(result.stderr, roots))
+ harness.goldens.check(
+ group,
+ f"{group}.markdown",
+ clipboard_output.read_bytes() if clipboard_output.exists() else b"",
+ )
+ harness.goldens.check(group, f"{group}.processes", read_process_log(log, roots))
+
+
+def verify_clipboard_chains(harness: Harness) -> None:
+ """The chains the run walked are this platform's candidate list, in order.
+
+ The goldens keep the clipboard role only so they hold on every platform. The adapter identity,
+ its arguments, and the fallback order are checked here instead, including that the run
+ exercised both a first-adapter success and the full chain.
+ """
+ candidates = CLIPBOARD_CANDIDATES["darwin" if sys.platform == "darwin" else "linux"]
+
+ def steps(chain: Sequence[tuple[str, Sequence[str]]]) -> list[list[object]]:
+ return [[adapter, list(arguments)] for adapter, arguments in chain]
+
+ for role, expected in candidates.items():
+ observed = sorted(harness.chains[role], key=len)
+ harness.goldens.equal(
+ f"clipboard.{role}.chains",
+ [steps(expected[: len(chain)]) for chain in observed],
+ [steps(chain) for chain in observed],
)
- proof.compare(
+ harness.goldens.equal(
+ f"clipboard.{role}.lengths",
+ sorted({1, len(expected)}),
+ sorted({len(chain) for chain in observed}),
+ )
+
+
+# Every user-visible message the retired Bun runtime produced, and the native file that owns it.
+ERROR_CATALOG = {
+ "HERDR_PLUGIN_STATE_DIR is not set": "rust/src/cli.rs",
+ "HERDR_PLUGIN_ROOT is not set": "rust/src/cli.rs",
+ "No supported clipboard reader is available": "rust/src/clipboard.rs",
+ "No supported clipboard writer is available": "rust/src/clipboard.rs",
+ "Missing pending annotation": "rust/src/editor.rs",
+ "Pending annotation is invalid": "rust/src/editor.rs",
+ "Write a comment before saving.": "rust/src/editor.rs",
+ "Plugin state directory is unavailable.": "rust/src/editor.rs",
+ "Nothing to copy.": "rust/src/manager_copy.rs",
+ "Nothing to copy and archive.": "rust/src/archive_workflow.rs",
+ "No archive selected.": "rust/src/manager.rs",
+ "Unable to save annotation": "rust/src/store.rs",
+ "Unable to update annotations": "rust/src/store.rs",
+ "Unable to update archives": "rust/src/store.rs",
+ "Unable to read": "rust/src/store.rs",
+ "Unable to access": "rust/src/store.rs",
+ "Unable to lock": "rust/src/store.rs",
+ "are busy; try again.": "rust/src/store.rs",
+ "Copied and archived, but active annotations remain:": "rust/src/manager.rs",
+ "Annotations copied and archived": "rust/src/cli.rs",
+ "Copy and archive failed": "rust/src/cli.rs",
+ "Copy and archive incomplete": "rust/src/cli.rs",
+ "copied as Markdown and archived.": "rust/src/cli.rs",
+ "Annotations restored, but the archive remains:": "rust/src/manager.rs",
+}
+
+
+def verify_error_catalog(root: Path, goldens: Goldens) -> None:
+ for message, source in ERROR_CATALOG.items():
+ goldens.equal(
f"errors.catalog.{safe_name(message)}",
- (True, True),
- present,
+ True,
+ message in (root / source).read_text(encoding="utf-8"),
)
@@ -1636,99 +1771,143 @@ def manifest_entries(manifest: Mapping[str, object], table: str) -> dict[str, di
}
-def verify_manifests(root: Path, proof: Proof) -> None:
- """Both Lite manifests must declare the same entrypoints, and the harness must drive them all."""
+LITE_UNIX_BUILD = ["bash", "../scripts/fetch-herdr-annotate.sh"]
+LITE_WINDOWS_BUILD = [
+ "powershell.exe",
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ "../scripts/fetch-herdr-annotate.ps1",
+]
+
+
+def verify_manifests(root: Path, goldens: Goldens) -> None:
+ """The Lite variant and the Full plugin declare one entrypoint set, and the harness drives it.
+
+ Lite is the same runtime reached from one directory down, so the two manifests must agree on
+ every declaration a user sees, and neither may gate the annotation tools to a platform.
+ """
def load(path: Path) -> dict[str, object]:
with path.open("rb") as handle:
return tomllib.load(handle)
lite = load(root / "lite" / "herdr-plugin.toml")
- native = load(root / "lite-rs" / "herdr-plugin.toml")
full = load(root / "herdr-plugin.toml")
+ for field in ("id", "name", "version", "min_herdr_version", "description", "platforms"):
+ goldens.equal(f"manifest.{field}", full.get(field), lite.get(field))
+
+ goldens.equal(
+ "manifest.lite-build-commands",
+ [LITE_UNIX_BUILD, LITE_WINDOWS_BUILD],
+ [item.get("command") for item in lite.get("build", []) if isinstance(item, dict)],
+ )
+
lite_actions = manifest_entries(lite, "actions")
- native_actions = manifest_entries(native, "actions")
lite_panes = manifest_entries(lite, "panes")
- native_panes = manifest_entries(native, "panes")
full_actions = manifest_entries(full, "actions")
full_panes = manifest_entries(full, "panes")
- proof.compare("manifest.action-ids", sorted(lite_actions), sorted(native_actions))
- proof.compare("manifest.pane-ids", sorted(lite_panes), sorted(native_panes))
- proof.compare(
+ goldens.equal(
"manifest.harness-entrypoints",
- sorted(TYPESCRIPT_SCRIPTS),
+ list(ENTRYPOINTS),
sorted(set(lite_actions) | set(lite_panes)),
)
+ # Full adds the plannotator-tui review entrypoints; every Lite one must also be in Full.
+ goldens.equal(
+ "manifest.pane-ids",
+ sorted(lite_panes),
+ sorted(set(full_panes) & set(lite_panes)),
+ )
+ goldens.equal(
+ "manifest.action-ids",
+ sorted(lite_actions),
+ sorted(set(full_actions) & set(lite_actions)),
+ )
- for table, lite_entries, native_entries, full_entries, fields in (
- ("action", lite_actions, native_actions, full_actions, ("title", "description", "contexts")),
- ("pane", lite_panes, native_panes, full_panes, ("title", "placement", "width", "height")),
+ for table, lite_entries, full_entries, fields in (
+ ("action", lite_actions, full_actions, ("title", "description", "contexts")),
+ ("pane", lite_panes, full_panes, ("title", "placement", "width", "height")),
):
for identifier, entry in lite_entries.items():
- native_entry = native_entries.get(identifier, {})
full_entry = full_entries.get(identifier, {})
for field in fields:
- proof.compare(
+ goldens.equal(
f"manifest.{table}.{identifier}.{field}",
- (entry.get(field), entry.get(field)),
- (native_entry.get(field), full_entry.get(field)),
+ entry.get(field),
+ full_entry.get(field),
)
- lite_command = entry.get("command", [])
- proof.compare(
+ goldens.equal(
f"manifest.{table}.{identifier}.command",
- (
- [NATIVE_PROGRAM, identifier],
- [part.replace("../src/", "src/") for part in lite_command],
- ),
- (native_entry.get("command"), full_entry.get("command")),
+ ([LITE_NATIVE_PROGRAM, identifier], [NATIVE_PROGRAM, identifier]),
+ (entry.get("command"), full_entry.get("command")),
)
- proof.compare(
+ goldens.equal(
f"manifest.{table}.{identifier}.platforms",
- (None, None, None),
- (entry.get("platforms"), native_entry.get("platforms"), full_entry.get("platforms")),
+ (None, None),
+ (entry.get("platforms"), full_entry.get("platforms")),
)
def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser()
+ parser = argparse.ArgumentParser(description="Check Herdr Annotate Lite against its goldens.")
parser.add_argument("--root", type=Path, required=True)
- parser.add_argument("--rust-binary", type=Path, required=True)
- return parser.parse_args()
+ parser.add_argument("--binary", type=Path, required=True)
+ parser.add_argument(
+ "--goldens",
+ type=Path,
+ default=None,
+ help="the recorded expectations (default: scripts/lite-goldens beside this file)",
+ )
+ parser.add_argument(
+ "--record",
+ action="store_true",
+ help="rewrite the goldens from this run, for a deliberate behavior change",
+ )
+ arguments = parser.parse_args()
+ if arguments.goldens is None:
+ arguments.goldens = Path(__file__).resolve().parent / "lite-goldens"
+ return arguments
def main() -> int:
args = parse_args()
os.umask(0o022)
- workspace = Path(tempfile.mkdtemp(prefix="herdr-annotate-parity-"))
- proof = Proof(workspace / "artifacts")
+ workspace = Path(tempfile.mkdtemp(prefix="herdr-annotate-lite-"))
+ goldens = Goldens(args.goldens.resolve(), workspace / "artifacts", record=args.record)
try:
- harness = Harness(args.root.resolve(), args.rust_binary.resolve(), workspace, proof)
+ harness = Harness(args.root.resolve(), args.binary.resolve(), workspace, goldens)
print("== process layer")
run_process_layer(harness)
print("== screen and store layers")
- typescript_state, rust_state = run_screen_and_store_layer(harness)
- print("== cross-read, error catalog, and manifests")
- cross_read(harness, typescript_state, rust_state)
- verify_error_catalog(args.root.resolve(), proof)
- verify_manifests(args.root.resolve(), proof)
- if proof.failures:
+ editor_state = run_screen_and_store_layer(harness)
+ print("== cross-read, clipboard chains, error catalog, and manifests")
+ verify_cross_read(harness, editor_state)
+ verify_clipboard_chains(harness)
+ verify_error_catalog(args.root.resolve(), goldens)
+ verify_manifests(args.root.resolve(), goldens)
+ goldens.finish()
+ summary = (
+ f"Lite regression: {goldens.checked} observables, {goldens.screens} screens, "
+ f"{len(DELIBERATE_DIFFERENCES)} deliberate difference"
+ )
+ if goldens.failures:
print(
- f"Parity Lite: {proof.observables} observables compared, {proof.screens} screens diffed, "
- f"{len(proof.failures)} divergences / "
- f"{len(DELIBERATE_DIVERGENCES)} deliberate; artifacts: {workspace}",
+ f"{summary}, {len(goldens.failures)} failures; artifacts: {workspace}",
file=sys.stderr,
)
return 1
- print(
- f"Parity Lite: {proof.observables} observables compared, {proof.screens} screens diffed, "
- f"zero divergences / {len(DELIBERATE_DIVERGENCES)} deliberate"
- )
+ if args.record:
+ print(f"{summary}, recorded into {goldens.directory}")
+ else:
+ print(f"{summary}, zero failures")
shutil.rmtree(workspace)
return 0
- except Exception as error: # noqa: BLE001 - harness must retain evidence on any failure.
- print(f"parity-lite: {error}; artifacts: {workspace}", file=sys.stderr)
+ except Exception as error: # noqa: BLE001 - the harness must retain evidence on any failure.
+ print(f"lite-regression: {error}; artifacts: {workspace}", file=sys.stderr)
return 1
diff --git a/scripts/lite-regression.sh b/scripts/lite-regression.sh
new file mode 100755
index 0000000..c61bd0f
--- /dev/null
+++ b/scripts/lite-regression.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Check the native Lite runtime against the goldens recorded from the retired Bun runtime.
+set -euo pipefail
+
+root="$(cd "$(dirname "$0")/.." && pwd)"
+
+for command in cargo python3; do
+ command -v "$command" >/dev/null 2>&1 || {
+ echo "lite-regression: required command is unavailable: $command" >&2
+ exit 2
+ }
+done
+
+echo "== stage the native runtime"
+bash "$root/scripts/stage-local.sh" >/dev/null
+
+exec python3 "$root/scripts/lite-regression.py" \
+ --root "$root" \
+ --binary "$root/bin/herdr-annotate.exe" \
+ "$@"
diff --git a/scripts/parity-lite.sh b/scripts/parity-lite.sh
deleted file mode 100755
index 80edab1..0000000
--- a/scripts/parity-lite.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/env bash
-# Differential proof for the TypeScript and native Rust Lite implementations.
-set -euo pipefail
-
-root="$(cd "$(dirname "$0")/.." && pwd)"
-
-for command in bun cargo python3; do
- command -v "$command" >/dev/null 2>&1 || {
- echo "parity-lite: required command is unavailable: $command" >&2
- exit 2
- }
-done
-
-echo "== stage native Lite"
-bash "$root/lite-rs/scripts/stage-local.sh" >/dev/null
-
-exec python3 "$root/scripts/parity-lite.py" \
- --root "$root" \
- --rust-binary "$root/lite-rs/bin/herdr-annotate.exe"
diff --git a/scripts/smoke-rust-lite.sh b/scripts/smoke-rust-lite.sh
deleted file mode 100755
index eb4aaf5..0000000
--- a/scripts/smoke-rust-lite.sh
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/usr/bin/env bash
-# Smoke-test the native Lite build in a disposable named Herdr session. This links the
-# evaluation manifest, confirms its binary-backed entrypoints, renders the manager pane,
-# then restores the machine's prior `annotate` install.
-#
-# HERDR_SESSION=rust-lite-test bash scripts/smoke-rust-lite.sh
-set -euo pipefail
-
-[ -n "${HERDR_SESSION:-}" ] || { echo "set HERDR_SESSION to a disposable named session" >&2; exit 2; }
-[ "$HERDR_SESSION" != default ] || { echo "refusing to run against the default session" >&2; exit 2; }
-
-root="$(cd "$(dirname "$0")/.." && pwd)"
-failures=0
-
-plugin_json() { herdr plugin list --json | python3 -c '
-import json,sys
-for p in json.load(sys.stdin)["result"]["plugins"]:
- if p["plugin_id"] == "annotate":
- print(json.dumps(p)); break'; }
-field() { python3 -c "import json,sys; p=json.load(sys.stdin); print(eval(sys.argv[1]))" "$1"; }
-check() {
- if [ "$2" = "$3" ]; then
- echo " ok $1: $2"
- else
- echo " FAIL $1: got '$2', want '$3'" >&2
- failures=$((failures+1))
- fi
-}
-
-before="$(plugin_json || true)"
-restore() {
- echo "== restore"
- herdr plugin uninstall annotate >/dev/null 2>&1 || true
- if [ -z "$before" ]; then
- echo " (nothing was installed)"
- return
- fi
- kind="$(printf '%s' "$before" | field "p['source']['kind']")"
- if [ "$kind" = github ]; then
- owner="$(printf '%s' "$before" | field "p['source']['owner']")"
- repo="$(printf '%s' "$before" | field "p['source']['repo']")"
- subdir="$(printf '%s' "$before" | field "p['source'].get('subdir') or ''")"
- commit="$(printf '%s' "$before" | field "p['source']['resolved_commit']")"
- herdr plugin install "$owner/$repo${subdir:+/$subdir}" --ref "$commit" --yes >/dev/null
- echo " restored $owner/$repo${subdir:+/$subdir}@${commit:0:7}"
- else
- prior_root="$(printf '%s' "$before" | field "p['plugin_root']")"
- herdr plugin link "$prior_root" >/dev/null
- echo " restored link $prior_root"
- fi
-}
-trap restore EXIT
-
-echo "== build native Lite"
-bash "$root/lite-rs/scripts/stage-local.sh" >/dev/null
-binary="$root/rust/target/release/herdr-annotate"
-check "binary version" "$("$binary" --version)" "herdr-annotate $(tr -d '[:space:]' < "$root/lite-rs/herdr-annotate.version")"
-
-echo "== link native manifest"
-herdr plugin link "$root/lite-rs" >/dev/null
-installed="$(plugin_json)"
-check "plugin root" "$(printf '%s' "$installed" | field "p['plugin_root']")" "$root/lite-rs"
-actions="$(herdr plugin action list --plugin annotate | python3 -c '
-import json,sys
-print(",".join(sorted(a["action_id"] for a in json.load(sys.stdin)["result"]["actions"])))')"
-check "actions" "$actions" "capture,copy-archive,copy-context,manage"
-commands="$(printf '%s' "$installed" | python3 -c '
-import json,sys
-p=json.load(sys.stdin)
-print(",".join(sorted(a["command"][0] for a in p["actions"])))')"
-check "native action commands" "$commands" "./bin/herdr-annotate.exe,./bin/herdr-annotate.exe,./bin/herdr-annotate.exe,./bin/herdr-annotate.exe"
-check "bundled binary" "$("$root/lite-rs/bin/herdr-annotate.exe" --version)" "herdr-annotate $(tr -d '[:space:]' < "$root/lite-rs/herdr-annotate.version")"
-
-echo "== manager pane renders in $HERDR_SESSION"
-herdr plugin pane open --plugin annotate --entrypoint manager --placement overlay --focus >/dev/null
-pane=""
-for _ in $(seq 1 20); do
- pane="$(herdr pane list | python3 -c '
-import json,sys
-ps=[p for p in json.load(sys.stdin)["result"]["panes"] if p.get("label") == "Annotations"]
-print(ps[-1]["pane_id"] if ps else "")')"
- [ -n "$pane" ] && break
- sleep 0.25
-done
-if [ -n "$pane" ] && herdr pane wait-output "$pane" --match "Annotations (" --timeout 8000 >/dev/null 2>&1; then
- echo " ok manager pane $pane rendered"
-else
- echo " FAIL manager pane did not render" >&2
- [ -n "$pane" ] && herdr pane read "$pane" >&2 || true
- herdr pane list >&2 || true
- herdr plugin log list --plugin annotate --limit 5 >&2 || true
- failures=$((failures+1))
-fi
-[ -n "$pane" ] && herdr plugin pane close "$pane" >/dev/null 2>&1 || true
-
-echo "== result: $failures failure(s)"
-[ "$failures" -eq 0 ]
diff --git a/scripts/smoke.sh b/scripts/smoke.sh
index 5c3fc6f..be2acb1 100755
--- a/scripts/smoke.sh
+++ b/scripts/smoke.sh
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
-# Smoke-test the plugin the way users get it: fresh install, upgrade from an old commit,
-# lite, and the lite -> full swap, then exercise the review pane in a disposable Herdr
-# session. Restores whatever `annotate` install was present before it ran.
+# Smoke-test the plugin the way users get it: fresh install, upgrade from an old commit, lite,
+# and the lite -> full swap, exercising both fetched binaries and both panes in a disposable
+# Herdr session. Restores whatever `annotate` install was present before it ran.
#
# HERDR_SESSION= bash scripts/smoke.sh [old-ref]
#
# Requires: herdr, python3, a running named session in HERDR_SESSION (never the default
-# session; the pane test opens and closes panes in it). The plugin registry is global, so
+# session; the pane tests open and close panes in it). The plugin registry is global, so
# this replaces the machine's `annotate` install while it runs and puts it back at the end.
set -euo pipefail
@@ -22,11 +22,54 @@ for p in json.load(sys.stdin)['result']['plugins']:
field() { python3 -c "import json,sys; p=json.load(sys.stdin); print(eval(sys.argv[1]))" "$1"; }
actions() { herdr plugin action list --plugin annotate | python3 -c "
import json,sys; print(','.join(sorted(a['action_id'] for a in json.load(sys.stdin)['result']['actions'])))"; }
-bin_version() {
- local root; root="$(plugin_json | field "p['plugin_root']")"
- local bin="$root/bin/plannotator-tui.exe"; [ -x "$bin" ] && "$bin" --version | awk '{print $2}' || echo none
+programs() { plugin_json | python3 -c "
+import json,sys
+print(','.join(sorted({a['command'][0] for a in json.load(sys.stdin)['actions']})))"; }
+plugin_root() { plugin_json | field "p['plugin_root']"; }
+# Lite installs from the lite/ subdirectory and shares the checkout's one staged bin/.
+checkout_root() {
+ local root; root="$(plugin_root)"
+ case "$root" in */lite) printf '%s' "${root%/lite}" ;; *) printf '%s' "$root" ;; esac
+}
+tui_version() {
+ local bin; bin="$(plugin_root)/bin/plannotator-tui.exe"
+ [ -x "$bin" ] && "$bin" --version | awk '{print $2}' || echo none
+}
+# An old commit, and the Lite plugin root, carry only one of the two pins.
+read_pin() { if [ -f "$1" ]; then tr -d '[:space:]' < "$1"; else echo none; fi; }
+tui_pin() { read_pin "$(plugin_root)/plannotator-tui.version"; }
+native_version() {
+ local bin; bin="$(checkout_root)/bin/herdr-annotate.exe"
+ [ -x "$bin" ] && "$bin" --version | awk '{print $2}' || echo none
+}
+native_pin() { read_pin "$(checkout_root)/herdr-annotate.version"; }
+pane_id() { herdr pane list | python3 -c "
+import json,sys
+panes=[p for p in json.load(sys.stdin)['result']['panes'] if p.get('label')==sys.argv[1]]
+print(panes[-1]['pane_id'] if panes else '')" "$1"; }
+wait_pane() {
+ local pane=""
+ for _ in $(seq 1 20); do
+ pane="$(pane_id "$1")"
+ [ -n "$pane" ] && break
+ sleep 0.25
+ done
+ printf '%s' "$pane"
+}
+# The manage action opens the manager as a popup, which `herdr pane list` does not report, so
+# open it as an overlay with the same --cwd the action passes: the plugin root, which is what
+# resolves the manifest's relative program for both variants.
+manager_renders() {
+ local pane result=FAIL
+ herdr plugin pane open --plugin annotate --entrypoint manager --placement overlay \
+ --cwd "$(plugin_root)" --focus >/dev/null 2>&1 || true
+ pane="$(wait_pane Annotations)"
+ if [ -n "$pane" ] && herdr pane wait-output "$pane" --match "Annotations (" --timeout 8000 >/dev/null 2>&1; then
+ result=ok
+ fi
+ [ -n "$pane" ] && herdr plugin pane close "$pane" >/dev/null 2>&1 || true
+ printf '%s' "$result"
}
-pin() { local root; root="$(plugin_json | field "p['plugin_root']")"; tr -d '[:space:]' < "$root/plannotator-tui.version" 2>/dev/null || echo none; }
check() { if [ "$2" = "$3" ]; then echo " ok $1: $2"; else echo " FAIL $1: got '$2', want '$3'" >&2; failures=$((failures+1)); fi; }
install() { herdr plugin uninstall annotate >/dev/null 2>&1 || true; herdr plugin install "$@" --yes >/dev/null; }
failures=0
@@ -55,15 +98,16 @@ trap restore EXIT
echo "== fresh install: full"
install "$spec"
check "actions" "$(actions)" "capture,copy-archive,copy-context,last,manage,open,open-link"
-check "binary matches pin" "$(bin_version)" "$(pin)"
+check "action programs" "$(programs)" "./bin/herdr-annotate.exe,./bin/plannotator-tui.exe"
+check "review binary matches pin" "$(tui_version)" "$(tui_pin)"
+check "native binary matches pin" "$(native_version)" "$(native_pin)"
+check "manager pane" "$(manager_renders)" "ok"
echo "== review pane opens with --cwd (the #7 regression)"
work="$(mktemp -d)"; mkdir -p "$work/docs"; printf '# Smoke plan\n\nhello\n' > "$work/docs/plan.md"
-before_panes="$(herdr pane list | python3 -c "import json,sys; print(len(json.load(sys.stdin)['result']['panes']))")"
herdr plugin pane open --plugin annotate --entrypoint doc --placement overlay --cwd "$work" \
--env "PLANNOTATOR_TUI_FILE=$work/docs/plan.md" >/dev/null
-pane="$(herdr pane list | python3 -c "
-import json,sys; ps=[p for p in json.load(sys.stdin)['result']['panes'] if p.get('label')=='Annotate']; print(ps[-1]['pane_id'] if ps else '')")"
+pane="$(wait_pane Annotate)"
if [ -n "$pane" ] && herdr pane wait-output "$pane" --match "Smoke plan" --timeout 8000 >/dev/null 2>&1; then
echo " ok pane $pane rendered the document"
else
@@ -74,20 +118,25 @@ rm -rf "$work"
echo "== upgrade: $old_ref -> main"
install "$spec" --ref "$old_ref"
-old_bin="$(bin_version)"; echo " old binary $old_bin (pin $(pin))"
+old_bin="$(tui_version)"; echo " old review binary $old_bin (pin $(tui_pin))"
install "$spec"
-check "binary replaced on upgrade" "$(bin_version)" "$(pin)"
-[ "$old_bin" != "$(bin_version)" ] && echo " ok binary changed $old_bin -> $(bin_version)" || echo " note old and new pins are equal; replacement not exercised"
+check "review binary replaced on upgrade" "$(tui_version)" "$(tui_pin)"
+check "native binary installed on upgrade" "$(native_version)" "$(native_pin)"
+[ "$old_bin" != "$(tui_version)" ] && echo " ok review binary changed $old_bin -> $(tui_version)" || echo " note old and new pins are equal; replacement not exercised"
echo "== fresh install: lite"
install "$spec/lite"
check "actions" "$(actions)" "capture,copy-archive,copy-context,manage"
-check "no binary" "$(bin_version)" "none"
+check "action programs" "$(programs)" "../bin/herdr-annotate.exe"
+check "no review binary" "$(tui_version)" "none"
+check "native binary matches pin" "$(native_version)" "$(native_pin)"
+check "manager pane" "$(manager_renders)" "ok"
echo "== swap: lite -> full"
install "$spec"
check "actions" "$(actions)" "capture,copy-archive,copy-context,last,manage,open,open-link"
-check "binary" "$(bin_version)" "$(pin)"
+check "review binary" "$(tui_version)" "$(tui_pin)"
+check "native binary" "$(native_version)" "$(native_pin)"
echo "== result: $failures failure(s)"
[ "$failures" -eq 0 ]
diff --git a/scripts/stage-local.sh b/scripts/stage-local.sh
new file mode 100755
index 0000000..6c572a5
--- /dev/null
+++ b/scripts/stage-local.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+# Build and stage the native runtime for `herdr plugin link`, which intentionally skips
+# manifest build hooks. Run from any directory in the checkout.
+set -euo pipefail
+
+root="$(cd "$(dirname "$0")/.." && pwd)"
+cargo build --manifest-path "$root/rust/Cargo.toml" --release
+HERDR_ANNOTATE_BIN="$root/rust/target/release/herdr-annotate" \
+ bash "$root/scripts/fetch-herdr-annotate.sh"
+echo "staged $root/bin/herdr-annotate.exe"
diff --git a/scripts/test-fetch-herdr-annotate.ps1 b/scripts/test-fetch-herdr-annotate.ps1
new file mode 100644
index 0000000..58df5cc
--- /dev/null
+++ b/scripts/test-fetch-herdr-annotate.ps1
@@ -0,0 +1,95 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [ValidateSet("LocalOverride")]
+ [string]$Case
+)
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+if (Test-Path variable:PSNativeCommandUseErrorActionPreference) {
+ $PSNativeCommandUseErrorActionPreference = $false
+}
+
+$repositoryRoot = Split-Path -Parent $PSScriptRoot
+$testRoot = Join-Path $env:RUNNER_TEMP ("herdr annotate fetch " + $Case + " " + [guid]::NewGuid())
+$pluginRoot = Join-Path $testRoot "plugin root with spaces"
+$pluginScripts = Join-Path $pluginRoot "scripts"
+$fetcher = Join-Path $pluginScripts "fetch-herdr-annotate.ps1"
+$destination = Join-Path $pluginRoot "bin/herdr-annotate.exe"
+$stamp = Join-Path $pluginRoot "bin/herdr-annotate.version"
+$version = (Get-Content -LiteralPath (Join-Path $repositoryRoot "herdr-annotate.version") -Raw).Trim()
+$oldLocalOverride = [Environment]::GetEnvironmentVariable("HERDR_ANNOTATE_BIN", "Process")
+
+function Assert-True {
+ param([bool]$Condition, [string]$Message)
+ if (-not $Condition) { throw $Message }
+}
+
+function Invoke-Fetcher {
+ $output = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $fetcher *>&1 |
+ Out-String
+ [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output }
+}
+
+function Assert-BytesEqual {
+ param([string]$Left, [string]$Right, [string]$Message)
+ $leftBytes = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($Left))
+ $rightBytes = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($Right))
+ Assert-True ($leftBytes -ceq $rightBytes) $Message
+}
+
+try {
+ New-Item -ItemType Directory -Force $pluginScripts | Out-Null
+ Copy-Item -LiteralPath (Join-Path $repositoryRoot "scripts/fetch-herdr-annotate.ps1") -Destination $fetcher
+ Copy-Item -LiteralPath (Join-Path $repositoryRoot "herdr-annotate.version") -Destination $pluginRoot
+
+ $sourceDirectory = Join-Path $testRoot "synthetic source with spaces"
+ $source = Join-Path $sourceDirectory "herdr-annotate local.exe"
+ New-Item -ItemType Directory -Force $sourceDirectory | Out-Null
+ Set-Content -LiteralPath $source -NoNewline -Value "local override bytes"
+ New-Item -ItemType Directory -Force (Split-Path -Parent $destination) | Out-Null
+ Set-Content -LiteralPath $destination -NoNewline -Value "old destination bytes"
+ Set-Content -LiteralPath $stamp -NoNewline -Value "old-version"
+
+ $env:HERDR_ANNOTATE_BIN = $source
+ $result = Invoke-Fetcher
+ Assert-True ($result.ExitCode -eq 0) "local override failed: $($result.Output)"
+ Assert-BytesEqual $source $destination "local override bytes differ"
+ Assert-True ((Get-Content -LiteralPath $stamp -Raw) -ceq $version) "local stamp differs"
+
+ $env:HERDR_ANNOTATE_BIN = $null
+ $beforeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash
+ $result = Invoke-Fetcher
+ Assert-True ($result.ExitCode -eq 0) "idempotent run failed: $($result.Output)"
+ Assert-True ($result.Output -match "already installed") "idempotent run did not short-circuit: $($result.Output)"
+ Assert-True (
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash
+ ) "idempotent run replaced the destination"
+
+ # A missing local build is fatal here: the annotation tools are this binary.
+ $env:HERDR_ANNOTATE_BIN = Join-Path $testRoot "missing explicit override.exe"
+ $result = Invoke-Fetcher
+ Assert-True ($result.ExitCode -ne 0) "missing explicit override exited successfully"
+ Assert-True ($result.Output -match "HERDR_ANNOTATE_BIN is not a file") "missing override error differs: $($result.Output)"
+ Assert-True (
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash
+ ) "missing override changed the destination"
+
+ $env:HERDR_ANNOTATE_BIN = $null
+ Set-Content -LiteralPath (Join-Path $pluginRoot "herdr-annotate.version") -NoNewline -Value ""
+ $result = Invoke-Fetcher
+ Assert-True ($result.ExitCode -ne 0) "empty version pin exited successfully"
+ Assert-True ($result.Output -match "herdr-annotate.version is empty") "empty pin error differs: $($result.Output)"
+ Assert-True (
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $destination).Hash -ceq $beforeHash
+ ) "empty pin changed the destination"
+} finally {
+ if ($null -eq $oldLocalOverride) {
+ $env:HERDR_ANNOTATE_BIN = $null
+ } else {
+ $env:HERDR_ANNOTATE_BIN = $oldLocalOverride
+ }
+ Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+exit 0
diff --git a/scripts/test-fetch-herdr-annotate.sh b/scripts/test-fetch-herdr-annotate.sh
new file mode 100755
index 0000000..e282df1
--- /dev/null
+++ b/scripts/test-fetch-herdr-annotate.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# The native Lite fetcher: local override, idempotence, and the two fatal inputs. Unlike the
+# plannotator-tui fetcher, a failure here is fatal: the annotation tools are the binary.
+set -euo pipefail
+
+repository_root="$(cd "$(dirname "$0")/.." && pwd)"
+test_root="$(mktemp -d)"
+trap 'rm -rf "$test_root"' EXIT
+plugin_root="$test_root/plugin root with spaces"
+source_root="$test_root/source binary with spaces"
+mkdir -p "$plugin_root/scripts" "$plugin_root/bin" "$source_root"
+cp "$repository_root/scripts/fetch-herdr-annotate.sh" "$plugin_root/scripts/"
+cp "$repository_root/herdr-annotate.version" "$plugin_root/"
+version="$(tr -d '[:space:]' < "$repository_root/herdr-annotate.version")"
+
+source_binary="$source_root/herdr-annotate local"
+cat > "$source_binary" < "$plugin_root/bin/herdr-annotate.exe"
+chmod +x "$plugin_root/bin/herdr-annotate.exe"
+printf 'old-version' > "$plugin_root/bin/herdr-annotate.version"
+
+HERDR_ANNOTATE_BIN="$source_binary" bash "$plugin_root/scripts/fetch-herdr-annotate.sh"
+cmp "$source_binary" "$plugin_root/bin/herdr-annotate.exe"
+test "$(cat "$plugin_root/bin/herdr-annotate.version")" = "$version"
+test ! -e "$plugin_root/bin/herdr-annotate"
+test "$("$plugin_root/bin/herdr-annotate.exe" --version)" = "herdr-annotate $version"
+
+output="$(bash "$plugin_root/scripts/fetch-herdr-annotate.sh")"
+case "$output" in
+ *"already installed"*) ;;
+ *) echo "idempotent fetch did not short-circuit: $output" >&2; exit 1 ;;
+esac
+
+before="$(cat "$plugin_root/bin/herdr-annotate.exe")"
+if HERDR_ANNOTATE_BIN="$test_root/missing override" bash "$plugin_root/scripts/fetch-herdr-annotate.sh" 2>"$test_root/missing.err"; then
+ echo "a missing local override exited successfully" >&2; exit 1
+fi
+grep -q "HERDR_ANNOTATE_BIN is not executable" "$test_root/missing.err"
+test "$(cat "$plugin_root/bin/herdr-annotate.exe")" = "$before"
+
+printf '' > "$plugin_root/herdr-annotate.version"
+if bash "$plugin_root/scripts/fetch-herdr-annotate.sh" 2>"$test_root/empty.err"; then
+ echo "an empty version pin exited successfully" >&2; exit 1
+fi
+grep -q "herdr-annotate.version is empty" "$test_root/empty.err"
+test "$(cat "$plugin_root/bin/herdr-annotate.exe")" = "$before"
diff --git a/scripts/test-windows-full-manifest.py b/scripts/test-windows-full-manifest.py
index 833bbff..75a2daf 100644
--- a/scripts/test-windows-full-manifest.py
+++ b/scripts/test-windows-full-manifest.py
@@ -9,8 +9,23 @@
PROGRAM = "./bin/plannotator-tui.exe"
+NATIVE_PROGRAM = "./bin/herdr-annotate.exe"
FULL_PLATFORMS = {"macos", "linux"}
UNIX_BUILD = ["bash", "scripts/fetch-plannotator-tui.sh"]
+NATIVE_UNIX_BUILD = ["bash", "scripts/fetch-herdr-annotate.sh"]
+NATIVE_WINDOWS_BUILD = [
+ "powershell.exe",
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ "scripts/fetch-herdr-annotate.ps1",
+]
+NATIVE_ENTRIES = {
+ "actions": ("capture", "copy-context", "copy-archive", "manage"),
+ "panes": ("editor", "manager"),
+}
DISTRIBUTED_PANE = [
"sh",
"-c",
@@ -90,16 +105,41 @@ def check_top_level_windows(path: Path, manifest: dict[str, object]) -> None:
fail(path, "top-level platforms must be macOS, Linux, and Windows")
-def check_distributed(path: Path, version_path: Path) -> None:
+def check_native_lite(path: Path, manifest: dict[str, object]) -> None:
+ """The Lite tools reach Windows too: one native argv, no shell, no platform gate."""
+ for table, identifiers in NATIVE_ENTRIES.items():
+ for identifier in identifiers:
+ item = entry(path, manifest, table, identifier)
+ command = item.get("command")
+ if command != [NATIVE_PROGRAM, identifier]:
+ fail(path, f"unexpected {table}.{identifier} argv: {command!r}")
+ if "platforms" in item:
+ fail(path, f"{table}.{identifier} is gated to {item['platforms']!r}")
+ if any("$" in argument for argument in command):
+ fail(path, f"interpolation found in {table}.{identifier}: {command!r}")
+
+
+def check_distributed(path: Path, version_path: Path, native_version_path: Path) -> None:
manifest = load(path)
check_top_level_windows(path, manifest)
+ check_native_lite(path, manifest)
if version_path.read_text(encoding="utf-8").strip() != "0.8.0":
fail(version_path, "plannotator-tui.version is not 0.8.0")
+ if native_version_path.read_text(encoding="utf-8").strip() != "0.1.0":
+ fail(native_version_path, "herdr-annotate.version is not 0.1.0")
build_entries = builds(path, manifest)
- if len(build_entries) != 1:
- fail(path, f"expected one Full build, found {len(build_entries)}")
- build = build_entries[0]
+ if len(build_entries) != 3:
+ fail(path, f"expected three Full builds, found {len(build_entries)}")
+ native_unix, native_windows, build = build_entries
+ if platforms(path, manifest, native_unix) != FULL_PLATFORMS:
+ fail(path, f"native Unix build platforms are {platforms(path, manifest, native_unix)!r}")
+ if native_unix.get("command") != NATIVE_UNIX_BUILD:
+ fail(path, f"unexpected native Unix build argv: {native_unix.get('command')!r}")
+ if platforms(path, manifest, native_windows) != {"windows"}:
+ fail(path, "the native PowerShell build is not Windows-only")
+ if native_windows.get("command") != NATIVE_WINDOWS_BUILD:
+ fail(path, f"unexpected native Windows build argv: {native_windows.get('command')!r}")
if platforms(path, manifest, build) != FULL_PLATFORMS:
fail(path, f"Full build platforms are {platforms(path, manifest, build)!r}")
if build.get("command") != UNIX_BUILD:
@@ -166,7 +206,11 @@ def main() -> None:
if len(sys.argv) > 2:
raise SystemExit("usage: test-windows-full-manifest.py [development-manifest]")
root = Path(__file__).resolve().parent.parent
- check_distributed(root / "herdr-plugin.toml", root / "plannotator-tui.version")
+ check_distributed(
+ root / "herdr-plugin.toml",
+ root / "plannotator-tui.version",
+ root / "herdr-annotate.version",
+ )
if len(sys.argv) == 2:
check_development(Path(sys.argv[1]))
diff --git a/src/archive-workflow.ts b/src/archive-workflow.ts
deleted file mode 100644
index 487f617..0000000
--- a/src/archive-workflow.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import type { ClipboardResult } from "./clipboard";
-import { formatAnnotations } from "./format";
-import { newestFirstAnnotations, type StoreResult } from "./store";
-import type { Annotation, ArchivedAnnotationSet } from "./types";
-
-type ClipboardWriter = (text: string) => ClipboardResult;
-
-interface CopyAndArchiveDependencies {
- readonly loadActive: () => StoreResult;
- readonly writeClipboard: ClipboardWriter;
- readonly saveArchive: (archive: ArchivedAnnotationSet) => StoreResult;
- readonly removeActive: (annotationIds: readonly string[]) => StoreResult;
- readonly createArchiveId: () => string;
- readonly now: () => string;
-}
-
-interface RestoreArchiveDependencies {
- readonly mergeActive: (annotations: readonly Annotation[]) => StoreResult;
- readonly removeArchive: (archiveId: string) => StoreResult;
-}
-
-/** The manager transition produced by a copy-and-archive attempt. */
-export type CopyAndArchiveOutcome =
- | { readonly _tag: "close"; readonly archivedCount: number }
- | { readonly _tag: "stay_open"; readonly message: string }
- | { readonly _tag: "archived_active_retained"; readonly message: string };
-
-/** Copy the active annotations, persist a recoverable archive, then remove those active IDs. */
-export function copyAndArchiveAnnotations(
- dependencies: CopyAndArchiveDependencies,
-): CopyAndArchiveOutcome {
- const loaded = dependencies.loadActive();
- if (!loaded.ok) return { _tag: "stay_open", message: loaded.message };
- if (loaded.value.length === 0) {
- return { _tag: "stay_open", message: "Nothing to copy and archive." };
- }
-
- const copied = dependencies.writeClipboard(
- formatAnnotations(newestFirstAnnotations(loaded.value)),
- );
- if (!copied.ok) return { _tag: "stay_open", message: copied.message };
-
- const archive: ArchivedAnnotationSet = {
- version: 1,
- id: dependencies.createArchiveId(),
- archivedAt: dependencies.now(),
- annotations: loaded.value,
- };
- const saved = dependencies.saveArchive(archive);
- if (!saved.ok) return { _tag: "stay_open", message: saved.message };
-
- const removed = dependencies.removeActive(loaded.value.map((annotation) => annotation.id));
- if (!removed.ok) {
- return { _tag: "archived_active_retained", message: removed.message };
- }
-
- return { _tag: "close", archivedCount: loaded.value.length };
-}
-
-/** The manager transition produced by restoring one archived annotation set. */
-export type RestoreArchivedSetOutcome =
- | { readonly _tag: "restored"; readonly restoredCount: number }
- | { readonly _tag: "stay_open"; readonly message: string }
- | {
- readonly _tag: "restored_archive_retained";
- readonly restoredCount: number;
- readonly message: string;
- };
-
-/** Merge an archived set into active annotations, then remove its recoverable archive record. */
-export function restoreArchivedSet(
- archive: ArchivedAnnotationSet,
- dependencies: RestoreArchiveDependencies,
-): RestoreArchivedSetOutcome {
- const merged = dependencies.mergeActive(archive.annotations);
- if (!merged.ok) return { _tag: "stay_open", message: merged.message };
-
- const removed = dependencies.removeArchive(archive.id);
- if (!removed.ok) {
- return {
- _tag: "restored_archive_retained",
- restoredCount: merged.value,
- message: removed.message,
- };
- }
-
- return { _tag: "restored", restoredCount: merged.value };
-}
diff --git a/src/capture.ts b/src/capture.ts
deleted file mode 100644
index 3e2d196..0000000
--- a/src/capture.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-#!/usr/bin/env bun
-import fs from "node:fs";
-import path from "node:path";
-import { notify, runHerdr } from "./herdr";
-import { normalizeWindowsPath, pluginRoot, stateDir } from "./paths";
-import {
- parseInvocationContext,
- selectedTextFromInvocation,
- type PendingAnnotation,
-} from "./types";
-
-try {
- let context = parseInvocationContext(undefined);
- let selectedText: string | undefined;
- try {
- const decoded: unknown = JSON.parse(process.env.HERDR_PLUGIN_CONTEXT_JSON ?? "{}");
- context = parseInvocationContext(decoded);
- selectedText = selectedTextFromInvocation(decoded);
- } catch {}
-
- const dir = stateDir();
- if (!dir) throw new Error("HERDR_PLUGIN_STATE_DIR is not set");
- const root = pluginRoot();
- if (!root) throw new Error("HERDR_PLUGIN_ROOT is not set");
-
- if (!selectedText) {
- // A program on the server (Neovim's mapping) may have handed the selection over in a
- // file; that beats the clipboard, which a headless server does not have.
- const { takeHandoff } = await import("./handoff");
- selectedText = takeHandoff();
- }
- if (!selectedText) {
- const { readClipboard } = await import("./clipboard");
- const clipboard = readClipboard();
- if (!clipboard.ok) throw new Error(clipboard.message);
- selectedText = clipboard.value;
- }
- if (!selectedText.trim()) {
- notify("Nothing to annotate", "Select text in Herdr or copy text to the clipboard.");
- process.exit(0);
- }
- fs.mkdirSync(dir, { recursive: true });
-
- const pending: PendingAnnotation = {
- selectedText,
- context,
- capturedAt: new Date().toISOString(),
- };
- const pendingPath = normalizeWindowsPath(
- path.join(dir, `pending-${Date.now()}-${process.pid}.json`),
- );
- fs.writeFileSync(pendingPath, `${JSON.stringify(pending)}\n`, { mode: 0o600 });
-
- const opened = runHerdr([
- "plugin",
- "pane",
- "open",
- "--cwd",
- root,
- "--plugin",
- "annotate",
- "--entrypoint",
- "editor",
- "--placement",
- "popup",
- "--width",
- "88",
- "--height",
- "24",
- "--env",
- `HERDR_ANNOTATE_PENDING=${pendingPath}`,
- "--focus",
- ]);
- if (!opened.ok) {
- fs.rmSync(pendingPath, { force: true });
- throw new Error(opened.message);
- }
-} catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- notify("Annotate failed", message);
- console.error(message);
- process.exit(1);
-}
diff --git a/src/clipboard.ts b/src/clipboard.ts
deleted file mode 100644
index 6d4f1b9..0000000
--- a/src/clipboard.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { spawnSync } from "node:child_process";
-
-interface ClipboardCommand {
- command: string;
- args: string[];
-}
-
-/** The result of an expected clipboard operation. */
-export type ClipboardResult =
- | { readonly ok: true; readonly value: T }
- | { readonly ok: false; readonly message: string };
-
-function readCommands(): ClipboardCommand[] {
- if (process.platform === "darwin") return [{ command: "pbpaste", args: [] }];
- if (process.platform === "win32") {
- return [
- {
- command: "powershell.exe",
- args: ["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"],
- },
- ];
- }
- return [
- { command: "wl-paste", args: ["--no-newline"] },
- { command: "xclip", args: ["-selection", "clipboard", "-out"] },
- { command: "xsel", args: ["--clipboard", "--output"] },
- ];
-}
-
-function writeCommands(): ClipboardCommand[] {
- if (process.platform === "darwin") return [{ command: "pbcopy", args: [] }];
- if (process.platform === "win32") {
- return [
- {
- command: "powershell.exe",
- args: ["-NoProfile", "-NonInteractive", "-Command", "$input | Set-Clipboard"],
- },
- ];
- }
- return [
- { command: "wl-copy", args: [] },
- { command: "xclip", args: ["-selection", "clipboard", "-in"] },
- { command: "xsel", args: ["--clipboard", "--input"] },
- ];
-}
-
-/** Read text from the first clipboard adapter available on the current platform. */
-export function readClipboard(): ClipboardResult {
- for (const candidate of readCommands()) {
- const result = spawnSync(candidate.command, candidate.args, {
- encoding: "utf8",
- stdio: ["ignore", "pipe", "ignore"],
- windowsHide: process.platform === "win32",
- });
- if (result.status === 0 && typeof result.stdout === "string") {
- return { ok: true, value: result.stdout };
- }
- }
- return { ok: false, message: "No supported clipboard reader is available" };
-}
-
-/** Write text through the first clipboard adapter available on the current platform. */
-export function writeClipboard(text: string): ClipboardResult {
- for (const candidate of writeCommands()) {
- const result = spawnSync(candidate.command, candidate.args, {
- input: text,
- encoding: "utf8",
- stdio: ["pipe", "ignore", "ignore"],
- windowsHide: process.platform === "win32",
- });
- if (result.status === 0) return { ok: true, value: undefined };
- }
- return { ok: false, message: "No supported clipboard writer is available" };
-}
diff --git a/src/edit-keys.ts b/src/edit-keys.ts
deleted file mode 100644
index 3c1216e..0000000
--- a/src/edit-keys.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import type readline from "node:readline";
-
-export type EditAction =
- | "word-left"
- | "word-right"
- | "line-start"
- | "line-end"
- | "delete-word"
- | "delete-line"
- | null;
-
-const isWordChar = (char: string | undefined): boolean =>
- char !== undefined && char !== "\n" && !/\s/.test(char);
-
-/** Index of the start of the word before `cursor` (readline backward-word). */
-export function wordStart(chars: readonly string[], cursor: number): number {
- let index = Math.min(cursor, chars.length);
- while (index > 0 && !isWordChar(chars[index - 1]) && chars[index - 1] !== "\n") index -= 1;
- if (index > 0 && chars[index - 1] === "\n" && index === cursor) return index - 1;
- while (index > 0 && isWordChar(chars[index - 1])) index -= 1;
- return index;
-}
-
-/** Index of the end of the word after `cursor` (readline forward-word). */
-export function wordEnd(chars: readonly string[], cursor: number): number {
- let index = Math.max(0, cursor);
- while (index < chars.length && !isWordChar(chars[index]) && chars[index] !== "\n") index += 1;
- if (index < chars.length && chars[index] === "\n" && index === cursor) return index + 1;
- while (index < chars.length && isWordChar(chars[index])) index += 1;
- return index;
-}
-
-export function lineStart(chars: readonly string[], cursor: number): number {
- let index = cursor;
- while (index > 0 && chars[index - 1] !== "\n") index -= 1;
- return index;
-}
-
-export function lineEnd(chars: readonly string[], cursor: number): number {
- let index = cursor;
- while (index < chars.length && chars[index] !== "\n") index += 1;
- return index;
-}
-
-/**
- * Map a readline keypress to an editing action beyond single-character moves.
- *
- * Terminals encode Option/Alt as xterm modifier 3 and Command/Super as 9; Node's
- * readline folds both into `key.meta`, so the raw sequence tells them apart.
- * Ghostty (and most macOS terminals) rewrite Cmd+Backspace to Ctrl+U and
- * Opt+Backspace to Ctrl+W, matching the readline line/word kill bindings.
- */
-export function resolveEditKey(key: readline.Key): EditAction {
- const sequence = key.sequence ?? "";
- const superModifier = /;9[A-Z~]$/.test(sequence);
-
- if (key.ctrl && key.name === "u") return "delete-line";
- if (key.ctrl && key.name === "w") return "delete-word";
- if (key.meta && key.name === "backspace") return "delete-word";
- if (key.ctrl && key.name === "a") return "line-start";
- if (key.ctrl && key.name === "e") return "line-end";
-
- if (key.meta && key.name === "b") return "word-left";
- if (key.meta && key.name === "f") return "word-right";
-
- if (key.name === "left" && (key.meta || key.ctrl)) return superModifier ? "line-start" : "word-left";
- if (key.name === "right" && (key.meta || key.ctrl)) return superModifier ? "line-end" : "word-right";
-
- return null;
-}
diff --git a/src/editor.ts b/src/editor.ts
deleted file mode 100644
index d05a744..0000000
--- a/src/editor.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-#!/usr/bin/env bun
-import crypto from "node:crypto";
-import fs from "node:fs";
-import readline from "node:readline";
-import { sanitizeTerminalText, wrapText } from "./format";
-import { stateDir } from "./paths";
-import { layoutComment } from "./layout";
-import { lineEnd, lineStart, resolveEditKey, wordEnd, wordStart } from "./edit-keys";
-import { charWidth, stringWidth, truncateToWidth } from "./width";
-import type { StoreResult } from "./store";
-import {
- parsePendingAnnotation,
- pendingAnnotationFromInvocation,
- type Annotation,
- type PendingAnnotation,
-} from "./types";
-
-const pendingPath = process.env.HERDR_ANNOTATE_PENDING;
-let pending: PendingAnnotation;
-
-function invocationContext(): unknown {
- try {
- return JSON.parse(process.env.HERDR_PLUGIN_CONTEXT_JSON ?? "{}");
- } catch {
- return {};
- }
-}
-
-try {
- const invocation = invocationContext();
- if (!pendingPath) {
- const fallback = pendingAnnotationFromInvocation(invocation);
- if (!fallback) throw new Error("Missing pending annotation");
- pending = fallback;
- } else {
- const decoded: unknown = JSON.parse(fs.readFileSync(pendingPath, "utf8"));
- const parsed = parsePendingAnnotation(decoded);
- if (!parsed) throw new Error("Pending annotation is invalid");
- pending = parsed;
- fs.rmSync(pendingPath, { force: true });
- }
-} catch (error) {
- console.error(error instanceof Error ? error.message : String(error));
- process.exit(1);
-}
-
-const out = (value: string) => process.stdout.write(value);
-const comment: string[] = [];
-let cursor = 0;
-let status = "";
-let finished = false;
-
-function moveCursorVertical(delta: number): void {
- const before = comment.slice(0, cursor).join("");
- const lines = before.split("\n");
- const row = lines.length - 1;
- const col = stringWidth(lines[row] ?? "");
- const allLines = comment.join("").split("\n");
- const targetRow = Math.max(0, Math.min(allLines.length - 1, row + delta));
- let next = 0;
- for (let index = 0; index < targetRow; index += 1) next += Array.from(allLines[index] as string).length + 1;
- // Land on the character whose cell column is closest to the current one.
- let offset = 0;
- let used = 0;
- for (const char of allLines[targetRow] as string) {
- const width = charWidth(char);
- if (used + width > col) break;
- used += width;
- offset += 1;
- }
- cursor = next + offset;
-}
-
-function writeAt(row: number, col: number, text: string): void {
- out(`\x1b[${row};${col}H${text}`);
-}
-
-function render(): void {
- const cols = Math.max(20, process.stdout.columns || 86);
- const rows = Math.max(10, process.stdout.rows || 22);
- const left = 3;
- const innerWidth = Math.max(1, cols - 4);
- const selectionRows = Math.max(3, Math.min(7, Math.floor((rows - 6) / 2)));
- const editorRows = Math.max(1, rows - selectionRows - 5);
- const wrappedSelection = wrapText(sanitizeTerminalText(pending.selectedText), innerWidth);
- const selected = wrappedSelection.slice(0, selectionRows);
- const editing = layoutComment(comment, cursor, innerWidth);
- const editorStart = Math.max(0, editing.cursorRow - editorRows + 1);
- const visibleEditor = editing.lines.slice(editorStart, editorStart + editorRows);
-
- out("\x1b[2J\x1b[H\x1b[?25l");
- writeAt(2, left, "\x1b[1mSelected text\x1b[0m");
- selected.forEach((line, index) => writeAt(3 + index, left, `\x1b[2m${line}\x1b[0m`));
- if (wrappedSelection.length > selectionRows) {
- writeAt(2 + selectionRows, left + innerWidth - 1, "\x1b[2m…\x1b[0m");
- }
-
- const commentTitleRow = 3 + selectionRows;
- writeAt(commentTitleRow, left, "\x1b[1mComment\x1b[0m");
- visibleEditor.forEach((line, index) => writeAt(commentTitleRow + 1 + index, left, line));
-
- const footer = status || "Ctrl+S save · Esc cancel · Enter new line";
- writeAt(rows, left, `\x1b[2m${truncateToWidth(footer, innerWidth)}\x1b[0m`);
-
- const visualCursorRow = editing.cursorRow - editorStart;
- if (visualCursorRow >= 0 && visualCursorRow < editorRows) {
- writeAt(commentTitleRow + 1 + visualCursorRow, left + editing.cursorCol, "\x1b[?25h");
- }
-}
-
-function cleanup(): void {
- if (finished) return;
- finished = true;
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
- out("\x1b[?25h\x1b[2J\x1b[H\x1b[?1049l");
-}
-
-function exit(code: number): void {
- cleanup();
- process.exit(code);
-}
-
-async function save(): Promise {
- const value = comment.join("").trim();
- if (!value) {
- status = "Write a comment before saving.";
- render();
- return;
- }
- const dir = stateDir();
- if (!dir) {
- status = "Plugin state directory is unavailable.";
- render();
- return;
- }
- const annotation: Annotation = {
- ...pending,
- id: crypto.randomUUID(),
- comment: value,
- createdAt: new Date().toISOString(),
- };
- let saved: StoreResult;
- try {
- const { appendAnnotation } = await import("./store");
- saved = appendAnnotation(dir, annotation);
- } catch {
- status = "Unable to save annotation.";
- render();
- return;
- }
- if (!saved.ok) {
- status = saved.message;
- render();
- return;
- }
- status = "Saved.";
- render();
- setTimeout(() => exit(0), 250);
-}
-
-process.on("exit", cleanup);
-process.on("SIGTERM", () => exit(0));
-try {
- process.on("SIGHUP", () => exit(0));
-} catch {
- // SIGHUP is not available on every supported platform.
-}
-process.stdout.on("resize", render);
-
-readline.emitKeypressEvents(process.stdin, { escapeCodeTimeout: 20 } as any);
-if (process.stdin.isTTY) process.stdin.setRawMode(true);
-process.stdin.resume();
-process.stdin.on("keypress", (text: string, key: readline.Key) => {
- status = "";
- if (key.ctrl && key.name === "c") return exit(0);
- if (key.ctrl && key.name === "s") {
- void save();
- return;
- }
- if (key.name === "escape") return exit(0);
- const action = resolveEditKey(key);
- if (action === "word-left") {
- cursor = wordStart(comment, cursor);
- } else if (action === "word-right") {
- cursor = wordEnd(comment, cursor);
- } else if (action === "line-start") {
- cursor = lineStart(comment, cursor);
- } else if (action === "line-end") {
- cursor = lineEnd(comment, cursor);
- } else if (action === "delete-word") {
- const start = wordStart(comment, cursor);
- comment.splice(start, cursor - start);
- cursor = start;
- } else if (action === "delete-line") {
- const start = lineStart(comment, cursor);
- comment.splice(start, cursor - start);
- cursor = start;
- } else if (key.name === "backspace") {
- if (cursor > 0) comment.splice(--cursor, 1);
- } else if (key.name === "delete") {
- if (cursor < comment.length) comment.splice(cursor, 1);
- } else if (key.name === "left") {
- cursor = Math.max(0, cursor - 1);
- } else if (key.name === "right") {
- cursor = Math.min(comment.length, cursor + 1);
- } else if (key.name === "up") {
- moveCursorVertical(-1);
- } else if (key.name === "down") {
- moveCursorVertical(1);
- } else if (key.name === "home") {
- while (cursor > 0 && comment[cursor - 1] !== "\n") cursor -= 1;
- } else if (key.name === "end") {
- while (cursor < comment.length && comment[cursor] !== "\n") cursor += 1;
- } else if (key.name === "return") {
- comment.splice(cursor, 0, "\n");
- cursor += 1;
- } else if (text && !key.ctrl && !key.meta) {
- const inserted = Array.from(text);
- comment.splice(cursor, 0, ...inserted);
- cursor += inserted.length;
- }
- render();
-});
-
-out("\x1b[?1049h");
-render();
diff --git a/src/export-archive.ts b/src/export-archive.ts
deleted file mode 100644
index 37d4a2c..0000000
--- a/src/export-archive.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/usr/bin/env bun
-import crypto from "node:crypto";
-import { copyAndArchiveAnnotations, type CopyAndArchiveOutcome } from "./archive-workflow";
-import { writeClipboard } from "./clipboard";
-import { notify } from "./herdr";
-import { appendArchivedSet, loadAnnotations, removeAnnotationsById } from "./store";
-
-/** The notification and exit status one copy-and-archive action reports. */
-export interface CopyArchiveReport {
- readonly title: string;
- readonly body: string;
- readonly failure: boolean;
-}
-
-/**
- * Map one copy-and-archive outcome to the action's notification and exit status.
- *
- * `loadedEmpty` separates the nothing-to-do case from a real failure: both are `stay_open`,
- * but an empty store is reported like `export.ts` and exits successfully.
- */
-export function copyArchiveReport(
- outcome: CopyAndArchiveOutcome,
- loadedEmpty: boolean,
-): CopyArchiveReport {
- if (outcome._tag === "close") {
- return {
- title: "Annotations copied and archived",
- body: `${outcome.archivedCount} annotation${outcome.archivedCount === 1 ? "" : "s"} copied as Markdown and archived.`,
- failure: false,
- };
- }
- if (outcome._tag === "archived_active_retained") {
- return {
- title: "Copy and archive incomplete",
- body: `Copied and archived, but active annotations remain: ${outcome.message}`,
- failure: true,
- };
- }
- if (loadedEmpty) {
- return { title: "No annotations", body: "There is nothing to copy yet.", failure: false };
- }
- return { title: "Copy and archive failed", body: outcome.message, failure: true };
-}
-
-function main(): void {
- const dir = process.env.HERDR_PLUGIN_STATE_DIR;
- if (!dir) {
- const message = "HERDR_PLUGIN_STATE_DIR is not set";
- notify("Copy and archive failed", message);
- console.error(message);
- process.exit(1);
- }
-
- let loadedEmpty = false;
- const outcome = copyAndArchiveAnnotations({
- loadActive: () => {
- const loaded = loadAnnotations(dir);
- if (loaded.ok && loaded.value.length === 0) loadedEmpty = true;
- return loaded;
- },
- writeClipboard,
- saveArchive: (archive) => appendArchivedSet(dir, archive),
- removeActive: (annotationIds) => removeAnnotationsById(dir, annotationIds),
- createArchiveId: () => crypto.randomUUID(),
- now: () => new Date().toISOString(),
- });
-
- const report = copyArchiveReport(outcome, loadedEmpty);
- notify(report.title, report.body);
- if (report.failure) {
- console.error(report.body);
- process.exit(1);
- }
-}
-
-if (import.meta.main) main();
diff --git a/src/export.ts b/src/export.ts
deleted file mode 100644
index a4d6c1f..0000000
--- a/src/export.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/usr/bin/env bun
-import { writeClipboard } from "./clipboard";
-import { formatAnnotations } from "./format";
-import { notify } from "./herdr";
-import { loadAnnotations, newestFirstAnnotations } from "./store";
-
-try {
- const dir = process.env.HERDR_PLUGIN_STATE_DIR;
- if (!dir) throw new Error("HERDR_PLUGIN_STATE_DIR is not set");
- const loaded = loadAnnotations(dir);
- if (!loaded.ok) throw new Error(loaded.message);
- const annotations = newestFirstAnnotations(loaded.value);
-
- if (annotations.length === 0) {
- notify("No annotations", "There is nothing to copy yet.");
- process.exit(0);
- }
-
- const copied = writeClipboard(formatAnnotations(annotations));
- if (!copied.ok) throw new Error(copied.message);
- notify(
- "Annotations copied",
- `${annotations.length} annotation${annotations.length === 1 ? "" : "s"} copied as Markdown.`,
- );
-} catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- notify("Copy failed", message);
- console.error(message);
- process.exit(1);
-}
diff --git a/src/format.ts b/src/format.ts
deleted file mode 100644
index 3d9571f..0000000
--- a/src/format.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import type { Annotation } from "./types";
-import { charWidth } from "./width";
-
-/** Remove terminal control characters while retaining useful whitespace. */
-export function sanitizeTerminalText(text: string): string {
- return text
- .replace(/\t/g, " ")
- .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
-}
-
-/** Wrap plain text to terminal-cell-width lines while preserving explicit newlines. */
-export function wrapText(text: string, width: number): string[] {
- const safeWidth = Math.max(1, width);
- const output: string[] = [];
- for (const sourceLine of text.replace(/\r\n/g, "\n").split("\n")) {
- const chars = Array.from(sourceLine);
- if (chars.length === 0) {
- output.push("");
- continue;
- }
- let line = "";
- let used = 0;
- for (const char of chars) {
- const width = charWidth(char);
- if (used + width > safeWidth && line !== "") {
- output.push(line);
- line = "";
- used = 0;
- }
- line += char;
- used += width;
- }
- output.push(line);
- }
- return output;
-}
-
-function fenceFor(text: string): string {
- const longest = Math.max(0, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
- return "`".repeat(Math.max(3, longest + 1));
-}
-
-/** Format saved annotations as portable, agent-neutral Markdown context. */
-export function formatAnnotations(annotations: readonly Annotation[]): string {
- const sections = annotations.map((annotation, index) => {
- const source = [annotation.context.workspace_label, annotation.context.tab_label]
- .filter(Boolean)
- .join(" / ");
- const fence = fenceFor(annotation.selectedText);
- const metadata = source ? `\nSource: ${source}\n` : "";
- return [
- `## Annotation ${index + 1}`,
- metadata,
- "Selected text:",
- "",
- fence,
- annotation.selectedText,
- fence,
- "",
- "Comment:",
- "",
- annotation.comment,
- ]
- .filter((line, lineIndex, lines) => !(line === "" && lines[lineIndex - 1] === ""))
- .join("\n");
- });
- return `# Annotated context\n\n${sections.join("\n\n")}\n`;
-}
diff --git a/src/handoff.ts b/src/handoff.ts
deleted file mode 100644
index 88e3f34..0000000
--- a/src/handoff.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import fs from "node:fs";
-import os from "node:os";
-import path from "node:path";
-
-/**
- * A selection handed to `annotate.capture` by another program on the same machine as the
- * Herdr server (Neovim, a script), for hosts where the plugin cannot read a clipboard:
- * headless servers reached over SSH or `herdr --remote`.
- *
- * The writer puts the text in {@link handoffPath} and invokes the action; the action takes
- * the file (reads and deletes it) when it is fresh. Anything older is a leftover, not a
- * selection, and is ignored so a stale file can never be annotated by surprise.
- */
-export const HANDOFF_MAX_AGE_MS = 15_000;
-
-/** `$XDG_RUNTIME_DIR` (per user, tmpfs) when set, else the system temp dir, plus the uid. */
-export function handoffPath(env: NodeJS.ProcessEnv = process.env): string {
- const base = env.XDG_RUNTIME_DIR?.trim() || os.tmpdir();
- const uid = typeof process.getuid === "function" ? String(process.getuid()) : "user";
- return path.join(base, `herdr-annotate-${uid}`, "selection");
-}
-
-/** The handed-off text when the file exists and is fresh; the file is removed either way. */
-export function takeHandoff(
- file: string = handoffPath(),
- now: number = Date.now(),
- maxAgeMs: number = HANDOFF_MAX_AGE_MS,
-): string | undefined {
- let stat: fs.Stats;
- try {
- stat = fs.statSync(file);
- } catch {
- return undefined;
- }
- let text: string | undefined;
- if (stat.isFile() && now - stat.mtimeMs <= maxAgeMs) {
- try {
- text = fs.readFileSync(file, "utf8");
- } catch {
- text = undefined;
- }
- }
- fs.rmSync(file, { force: true });
- return text && text.trim() ? text : undefined;
-}
diff --git a/src/herdr.ts b/src/herdr.ts
deleted file mode 100644
index 8cc6e5d..0000000
--- a/src/herdr.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { spawnSync } from "node:child_process";
-
-const binary = process.env.HERDR_BIN_PATH ?? "herdr";
-
-/** The result of invoking Herdr for an expected plugin operation. */
-export type HerdrResult =
- | { readonly ok: true }
- | { readonly ok: false; readonly message: string };
-
-/** Invoke Herdr synchronously and return a safe error projection on failure. */
-export function runHerdr(args: readonly string[]): HerdrResult {
- const result = spawnSync(binary, args, {
- encoding: "utf8",
- stdio: ["ignore", "ignore", "pipe"],
- windowsHide: process.platform === "win32",
- });
- if (result.status !== 0) {
- return {
- ok: false,
- message: result.stderr?.trim() || `herdr ${args.join(" ")} failed`,
- };
- }
- return { ok: true };
-}
-
-/** Best-effort user notification; failures are intentionally non-fatal. */
-export function notify(title: string, body?: string): void {
- const args = ["notification", "show", title];
- if (body) args.push("--body", body);
- spawnSync(binary, args, {
- encoding: "utf8",
- stdio: ["ignore", "ignore", "pipe"],
- windowsHide: process.platform === "win32",
- });
-}
diff --git a/src/layout.ts b/src/layout.ts
deleted file mode 100644
index fc5ed5e..0000000
--- a/src/layout.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { charWidth } from "./width";
-
-/**
- * Lay the comment out in terminal cells and report where the cursor lands.
- *
- * The cursor column is measured in cells, not characters, so that wide
- * characters do not push the reported position out of step with the screen.
- */
-export function layoutComment(
- comment: readonly string[],
- cursor: number,
- width: number,
-): { lines: string[]; cursorRow: number; cursorCol: number } {
- const safeWidth = Math.max(1, width);
- const lines: string[] = [""];
- let row = 0;
- let col = 0;
- let cursorRow = 0;
- let cursorCol = 0;
- for (let index = 0; index <= comment.length; index += 1) {
- const cells = index < comment.length ? charWidth(comment[index] as string) : 0;
- // A wide character must not straddle the right edge.
- if (col > 0 && col + cells > safeWidth) {
- lines.push("");
- row += 1;
- col = 0;
- }
- if (index === cursor) {
- cursorRow = row;
- cursorCol = col;
- }
- if (index === comment.length) break;
- const char = comment[index] as string;
- if (char === "\n") {
- lines.push("");
- row += 1;
- col = 0;
- continue;
- }
- lines[row] += char;
- col += cells;
- }
- return { lines, cursorRow, cursorCol };
-}
diff --git a/src/manager-copy.ts b/src/manager-copy.ts
deleted file mode 100644
index cc6b44d..0000000
--- a/src/manager-copy.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { ClipboardResult } from "./clipboard";
-import { formatAnnotations } from "./format";
-import type { Annotation } from "./types";
-
-type ClipboardWriter = (text: string) => ClipboardResult;
-
-/** Whether the annotation manager should close or remain visible after a copy attempt. */
-export type ManagerCopyOutcome =
- | { readonly _tag: "close" }
- | { readonly _tag: "stay_open"; readonly message: string };
-
-/** Format and copy annotations without changing the supplied annotations or their store. */
-export function copyAnnotations(
- annotations: readonly Annotation[],
- writeClipboard: ClipboardWriter,
-): ManagerCopyOutcome {
- if (annotations.length === 0) {
- return { _tag: "stay_open", message: "Nothing to copy." };
- }
-
- const copied = writeClipboard(formatAnnotations(annotations));
- if (!copied.ok) {
- return { _tag: "stay_open", message: copied.message };
- }
-
- return { _tag: "close" };
-}
diff --git a/src/manager.ts b/src/manager.ts
deleted file mode 100644
index 24e4b97..0000000
--- a/src/manager.ts
+++ /dev/null
@@ -1,429 +0,0 @@
-#!/usr/bin/env bun
-import crypto from "node:crypto";
-import readline from "node:readline";
-import { copyAndArchiveAnnotations, restoreArchivedSet } from "./archive-workflow";
-import { writeClipboard } from "./clipboard";
-import { sanitizeTerminalText, wrapText } from "./format";
-import { stringWidth, truncateToWidth } from "./width";
-import { copyAnnotations } from "./manager-copy";
-import { emitToTerminal, paneClipboardWriter } from "./pane-clipboard";
-import { stateDir } from "./paths";
-import {
- appendArchivedSet,
- loadAnnotations,
- loadArchivedSets,
- mergeAnnotations,
- newestFirstAnnotations,
- newestFirstArchivedSets,
- removeAnnotationsById,
- removeArchivedSet,
-} from "./store";
-import type { Annotation, ArchivedAnnotationSet } from "./types";
-
-type ManagerView = "active" | "archives";
-
-type Confirmation =
- | { readonly _tag: "none" }
- | { readonly _tag: "clear_active" }
- | { readonly _tag: "delete_archive"; readonly archiveId: string };
-
-function requireStateDir(): string {
- const value = stateDir();
- if (!value) {
- console.error("HERDR_PLUGIN_STATE_DIR is not set");
- process.exit(1);
- }
- return value;
-}
-
-const dir = requireStateDir();
-const out = (value: string) => process.stdout.write(value);
-const writePaneClipboard = paneClipboardWriter(writeClipboard, emitToTerminal);
-let annotations: Annotation[] = [];
-let archives: ArchivedAnnotationSet[] = [];
-let activeSelected = 0;
-let archiveSelected = 0;
-let view: ManagerView = "active";
-let status = "";
-let confirmation: Confirmation = { _tag: "none" };
-let finished = false;
-
-function clampSelection(selected: number, length: number): number {
- return Math.max(0, Math.min(selected, length - 1));
-}
-
-function reloadActive(): boolean {
- const loaded = loadAnnotations(dir);
- if (!loaded.ok) {
- status = loaded.message;
- return false;
- }
- annotations = newestFirstAnnotations(loaded.value);
- activeSelected = clampSelection(activeSelected, annotations.length);
- return true;
-}
-
-function reloadArchives(): boolean {
- const loaded = loadArchivedSets(dir);
- if (!loaded.ok) {
- status = loaded.message;
- return false;
- }
- archives = newestFirstArchivedSets(loaded.value);
- archiveSelected = clampSelection(archiveSelected, archives.length);
- return true;
-}
-
-function clipped(text: string, width: number): string {
- const value = sanitizeTerminalText(text).replace(/\s+/g, " ").trim();
- if (stringWidth(value) <= width) return value;
- return `${truncateToWidth(value, Math.max(0, width - 1))}…`;
-}
-
-function writeAt(row: number, col: number, text: string): void {
- out(`\x1b[${row};${col}H${text}`);
-}
-
-function firstVisibleIndex(selected: number, length: number, visibleRows: number): number {
- return Math.max(0, Math.min(selected - Math.floor(visibleRows / 2), length - visibleRows));
-}
-
-function countLabel(count: number): string {
- return `${count} annotation${count === 1 ? "" : "s"}`;
-}
-
-function renderActive(
- rows: number,
- listWidth: number,
- detailLeft: number,
- detailWidth: number,
- listRows: number,
-): void {
- const first = firstVisibleIndex(activeSelected, annotations.length, listRows);
- writeAt(
- 1,
- 2,
- `\x1b[1mAnnotations (${annotations.length})\x1b[0m \x1b[2mnewest first\x1b[0m`,
- );
-
- if (annotations.length === 0) {
- writeAt(3, 2, "\x1b[2mNo active annotations.\x1b[0m");
- return;
- }
-
- annotations.slice(first, first + listRows).forEach((annotation, index) => {
- const absolute = first + index;
- const active = absolute === activeSelected;
- const label = clipped(annotation.selectedText, listWidth - 4);
- writeAt(2 + index, 2, `${active ? "\x1b[7m›" : " "} ${label}\x1b[0m`);
- });
-
- const current = annotations[activeSelected];
- if (!current) return;
- const source = [current.context.workspace_label, current.context.tab_label]
- .filter(Boolean)
- .join(" / ");
- writeAt(2, detailLeft, "\x1b[1mSelected text\x1b[0m");
- const selectedLines = wrapText(sanitizeTerminalText(current.selectedText), detailWidth).slice(0, 7);
- selectedLines.forEach((line, index) => writeAt(3 + index, detailLeft, `\x1b[2m${line}\x1b[0m`));
- const commentRow = 4 + Math.max(3, selectedLines.length);
- writeAt(commentRow, detailLeft, "\x1b[1mComment\x1b[0m");
- wrapText(sanitizeTerminalText(current.comment), detailWidth)
- .slice(0, Math.max(1, rows - commentRow - 4))
- .forEach((line, index) => writeAt(commentRow + 1 + index, detailLeft, line));
- const metadata = [source, new Date(current.createdAt).toLocaleString()].filter(Boolean).join(" · ");
- if (metadata) writeAt(rows - 2, detailLeft, `\x1b[2m${clipped(metadata, detailWidth)}\x1b[0m`);
-}
-
-function renderArchives(
- rows: number,
- listWidth: number,
- detailLeft: number,
- detailWidth: number,
- listRows: number,
-): void {
- const first = firstVisibleIndex(archiveSelected, archives.length, listRows);
- writeAt(
- 1,
- 2,
- `\x1b[1mArchives (${archives.length})\x1b[0m \x1b[2mnewest first\x1b[0m`,
- );
-
- if (archives.length === 0) {
- writeAt(3, 2, "\x1b[2mNo archived sets.\x1b[0m");
- return;
- }
-
- archives.slice(first, first + listRows).forEach((archive, index) => {
- const absolute = first + index;
- const active = absolute === archiveSelected;
- const label = `${new Date(archive.archivedAt).toLocaleString()} · ${countLabel(archive.annotations.length)}`;
- writeAt(
- 2 + index,
- 2,
- `${active ? "\x1b[7m›" : " "} ${clipped(label, listWidth - 4)}\x1b[0m`,
- );
- });
-
- const current = archives[archiveSelected];
- if (!current) return;
- writeAt(2, detailLeft, "\x1b[1mArchived set\x1b[0m");
- writeAt(3, detailLeft, `\x1b[2m${clipped(new Date(current.archivedAt).toLocaleString(), detailWidth)}\x1b[0m`);
- writeAt(5, detailLeft, `\x1b[1m${countLabel(current.annotations.length)}\x1b[0m`);
- const visibleAnnotations = newestFirstAnnotations(current.annotations);
- const previewRows = Math.max(1, rows - 8);
- visibleAnnotations.slice(0, previewRows).forEach((annotation, index) => {
- const label = `${index + 1}. ${annotation.selectedText}`;
- writeAt(6 + index, detailLeft, clipped(label, detailWidth));
- });
- if (visibleAnnotations.length > previewRows) {
- writeAt(rows - 2, detailLeft, `\x1b[2m… ${visibleAnnotations.length - previewRows} more\x1b[0m`);
- }
-}
-
-function footerText(): string {
- if (confirmation._tag === "clear_active") {
- return "Press Shift+D again to clear all active annotations · Esc cancel";
- }
- if (confirmation._tag === "delete_archive") {
- return "Press d again to permanently delete this archive · Esc cancel";
- }
- if (status) return status;
- if (view === "active") {
- return "j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q";
- }
- return "j/k · y copy · u restore · d twice delete · Tab active · q";
-}
-
-function render(): void {
- const cols = Math.max(50, process.stdout.columns || 98);
- const rows = Math.max(14, process.stdout.rows || 28);
- const listWidth = Math.max(22, Math.min(36, Math.floor(cols * 0.36)));
- const detailLeft = listWidth + 3;
- const detailWidth = Math.max(1, cols - detailLeft - 1);
- const listRows = Math.max(1, rows - 4);
-
- out("\x1b[2J\x1b[H\x1b[?25l");
- for (let row = 2; row < rows; row += 1) {
- writeAt(row, listWidth + 1, "\x1b[2m│\x1b[0m");
- }
- if (view === "active") {
- renderActive(rows, listWidth, detailLeft, detailWidth, listRows);
- } else {
- renderArchives(rows, listWidth, detailLeft, detailWidth, listRows);
- }
- writeAt(rows, 2, `\x1b[2m${clipped(footerText(), cols - 3)}\x1b[0m`);
-}
-
-function copy(items: readonly Annotation[]): void {
- const outcome = copyAnnotations(items, writePaneClipboard);
- if (outcome._tag === "stay_open") {
- status = outcome.message;
- return;
- }
- exit(0);
-}
-
-function copyAndArchive(): void {
- const outcome = copyAndArchiveAnnotations({
- loadActive: () => loadAnnotations(dir),
- writeClipboard: writePaneClipboard,
- saveArchive: (archive) => appendArchivedSet(dir, archive),
- removeActive: (annotationIds) => removeAnnotationsById(dir, annotationIds),
- createArchiveId: () => crypto.randomUUID(),
- now: () => new Date().toISOString(),
- });
- if (outcome._tag === "close") exit(0);
- if (outcome._tag === "archived_active_retained") {
- reloadArchives();
- status = `Copied and archived, but active annotations remain: ${outcome.message}`;
- return;
- }
- status = outcome.message;
-}
-
-function deleteSelectedAnnotation(): void {
- const target = annotations[activeSelected];
- if (!target) return;
- const removed = removeAnnotationsById(dir, [target.id]);
- if (!removed.ok) {
- status = removed.message;
- return;
- }
- if (!reloadActive()) return;
- status = "Annotation deleted.";
-}
-
-function clearActive(): void {
- const removed = removeAnnotationsById(
- dir,
- annotations.map((annotation) => annotation.id),
- );
- if (!removed.ok) {
- status = removed.message;
- return;
- }
- confirmation = { _tag: "none" };
- if (!reloadActive()) return;
- status = "All active annotations cleared.";
-}
-
-function restoreSelectedArchive(): void {
- const target = archives[archiveSelected];
- if (!target) {
- status = "No archive selected.";
- return;
- }
- const outcome = restoreArchivedSet(target, {
- mergeActive: (items) => mergeAnnotations(dir, items),
- removeArchive: (archiveId) => removeArchivedSet(dir, archiveId),
- });
- if (outcome._tag === "stay_open") {
- status = outcome.message;
- return;
- }
- const activeReloaded = reloadActive();
- const archivesReloaded = reloadArchives();
- if (!activeReloaded || !archivesReloaded) return;
- if (outcome._tag === "restored_archive_retained") {
- status = `Annotations restored, but the archive remains: ${outcome.message}`;
- return;
- }
- status = outcome.restoredCount
- ? `${countLabel(outcome.restoredCount)} restored.`
- : "Archive removed; its annotations were already active.";
-}
-
-function deleteSelectedArchive(archiveId: string): void {
- const removed = removeArchivedSet(dir, archiveId);
- if (!removed.ok) {
- status = removed.message;
- return;
- }
- confirmation = { _tag: "none" };
- if (!reloadArchives()) return;
- status = "Archive permanently deleted.";
-}
-
-function switchView(): void {
- confirmation = { _tag: "none" };
- status = "";
- if (view === "active") {
- view = "archives";
- reloadArchives();
- } else {
- view = "active";
- reloadActive();
- }
-}
-
-function handleActiveKey(text: string, key: readline.Key): void {
- if (text === "D") {
- if (confirmation._tag === "clear_active") clearActive();
- else confirmation = { _tag: "clear_active" };
- return;
- }
-
- confirmation = { _tag: "none" };
- if (key.name === "up" || key.name === "k") {
- activeSelected = Math.max(0, activeSelected - 1);
- } else if (key.name === "down" || key.name === "j") {
- activeSelected = Math.min(Math.max(0, annotations.length - 1), activeSelected + 1);
- } else if (text === "C") {
- copyAndArchive();
- } else if (key.name === "y") {
- const current = annotations[activeSelected];
- copy(current ? [current] : []);
- } else if (key.name === "c") {
- copy(annotations);
- } else if (key.name === "d") {
- deleteSelectedAnnotation();
- } else if (key.name === "r") {
- if (reloadActive()) status = "Reloaded.";
- }
-}
-
-function handleArchiveKey(text: string, key: readline.Key): void {
- if (text === "d") {
- const current = archives[archiveSelected];
- if (!current) {
- confirmation = { _tag: "none" };
- status = "No archive selected.";
- } else if (
- confirmation._tag === "delete_archive" &&
- confirmation.archiveId === current.id
- ) {
- deleteSelectedArchive(current.id);
- } else {
- confirmation = { _tag: "delete_archive", archiveId: current.id };
- }
- return;
- }
-
- confirmation = { _tag: "none" };
- if (key.name === "up" || key.name === "k") {
- archiveSelected = Math.max(0, archiveSelected - 1);
- } else if (key.name === "down" || key.name === "j") {
- archiveSelected = Math.min(Math.max(0, archives.length - 1), archiveSelected + 1);
- } else if (key.name === "y") {
- const current = archives[archiveSelected];
- copy(current ? newestFirstAnnotations(current.annotations) : []);
- } else if (key.name === "u") {
- restoreSelectedArchive();
- } else if (key.name === "r") {
- if (reloadArchives()) status = "Reloaded.";
- }
-}
-
-function cleanup(): void {
- if (finished) return;
- finished = true;
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
- out("\x1b[?25h\x1b[2J\x1b[H\x1b[?1049l");
-}
-
-function exit(code: number): never {
- cleanup();
- process.exit(code);
-}
-
-reloadActive();
-reloadArchives();
-process.on("exit", cleanup);
-process.on("SIGTERM", () => exit(0));
-try {
- process.on("SIGHUP", () => exit(0));
-} catch {
- // SIGHUP is not available on every supported platform.
-}
-process.stdout.on("resize", render);
-
-readline.emitKeypressEvents(process.stdin, { escapeCodeTimeout: 20 } as any);
-if (process.stdin.isTTY) process.stdin.setRawMode(true);
-process.stdin.resume();
-process.stdin.on("keypress", (text: string, key: readline.Key) => {
- if (key.ctrl && key.name === "c") return exit(0);
- if (key.name === "escape") {
- if (confirmation._tag !== "none") {
- confirmation = { _tag: "none" };
- status = "";
- render();
- return;
- }
- return exit(0);
- }
- if (key.name === "q") return exit(0);
- if (key.name === "tab") {
- switchView();
- render();
- return;
- }
-
- status = "";
- if (view === "active") handleActiveKey(text, key);
- else handleArchiveKey(text, key);
- render();
-});
-
-out("\x1b[?1049h");
-render();
diff --git a/src/open-manager.ts b/src/open-manager.ts
deleted file mode 100644
index 5d5ebdb..0000000
--- a/src/open-manager.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/usr/bin/env bun
-import { notify, runHerdr } from "./herdr";
-import { pluginRoot } from "./paths";
-
-const root = pluginRoot();
-if (!root) {
- const message = "HERDR_PLUGIN_ROOT is not set";
- notify("Unable to open annotations", message);
- console.error(message);
- process.exit(1);
-}
-
-const opened = runHerdr([
- "plugin",
- "pane",
- "open",
- "--cwd",
- root,
- "--plugin",
- "annotate",
- "--entrypoint",
- "manager",
- "--placement",
- "popup",
- "--width",
- "100",
- "--height",
- "30",
- "--focus",
-]);
-
-if (!opened.ok) {
- notify("Unable to open annotations", opened.message);
- console.error(opened.message);
- process.exit(1);
-}
diff --git a/src/pane-clipboard.ts b/src/pane-clipboard.ts
deleted file mode 100644
index c438cab..0000000
--- a/src/pane-clipboard.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * Clipboard writes performed from inside a Herdr pane.
- *
- * Herdr 0.9.0 forwards OSC 52 sequences emitted by pane output to the viewing client, so a copy made
- * inside a pane can reach the clipboard of the machine the person is sitting at rather than only the
- * machine the plugin runs on. Panes emit the sequence in addition to the native clipboard write; the
- * global actions run with piped stdout and no terminal, so they cannot use this path.
- */
-import type { ClipboardResult } from "./clipboard";
-
-/**
- * Base64 payload size that terminals commonly refuse beyond. Herdr forwards whatever the terminal
- * accepts, so this is advisory only: oversized text is still emitted in full, never truncated.
- */
-export const OSC52_COMMON_PAYLOAD_LIMIT_BYTES = 74994;
-
-/** Build the OSC 52 sequence that sets the terminal clipboard selection to `text`. */
-export function osc52ClipboardSequence(text: string): string {
- return `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`;
-}
-
-/** Whether the encoded payload is larger than terminals commonly accept. */
-export function exceedsCommonOsc52Limit(text: string): boolean {
- return Buffer.from(text, "utf8").toString("base64").length > OSC52_COMMON_PAYLOAD_LIMIT_BYTES;
-}
-
-/**
- * Write the sequence to the pane's terminal, reporting whether it was written.
- *
- * Best effort: a closed or broken stdout must not throw out of a copy.
- */
-export function emitToTerminal(sequence: string): boolean {
- try {
- process.stdout.write(sequence);
- return true;
- } catch {
- return false;
- }
-}
-
-/**
- * Wrap a native clipboard writer so every pane copy also reaches the viewing client's terminal.
- *
- * The native write is attempted first and the sequence is emitted afterwards. Either one landing is
- * a successful copy: a remote server commonly has no clipboard tool at all, and the sequence is the
- * copy that actually reached the person, so it must not be reported as a failure or block the rest
- * of a copy-and-archive. Only a copy that reached neither destination fails, with the native error.
- */
-export function paneClipboardWriter(
- writeClipboard: (text: string) => ClipboardResult,
- emit: (sequence: string) => boolean,
-): (text: string) => ClipboardResult {
- return (text: string) => {
- const native = writeClipboard(text);
- const emitted = emit(osc52ClipboardSequence(text));
- if (native.ok || emitted) return { ok: true, value: undefined };
- return native;
- };
-}
diff --git a/src/paths.ts b/src/paths.ts
deleted file mode 100644
index b93dbeb..0000000
--- a/src/paths.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import path from "node:path";
-
-/** Remove Windows extended-path prefixes that Bun and CreateProcess cannot use as a cwd. */
-export function normalizeWindowsPath(value: string): string;
-export function normalizeWindowsPath(value: undefined): undefined;
-export function normalizeWindowsPath(value: string | undefined): string | undefined {
- if (!value) return value;
-
- if (value.startsWith("\\\\?\\")) {
- const withoutPrefix = value.slice(4);
- return withoutPrefix.slice(0, 4).toUpperCase() === "UNC\\"
- ? `\\\\${withoutPrefix.slice(4)}`
- : withoutPrefix;
- }
-
- if (value.startsWith("//?/")) {
- const withoutPrefix = value.slice(4);
- return withoutPrefix.slice(0, 4).toUpperCase() === "UNC/"
- ? `//${withoutPrefix.slice(4)}`
- : withoutPrefix;
- }
-
- return value;
-}
-
-/** Return Herdr's plugin-owned state directory when the runtime supplied one. */
-export function stateDir(): string | undefined {
- return process.env.HERDR_PLUGIN_STATE_DIR || undefined;
-}
-
-/** Return Herdr's plugin root in a form that Bun and CreateProcess can use as a cwd. */
-export function pluginRoot(): string | undefined {
- const value = process.env.HERDR_PLUGIN_ROOT;
- return value ? normalizeWindowsPath(value) : undefined;
-}
-
-/** Resolve the JSONL store inside an already parsed plugin state directory. */
-export function annotationsPath(dir: string): string {
- return path.join(dir, "annotations.jsonl");
-}
-
-/** Resolve the archived-set JSONL store inside the plugin state directory. */
-export function archivesPath(dir: string): string {
- return path.join(dir, "archives.jsonl");
-}
diff --git a/src/store.ts b/src/store.ts
deleted file mode 100644
index 06213fa..0000000
--- a/src/store.ts
+++ /dev/null
@@ -1,290 +0,0 @@
-import { randomUUID } from "node:crypto";
-import fs from "node:fs";
-import path from "node:path";
-import { annotationsPath, archivesPath } from "./paths";
-import {
- parseAnnotation,
- parseArchivedAnnotationSet,
- type Annotation,
- type ArchivedAnnotationSet,
-} from "./types";
-
-type StoreName = "annotations" | "archives";
-
-type LockCreation =
- | { readonly _tag: "acquired" }
- | { readonly _tag: "exists" }
- | { readonly _tag: "error"; readonly message: string };
-
-interface StoreLockLease {
- readonly path: string;
- readonly owner: string;
-}
-
-const staleLockMilliseconds = 30_000;
-
-/** The result of an expected annotation-store operation. */
-export type StoreResult =
- | { readonly ok: true; readonly value: T }
- | { readonly ok: false; readonly message: string };
-
-/** Present append-ordered annotations with the most recently saved first. */
-export function newestFirstAnnotations(annotations: readonly Annotation[]): Annotation[] {
- return annotations.slice().reverse();
-}
-
-/** Load the complete active store, rejecting malformed records instead of dropping data. */
-export function loadAnnotations(dir: string): StoreResult {
- return withStoreLock(dir, "annotations", () => loadAnnotationsUnlocked(dir));
-}
-
-/** Append one annotation without rewriting existing records. */
-export function appendAnnotation(dir: string, annotation: Annotation): StoreResult {
- return withStoreLock(dir, "annotations", () => {
- try {
- fs.appendFileSync(annotationsPath(dir), `${JSON.stringify(annotation)}\n`, { mode: 0o600 });
- return { ok: true, value: undefined };
- } catch (error) {
- return { ok: false, message: safeFileError("Unable to save annotation", error) };
- }
- });
-}
-
-/** Remove selected annotation IDs without racing concurrent annotation saves. */
-export function removeAnnotationsById(
- dir: string,
- annotationIds: readonly string[],
-): StoreResult {
- return withStoreLock(dir, "annotations", () => {
- const loaded = loadAnnotationsUnlocked(dir);
- if (!loaded.ok) return loaded;
- const removed = new Set(annotationIds);
- return replaceAnnotationsUnlocked(
- dir,
- loaded.value.filter((annotation) => !removed.has(annotation.id)),
- );
- });
-}
-
-/** Merge annotations into the active list without duplicating existing annotation IDs. */
-export function mergeAnnotations(
- dir: string,
- annotations: readonly Annotation[],
-): StoreResult {
- return withStoreLock(dir, "annotations", () => {
- const loaded = loadAnnotationsUnlocked(dir);
- if (!loaded.ok) return loaded;
- const existingIds = new Set(loaded.value.map((annotation) => annotation.id));
- const additions = annotations.filter((annotation) => !existingIds.has(annotation.id));
- if (additions.length === 0) return { ok: true, value: 0 };
- const replaced = replaceAnnotationsUnlocked(dir, [...loaded.value, ...additions]);
- if (!replaced.ok) return replaced;
- return { ok: true, value: additions.length };
- });
-}
-
-/** Present append-ordered archive sets with the most recently archived first. */
-export function newestFirstArchivedSets(
- archives: readonly ArchivedAnnotationSet[],
-): ArchivedAnnotationSet[] {
- return archives.slice().reverse();
-}
-
-/** Load complete, parsed annotation sets from the archive store. */
-export function loadArchivedSets(dir: string): StoreResult {
- return withStoreLock(dir, "archives", () => loadArchivedSetsUnlocked(dir));
-}
-
-/** Atomically append one complete set to the archive store. */
-export function appendArchivedSet(
- dir: string,
- archive: ArchivedAnnotationSet,
-): StoreResult {
- return withStoreLock(dir, "archives", () => {
- const loaded = loadArchivedSetsUnlocked(dir);
- if (!loaded.ok) return loaded;
- return replaceArchivedSetsUnlocked(dir, [...loaded.value, archive]);
- });
-}
-
-/** Permanently remove one archived set by its archive ID. */
-export function removeArchivedSet(dir: string, archiveId: string): StoreResult {
- return withStoreLock(dir, "archives", () => {
- const loaded = loadArchivedSetsUnlocked(dir);
- if (!loaded.ok) return loaded;
- return replaceArchivedSetsUnlocked(
- dir,
- loaded.value.filter((archive) => archive.id !== archiveId),
- );
- });
-}
-
-function loadAnnotationsUnlocked(dir: string): StoreResult {
- return loadJsonLines(annotationsPath(dir), "annotations", parseAnnotation);
-}
-
-function replaceAnnotationsUnlocked(
- dir: string,
- annotations: readonly Annotation[],
-): StoreResult {
- return replaceJsonLines(
- dir,
- annotationsPath(dir),
- "annotations",
- annotations,
- "Unable to update annotations",
- );
-}
-
-function loadArchivedSetsUnlocked(dir: string): StoreResult {
- return loadJsonLines(archivesPath(dir), "archives", parseArchivedAnnotationSet);
-}
-
-function replaceArchivedSetsUnlocked(
- dir: string,
- archives: readonly ArchivedAnnotationSet[],
-): StoreResult {
- return replaceJsonLines(dir, archivesPath(dir), "archives", archives, "Unable to update archives");
-}
-
-function loadJsonLines(
- file: string,
- label: string,
- parse: (value: unknown) => T | undefined,
-): StoreResult {
- if (!fs.existsSync(file)) return { ok: true, value: [] };
- try {
- const records: T[] = [];
- for (const line of fs.readFileSync(file, "utf8").split("\n").filter(Boolean)) {
- let decoded: unknown;
- try {
- decoded = JSON.parse(line);
- } catch {
- return { ok: false, message: `Unable to read ${label} (invalid data)` };
- }
- const record = parse(decoded);
- if (!record) return { ok: false, message: `Unable to read ${label} (invalid data)` };
- records.push(record);
- }
- return { ok: true, value: records };
- } catch (error) {
- return { ok: false, message: safeFileError(`Unable to read ${label}`, error) };
- }
-}
-
-function replaceJsonLines(
- dir: string,
- file: string,
- temporaryLabel: string,
- records: readonly unknown[],
- errorMessage: string,
-): StoreResult {
- const temporary = path.join(dir, `.${temporaryLabel}-${process.pid}-${Date.now()}.tmp`);
- try {
- const contents = records.map((record) => JSON.stringify(record)).join("\n");
- fs.writeFileSync(temporary, contents ? `${contents}\n` : "", { mode: 0o600 });
- fs.renameSync(temporary, file);
- return { ok: true, value: undefined };
- } catch (error) {
- try {
- fs.rmSync(temporary, { force: true });
- } catch {}
- return { ok: false, message: safeFileError(errorMessage, error) };
- }
-}
-
-function withStoreLock(
- dir: string,
- store: StoreName,
- operation: () => StoreResult,
-): StoreResult {
- try {
- fs.mkdirSync(dir, { recursive: true });
- } catch (error) {
- return { ok: false, message: safeFileError(`Unable to access ${store}`, error) };
- }
-
- const acquired = acquireStoreLock(path.join(dir, `.${store}.lock`), store);
- if (!acquired.ok) return acquired;
-
- try {
- return operation();
- } finally {
- releaseStoreLock(acquired.value);
- }
-}
-
-function acquireStoreLock(lock: string, store: StoreName): StoreResult {
- const owner = `${process.pid}:${randomUUID()}`;
- const created = createStoreLock(lock, store, owner);
- if (created._tag === "acquired") return { ok: true, value: { path: lock, owner } };
- if (created._tag === "error") return { ok: false, message: created.message };
-
- if (!isStaleLock(lock)) {
- return { ok: false, message: `${capitalized(store)} are busy; try again.` };
- }
-
- try {
- fs.rmSync(lock, { recursive: true, force: true });
- } catch (error) {
- return { ok: false, message: safeFileError(`Unable to lock ${store}`, error) };
- }
-
- const retried = createStoreLock(lock, store, owner);
- if (retried._tag === "exists") {
- return { ok: false, message: `${capitalized(store)} are busy; try again.` };
- }
- if (retried._tag === "error") return { ok: false, message: retried.message };
- return { ok: true, value: { path: lock, owner } };
-}
-
-function createStoreLock(lock: string, store: StoreName, owner: string): LockCreation {
- let directoryCreated = false;
- try {
- fs.mkdirSync(lock, { mode: 0o700 });
- directoryCreated = true;
- fs.writeFileSync(path.join(lock, "owner"), `${owner}\n`, { mode: 0o600 });
- return { _tag: "acquired" };
- } catch (error) {
- if (hasFileErrorCode(error, "EEXIST")) {
- return { _tag: "exists" };
- }
- if (directoryCreated) {
- try {
- fs.rmSync(lock, { recursive: true, force: true });
- } catch {}
- }
- return { _tag: "error", message: safeFileError(`Unable to lock ${store}`, error) };
- }
-}
-
-function isStaleLock(lock: string): boolean {
- try {
- return Date.now() - fs.statSync(lock).mtimeMs >= staleLockMilliseconds;
- } catch {
- return false;
- }
-}
-
-function releaseStoreLock(lease: StoreLockLease): void {
- try {
- const currentOwner = fs.readFileSync(path.join(lease.path, "owner"), "utf8").trim();
- if (currentOwner !== lease.owner) return;
- fs.rmSync(lease.path, { recursive: true, force: true });
- } catch {}
-}
-
-function capitalized(value: string): string {
- return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
-}
-
-function hasFileErrorCode(error: unknown, code: string): boolean {
- return error instanceof Error && "code" in error && error.code === code;
-}
-
-function safeFileError(prefix: string, error: unknown): string {
- if (error instanceof Error && "code" in error && typeof error.code === "string") {
- return `${prefix} (${error.code})`;
- }
- return prefix;
-}
diff --git a/src/types.ts b/src/types.ts
deleted file mode 100644
index ac8f277..0000000
--- a/src/types.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/** Herdr invocation fields retained as useful annotation provenance. */
-export interface InvocationContext {
- workspace_id?: string;
- workspace_label?: string;
- tab_id?: string;
- tab_label?: string;
- focused_pane_id?: string;
- focused_pane_cwd?: string;
- focused_pane_agent?: string;
-}
-
-/** Clipboard text and provenance waiting for a user comment. */
-export interface PendingAnnotation {
- selectedText: string;
- context: InvocationContext;
- capturedAt: string;
-}
-
-/** A saved annotation with a non-empty user comment. */
-export interface Annotation extends PendingAnnotation {
- id: string;
- comment: string;
- createdAt: string;
-}
-
-/** One recoverable set of annotations moved out of the active list. */
-export interface ArchivedAnnotationSet {
- readonly version: 1;
- readonly id: string;
- readonly archivedAt: string;
- readonly annotations: readonly Annotation[];
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function optionalString(record: Record, key: string): string | undefined {
- const value = record[key];
- return typeof value === "string" ? value : undefined;
-}
-
-/** Parse untrusted Herdr context JSON into the small provenance shape this plugin stores. */
-export function parseInvocationContext(value: unknown): InvocationContext {
- if (!isRecord(value)) return {};
- return {
- workspace_id: optionalString(value, "workspace_id"),
- workspace_label: optionalString(value, "workspace_label"),
- tab_id: optionalString(value, "tab_id"),
- tab_label: optionalString(value, "tab_label"),
- focused_pane_id: optionalString(value, "focused_pane_id"),
- focused_pane_cwd: optionalString(value, "focused_pane_cwd"),
- focused_pane_agent: optionalString(value, "focused_pane_agent"),
- };
-}
-
-/** Read a non-empty terminal selection from Herdr's plugin invocation context. */
-export function selectedTextFromInvocation(value: unknown): string | undefined {
- if (!isRecord(value)) return undefined;
- const selectedText = optionalString(value, "selected_text");
- return selectedText?.trim() ? selectedText : undefined;
-}
-
-/** Build a pending annotation from selected text supplied by a Herdr pane invocation. */
-export function pendingAnnotationFromInvocation(
- value: unknown,
- capturedAt = new Date().toISOString(),
-): PendingAnnotation | undefined {
- const selectedText = selectedTextFromInvocation(value);
- if (!selectedText) return undefined;
- return {
- selectedText,
- context: parseInvocationContext(value),
- capturedAt,
- };
-}
-
-/** Parse a pending-annotation file, returning undefined when required fields are invalid. */
-export function parsePendingAnnotation(value: unknown): PendingAnnotation | undefined {
- if (!isRecord(value)) return undefined;
- const selectedText = optionalString(value, "selectedText");
- const capturedAt = optionalString(value, "capturedAt");
- if (selectedText === undefined || capturedAt === undefined) return undefined;
- return {
- selectedText,
- capturedAt,
- context: parseInvocationContext(value.context),
- };
-}
-
-/** Parse one persisted JSONL record, returning undefined for malformed records. */
-export function parseAnnotation(value: unknown): Annotation | undefined {
- const pending = parsePendingAnnotation(value);
- if (!pending || !isRecord(value)) return undefined;
- const id = optionalString(value, "id");
- const comment = optionalString(value, "comment");
- const createdAt = optionalString(value, "createdAt");
- if (!id || !comment?.trim() || !createdAt) return undefined;
- return { ...pending, id, comment, createdAt };
-}
-
-/** Parse one persisted archive record without accepting partial annotation sets. */
-export function parseArchivedAnnotationSet(value: unknown): ArchivedAnnotationSet | undefined {
- if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.annotations)) {
- return undefined;
- }
- const id = optionalString(value, "id");
- const archivedAt = optionalString(value, "archivedAt");
- if (!id || !archivedAt || value.annotations.length === 0) return undefined;
-
- const annotations: Annotation[] = [];
- for (const item of value.annotations) {
- const annotation = parseAnnotation(item);
- if (!annotation) return undefined;
- annotations.push(annotation);
- }
-
- return { version: 1, id, archivedAt, annotations };
-}
diff --git a/src/width.ts b/src/width.ts
deleted file mode 100644
index 1d2ca2a..0000000
--- a/src/width.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-/**
- * Terminal cell widths.
- *
- * East Asian wide and fullwidth characters occupy two terminal cells. Counting
- * them as one shifts every column the editor computes, which misplaces the
- * cursor and lets IME preedit overlays land inside existing text.
- */
-
-const WIDE_RANGES: readonly (readonly [number, number])[] = [
- [0x1100, 0x115f], // Hangul Jamo initial consonants
- [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols and punctuation
- [0x3041, 0x33ff], // Hiragana through CJK compatibility
- [0x3400, 0x4dbf], // CJK unified ideographs extension A
- [0x4e00, 0x9fff], // CJK unified ideographs
- [0xa000, 0xa4cf], // Yi syllables
- [0xa960, 0xa97f], // Hangul Jamo extended-A
- [0xac00, 0xd7a3], // Hangul syllables
- [0xf900, 0xfaff], // CJK compatibility ideographs
- [0xfe10, 0xfe19], // Vertical forms
- [0xfe30, 0xfe6f], // CJK compatibility forms, small form variants
- [0xff00, 0xff60], // Fullwidth forms
- [0xffe0, 0xffe6], // Fullwidth signs
- [0x1f300, 0x1f64f], // Emoji
- [0x1f900, 0x1f9ff], // Supplemental symbols and pictographs
- [0x20000, 0x3fffd], // CJK extensions B and beyond
-];
-
-function isWide(codePoint: number): boolean {
- for (const [start, end] of WIDE_RANGES) {
- if (codePoint < start) return false;
- if (codePoint <= end) return true;
- }
- return false;
-}
-
-/** Cells occupied by a single character. Control characters count as zero. */
-export function charWidth(char: string): number {
- const codePoint = char.codePointAt(0);
- if (codePoint === undefined) return 0;
- if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0;
- // Combining marks attach to the preceding character.
- if (codePoint >= 0x0300 && codePoint <= 0x036f) return 0;
- if (codePoint >= 0x200b && codePoint <= 0x200f) return 0;
- return isWide(codePoint) ? 2 : 1;
-}
-
-/** Cells occupied by a string. */
-export function stringWidth(text: string): number {
- let total = 0;
- for (const char of text) total += charWidth(char);
- return total;
-}
-
-/** Longest prefix of `text` that fits in `width` cells without splitting a character. */
-export function truncateToWidth(text: string, width: number): string {
- if (width <= 0) return "";
- let used = 0;
- let output = "";
- for (const char of text) {
- const next = charWidth(char);
- if (used + next > width) break;
- output += char;
- used += next;
- }
- return output;
-}
diff --git a/test/archive-workflow.test.ts b/test/archive-workflow.test.ts
deleted file mode 100644
index 2035748..0000000
--- a/test/archive-workflow.test.ts
+++ /dev/null
@@ -1,253 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test";
-import fs from "node:fs";
-import os from "node:os";
-import path from "node:path";
-import { copyAndArchiveAnnotations, restoreArchivedSet } from "../src/archive-workflow";
-import {
- appendAnnotation,
- appendArchivedSet,
- loadAnnotations,
- loadArchivedSets,
- mergeAnnotations,
- removeAnnotationsById,
- removeArchivedSet,
-} from "../src/store";
-import type { Annotation, ArchivedAnnotationSet } from "../src/types";
-
-const directories: string[] = [];
-
-function temporaryDirectory(): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-annotate-workflow-"));
- directories.push(dir);
- return dir;
-}
-
-function annotation(id: string): Annotation {
- return {
- id,
- selectedText: `selection ${id}`,
- comment: `comment ${id}`,
- capturedAt: "2026-08-08T00:00:00Z",
- createdAt: "2026-08-08T00:00:01Z",
- context: {},
- };
-}
-
-function archive(annotationIds: readonly string[]): ArchivedAnnotationSet {
- return {
- version: 1,
- id: "archive-one",
- archivedAt: "2026-08-26T23:32:00Z",
- annotations: annotationIds.map(annotation),
- };
-}
-
-afterEach(() => {
- for (const dir of directories.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
-});
-
-describe("copy and archive", () => {
- test("copies newest first, saves the complete set, then removes active IDs", () => {
- const active = [annotation("one"), annotation("two")];
- const events: string[] = [];
- let clipboardText = "";
- let savedArchive: ArchivedAnnotationSet | undefined;
- let removedIds: readonly string[] = [];
-
- const outcome = copyAndArchiveAnnotations({
- loadActive: () => {
- events.push("load");
- return { ok: true, value: active };
- },
- writeClipboard: (text) => {
- events.push("copy");
- clipboardText = text;
- return { ok: true, value: undefined };
- },
- saveArchive: (saved) => {
- events.push("archive");
- savedArchive = saved;
- return { ok: true, value: undefined };
- },
- removeActive: (ids) => {
- events.push("remove");
- removedIds = ids;
- return { ok: true, value: undefined };
- },
- createArchiveId: () => "archive-one",
- now: () => "2026-08-26T23:32:00Z",
- });
-
- expect(outcome).toEqual({ _tag: "close", archivedCount: 2 });
- expect(events).toEqual(["load", "copy", "archive", "remove"]);
- expect(clipboardText.indexOf("selection two")).toBeLessThan(
- clipboardText.indexOf("selection one"),
- );
- expect(savedArchive).toEqual(archive(["one", "two"]));
- expect(removedIds).toEqual(["one", "two"]);
- });
-
- test("does not archive or clear when clipboard writing fails", () => {
- const events: string[] = [];
- const outcome = copyAndArchiveAnnotations({
- loadActive: () => ({ ok: true, value: [annotation("one")] }),
- writeClipboard: () => ({ ok: false, message: "Clipboard unavailable" }),
- saveArchive: () => {
- events.push("archive");
- return { ok: true, value: undefined };
- },
- removeActive: () => {
- events.push("remove");
- return { ok: true, value: undefined };
- },
- createArchiveId: () => "archive-one",
- now: () => "2026-08-26T23:32:00Z",
- });
-
- expect(outcome).toEqual({ _tag: "stay_open", message: "Clipboard unavailable" });
- expect(events).toEqual([]);
- });
-
- test("does not clear active annotations when archive storage fails", () => {
- let removed = false;
- const outcome = copyAndArchiveAnnotations({
- loadActive: () => ({ ok: true, value: [annotation("one")] }),
- writeClipboard: () => ({ ok: true, value: undefined }),
- saveArchive: () => ({ ok: false, message: "Archive unavailable" }),
- removeActive: () => {
- removed = true;
- return { ok: true, value: undefined };
- },
- createArchiveId: () => "archive-one",
- now: () => "2026-08-26T23:32:00Z",
- });
-
- expect(outcome).toEqual({ _tag: "stay_open", message: "Archive unavailable" });
- expect(removed).toBeFalse();
- });
-
- test("reports that active data remains when clearing fails after archival", () => {
- let archived = false;
- const outcome = copyAndArchiveAnnotations({
- loadActive: () => ({ ok: true, value: [annotation("one")] }),
- writeClipboard: () => ({ ok: true, value: undefined }),
- saveArchive: () => {
- archived = true;
- return { ok: true, value: undefined };
- },
- removeActive: () => ({ ok: false, message: "Active store unavailable" }),
- createArchiveId: () => "archive-one",
- now: () => "2026-08-26T23:32:00Z",
- });
-
- expect(archived).toBeTrue();
- expect(outcome).toEqual({
- _tag: "archived_active_retained",
- message: "Active store unavailable",
- });
- });
-});
-
-describe("restore archive", () => {
- test("merges annotations before removing the archived set", () => {
- const events: string[] = [];
- const outcome = restoreArchivedSet(archive(["one", "two"]), {
- mergeActive: () => {
- events.push("merge");
- return { ok: true, value: 2 };
- },
- removeArchive: () => {
- events.push("remove");
- return { ok: true, value: undefined };
- },
- });
-
- expect(outcome).toEqual({ _tag: "restored", restoredCount: 2 });
- expect(events).toEqual(["merge", "remove"]);
- });
-
- test("keeps the archive when restoring active annotations fails", () => {
- let removed = false;
- const outcome = restoreArchivedSet(archive(["one"]), {
- mergeActive: () => ({ ok: false, message: "Active store unavailable" }),
- removeArchive: () => {
- removed = true;
- return { ok: true, value: undefined };
- },
- });
-
- expect(outcome).toEqual({ _tag: "stay_open", message: "Active store unavailable" });
- expect(removed).toBeFalse();
- });
-
- test("reports a retained archive after active annotations were restored", () => {
- const outcome = restoreArchivedSet(archive(["one"]), {
- mergeActive: () => ({ ok: true, value: 1 }),
- removeArchive: () => ({ ok: false, message: "Archive unavailable" }),
- });
-
- expect(outcome).toEqual({
- _tag: "restored_archive_retained",
- restoredCount: 1,
- message: "Archive unavailable",
- });
- });
-});
-
-test("archives, clears, and restores a set through the real stores", () => {
- const dir = temporaryDirectory();
- appendAnnotation(dir, annotation("one"));
- appendAnnotation(dir, annotation("two"));
-
- const archived = copyAndArchiveAnnotations({
- loadActive: () => loadAnnotations(dir),
- writeClipboard: () => ({ ok: true, value: undefined }),
- saveArchive: (set) => appendArchivedSet(dir, set),
- removeActive: (ids) => removeAnnotationsById(dir, ids),
- createArchiveId: () => "archive-one",
- now: () => "2026-08-26T23:32:00Z",
- });
-
- expect(archived).toEqual({ _tag: "close", archivedCount: 2 });
- expect(loadAnnotations(dir)).toEqual({ ok: true, value: [] });
- const storedArchives = loadArchivedSets(dir);
- expect(storedArchives.ok && storedArchives.value.map(({ id }) => id)).toEqual(["archive-one"]);
- if (!storedArchives.ok) return;
- const storedArchive = storedArchives.value[0];
- if (!storedArchive) return;
-
- const restored = restoreArchivedSet(storedArchive, {
- mergeActive: (items) => mergeAnnotations(dir, items),
- removeArchive: (archiveId) => removeArchivedSet(dir, archiveId),
- });
-
- expect(restored).toEqual({ _tag: "restored", restoredCount: 2 });
- const active = loadAnnotations(dir);
- expect(active.ok && active.value.map(({ id }) => id)).toEqual(["one", "two"]);
- expect(loadArchivedSets(dir)).toEqual({ ok: true, value: [] });
-});
-
-test("copy and archive preserves an annotation saved after the active snapshot", () => {
- const dir = temporaryDirectory();
- appendAnnotation(dir, annotation("snapshot"));
-
- const outcome = copyAndArchiveAnnotations({
- loadActive: () => loadAnnotations(dir),
- writeClipboard: () => ({ ok: true, value: undefined }),
- saveArchive: (set) => {
- const saved = appendArchivedSet(dir, set);
- if (!saved.ok) return saved;
- return appendAnnotation(dir, annotation("concurrent"));
- },
- removeActive: (ids) => removeAnnotationsById(dir, ids),
- createArchiveId: () => "archive-one",
- now: () => "2026-08-26T23:32:00Z",
- });
-
- expect(outcome).toEqual({ _tag: "close", archivedCount: 1 });
- expect(loadAnnotations(dir)).toEqual({ ok: true, value: [annotation("concurrent")] });
- const storedArchives = loadArchivedSets(dir);
- expect(
- storedArchives.ok && storedArchives.value[0]?.annotations.map(({ id }) => id),
- ).toEqual(["snapshot"]);
-});
diff --git a/test/edit-keys.test.ts b/test/edit-keys.test.ts
deleted file mode 100644
index d10a17d..0000000
--- a/test/edit-keys.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { lineEnd, lineStart, resolveEditKey, wordEnd, wordStart } from "../src/edit-keys";
-
-const chars = (text: string) => Array.from(text);
-
-describe("word boundaries", () => {
- test("wordStart skips trailing spaces then the word", () => {
- const c = chars("foo bar ");
- expect(wordStart(c, c.length)).toBe(4);
- expect(wordStart(c, 4)).toBe(0);
- expect(wordStart(c, 0)).toBe(0);
- });
-
- test("wordStart stops at a newline before crossing it", () => {
- const c = chars("foo\nbar");
- expect(wordStart(c, 4)).toBe(3);
- expect(wordStart(c, 3)).toBe(0);
- });
-
- test("wordEnd skips leading spaces then the word", () => {
- const c = chars(" foo bar");
- expect(wordEnd(c, 0)).toBe(5);
- expect(wordEnd(c, 5)).toBe(9);
- expect(wordEnd(c, 9)).toBe(9);
- });
-
- test("wordEnd stops after a newline", () => {
- const c = chars("foo\nbar");
- expect(wordEnd(c, 3)).toBe(4);
- });
-
- test("CJK runs count as one word", () => {
- const c = chars("한글 테스트");
- expect(wordStart(c, c.length)).toBe(3);
- expect(wordEnd(c, 0)).toBe(2);
- });
-});
-
-describe("line boundaries", () => {
- test("lineStart and lineEnd stay within the current line", () => {
- const c = chars("ab\ncd\nef");
- expect(lineStart(c, 4)).toBe(3);
- expect(lineEnd(c, 4)).toBe(5);
- expect(lineStart(c, 0)).toBe(0);
- expect(lineEnd(c, 8)).toBe(8);
- });
-});
-
-describe("resolveEditKey", () => {
- test("readline kill bindings (Cmd/Opt+Backspace via Ghostty)", () => {
- expect(resolveEditKey({ ctrl: true, name: "u", sequence: "\x15" })).toBe("delete-line");
- expect(resolveEditKey({ ctrl: true, name: "w", sequence: "\x17" })).toBe("delete-word");
- expect(resolveEditKey({ meta: true, name: "backspace", sequence: "\x1b\x7f" })).toBe("delete-word");
- });
-
- test("Option arrows move by word", () => {
- expect(resolveEditKey({ meta: true, name: "left", sequence: "\x1b[1;3D" })).toBe("word-left");
- expect(resolveEditKey({ meta: true, name: "right", sequence: "\x1b[1;3C" })).toBe("word-right");
- expect(resolveEditKey({ meta: true, name: "b", sequence: "\x1bb" })).toBe("word-left");
- expect(resolveEditKey({ meta: true, name: "f", sequence: "\x1bf" })).toBe("word-right");
- });
-
- test("Command arrows move to line edges", () => {
- expect(resolveEditKey({ meta: true, name: "left", sequence: "\x1b[1;9D" })).toBe("line-start");
- expect(resolveEditKey({ meta: true, name: "right", sequence: "\x1b[1;9C" })).toBe("line-end");
- });
-
- test("plain keys are left to the editor", () => {
- expect(resolveEditKey({ name: "left", sequence: "\x1b[D" })).toBeNull();
- expect(resolveEditKey({ name: "backspace", sequence: "\x7f" })).toBeNull();
- expect(resolveEditKey({ name: "a", sequence: "a" })).toBeNull();
- });
-});
diff --git a/test/export-archive.test.ts b/test/export-archive.test.ts
deleted file mode 100644
index b9c1f20..0000000
--- a/test/export-archive.test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { copyAndArchiveAnnotations } from "../src/archive-workflow";
-import { copyArchiveReport } from "../src/export-archive";
-import type { Annotation, ArchivedAnnotationSet } from "../src/types";
-
-function annotation(id: string): Annotation {
- return {
- id,
- selectedText: `selection ${id}`,
- comment: `comment ${id}`,
- capturedAt: "2026-08-08T00:00:00Z",
- createdAt: "2026-08-08T00:00:01Z",
- context: {},
- };
-}
-
-interface Dependencies {
- readonly active?: readonly Annotation[];
- readonly loadMessage?: string;
- readonly clipboardMessage?: string;
- readonly saveMessage?: string;
- readonly removeMessage?: string;
-}
-
-/** Drive the workflow with mocked dependencies, then report it like `src/export-archive.ts` does. */
-function report(dependencies: Dependencies) {
- const active = dependencies.active ?? [];
- let loadedEmpty = false;
- const outcome = copyAndArchiveAnnotations({
- loadActive: () => {
- if (dependencies.loadMessage) return { ok: false, message: dependencies.loadMessage };
- if (active.length === 0) loadedEmpty = true;
- return { ok: true, value: [...active] };
- },
- writeClipboard: () =>
- dependencies.clipboardMessage
- ? { ok: false, message: dependencies.clipboardMessage }
- : { ok: true, value: undefined },
- saveArchive: (_archive: ArchivedAnnotationSet) =>
- dependencies.saveMessage
- ? { ok: false, message: dependencies.saveMessage }
- : { ok: true, value: undefined },
- removeActive: () =>
- dependencies.removeMessage
- ? { ok: false, message: dependencies.removeMessage }
- : { ok: true, value: undefined },
- createArchiveId: () => "archive-one",
- now: () => "2026-08-26T23:32:00.000Z",
- });
- return copyArchiveReport(outcome, loadedEmpty);
-}
-
-describe("copy-archive action reporting", () => {
- test("an empty store reports nothing to copy and succeeds", () => {
- expect(report({ active: [] })).toEqual({
- title: "No annotations",
- body: "There is nothing to copy yet.",
- failure: false,
- });
- });
-
- test("a complete archive reports the singular and plural counts", () => {
- expect(report({ active: [annotation("one")] })).toEqual({
- title: "Annotations copied and archived",
- body: "1 annotation copied as Markdown and archived.",
- failure: false,
- });
- expect(report({ active: [annotation("one"), annotation("two")] })).toEqual({
- title: "Annotations copied and archived",
- body: "2 annotations copied as Markdown and archived.",
- failure: false,
- });
- });
-
- test("a failed clipboard write fails with the clipboard message", () => {
- expect(
- report({ active: [annotation("one")], clipboardMessage: "clipboard write failed" }),
- ).toEqual({
- title: "Copy and archive failed",
- body: "clipboard write failed",
- failure: true,
- });
- });
-
- test("a failed load or save fails without the empty-store wording", () => {
- expect(report({ loadMessage: "annotations.jsonl is unreadable" })).toEqual({
- title: "Copy and archive failed",
- body: "annotations.jsonl is unreadable",
- failure: true,
- });
- expect(report({ active: [annotation("one")], saveMessage: "archives.jsonl is busy" })).toEqual({
- title: "Copy and archive failed",
- body: "archives.jsonl is busy",
- failure: true,
- });
- });
-
- test("retained active annotations report the partial archive and fail", () => {
- expect(
- report({ active: [annotation("one")], removeMessage: "store is busy" }),
- ).toEqual({
- title: "Copy and archive incomplete",
- body: "Copied and archived, but active annotations remain: store is busy",
- failure: true,
- });
- });
-});
diff --git a/test/format.test.ts b/test/format.test.ts
deleted file mode 100644
index 476e599..0000000
--- a/test/format.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { formatAnnotations, sanitizeTerminalText, wrapText } from "../src/format";
-import { parseAnnotation, parsePendingAnnotation } from "../src/types";
-
-describe("wrapText", () => {
- test("wraps and preserves explicit newlines", () => {
- expect(wrapText("abcdef\nxy", 3)).toEqual(["abc", "def", "xy"]);
- });
-});
-
-test("terminal display strips control characters", () => {
- expect(sanitizeTerminalText("safe\u001b[2J\ttext\nnext")).toBe("safe[2J text\nnext");
-});
-
-describe("persisted annotation parsing", () => {
- test("rejects malformed boundary input", () => {
- expect(parsePendingAnnotation({ selectedText: 42 })).toBeUndefined();
- expect(parseAnnotation({ selectedText: "text", capturedAt: "now", context: {} })).toBeUndefined();
- });
-});
-
-describe("formatAnnotations", () => {
- test("formats source, selection, and comment as agent context", () => {
- const output = formatAnnotations([
- {
- id: "one",
- selectedText: "failed to connect",
- comment: "Check the database first.",
- capturedAt: "2026-08-08T00:00:00Z",
- createdAt: "2026-08-08T00:00:01Z",
- context: { workspace_label: "api", tab_label: "server" },
- },
- ]);
- expect(output).toContain("# Annotated context");
- expect(output).toContain("Source: api / server");
- expect(output).toContain("failed to connect");
- expect(output).toContain("Check the database first.");
- });
-
- test("uses a longer fence when the selection contains backticks", () => {
- const output = formatAnnotations([
- {
- id: "one",
- selectedText: "```example```",
- comment: "Fence safely.",
- capturedAt: "2026-08-08T00:00:00Z",
- createdAt: "2026-08-08T00:00:01Z",
- context: {},
- },
- ]);
- expect(output).toContain("````\n```example```\n````");
- });
-});
-
-describe("wrapText with wide characters", () => {
- test("wraps on cells so lines fit the box", () => {
- expect(wrapText("한글한글", 4)).toEqual(["한글", "한글"]);
- });
-
- test("never splits a wide character across lines", () => {
- expect(wrapText("한글한", 5)).toEqual(["한글", "한"]);
- });
-
- test("mixes narrow and wide characters", () => {
- expect(wrapText("a한b한", 4)).toEqual(["a한b", "한"]);
- });
-});
diff --git a/test/handoff.test.ts b/test/handoff.test.ts
deleted file mode 100644
index 49818d3..0000000
--- a/test/handoff.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import fs from "node:fs";
-import os from "node:os";
-import path from "node:path";
-import { HANDOFF_MAX_AGE_MS, handoffPath, takeHandoff } from "../src/handoff";
-
-function tempFile(): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-annotate-handoff-"));
- return path.join(dir, "selection");
-}
-
-describe("handoffPath", () => {
- test("prefers XDG_RUNTIME_DIR and is per user", () => {
- const p = handoffPath({ XDG_RUNTIME_DIR: "/run/user/1000" });
- expect(p.startsWith(path.join("/run/user/1000", "herdr-annotate-"))).toBe(true);
- expect(path.basename(p)).toBe("selection");
- });
- test("falls back to the temp dir", () => {
- expect(handoffPath({}).startsWith(os.tmpdir())).toBe(true);
- });
-});
-
-describe("takeHandoff", () => {
- test("returns a fresh file's text and removes it", () => {
- const file = tempFile();
- fs.writeFileSync(file, "hello\nworld\n");
- expect(takeHandoff(file)).toBe("hello\nworld\n");
- expect(fs.existsSync(file)).toBe(false);
- });
- test("ignores and removes a stale file", () => {
- const file = tempFile();
- fs.writeFileSync(file, "old");
- const later = Date.now() + HANDOFF_MAX_AGE_MS + 1000;
- expect(takeHandoff(file, later)).toBeUndefined();
- expect(fs.existsSync(file)).toBe(false);
- });
- test("ignores blank text and a missing file", () => {
- const file = tempFile();
- fs.writeFileSync(file, " \n");
- expect(takeHandoff(file)).toBeUndefined();
- expect(takeHandoff(file)).toBeUndefined();
- });
-});
diff --git a/test/layout.test.ts b/test/layout.test.ts
deleted file mode 100644
index 77be267..0000000
--- a/test/layout.test.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { layoutComment } from "../src/layout";
-
-const chars = (text: string): string[] => Array.from(text);
-
-describe("layoutComment", () => {
- test("reports the cursor column in cells, not characters", () => {
- const comment = chars("한글");
- expect(layoutComment(comment, comment.length, 40).cursorCol).toBe(4);
- });
-
- test("counts narrow characters as one cell", () => {
- const comment = chars("abc");
- expect(layoutComment(comment, comment.length, 40).cursorCol).toBe(3);
- });
-
- test("mixes narrow and wide characters", () => {
- const comment = chars("a한b");
- expect(layoutComment(comment, comment.length, 40).cursorCol).toBe(4);
- expect(layoutComment(comment, 2, 40).cursorCol).toBe(3);
- });
-
- test("never lets a wide character straddle the right edge", () => {
- const comment = chars("한글한");
- const result = layoutComment(comment, comment.length, 5);
- expect(result.lines).toEqual(["한글", "한"]);
- expect(result.cursorRow).toBe(1);
- expect(result.cursorCol).toBe(2);
- });
-
- test("keeps explicit newlines", () => {
- const comment = chars("한\n글");
- const result = layoutComment(comment, comment.length, 40);
- expect(result.lines).toEqual(["한", "글"]);
- expect(result.cursorRow).toBe(1);
- expect(result.cursorCol).toBe(2);
- });
-});
diff --git a/test/manager-copy.test.ts b/test/manager-copy.test.ts
deleted file mode 100644
index 0f732d3..0000000
--- a/test/manager-copy.test.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { copyAnnotations } from "../src/manager-copy";
-import type { Annotation } from "../src/types";
-
-function annotation(id: string): Annotation {
- return {
- id,
- selectedText: `selection ${id}`,
- comment: `comment ${id}`,
- capturedAt: "2026-08-08T00:00:00Z",
- createdAt: "2026-08-08T00:00:01Z",
- context: {},
- };
-}
-
-describe("annotation manager copying", () => {
- test("closes after a successful copy without changing annotations", () => {
- const annotations = [annotation("one"), annotation("two")];
- const original = structuredClone(annotations);
- let clipboardText = "";
-
- const outcome = copyAnnotations(annotations, (text) => {
- clipboardText = text;
- return { ok: true, value: undefined };
- });
-
- expect(outcome).toEqual({ _tag: "close" });
- expect(clipboardText).toContain("selection one");
- expect(clipboardText).toContain("selection two");
- expect(annotations).toEqual(original);
- });
-
- test("stays open and reports a clipboard failure", () => {
- const outcome = copyAnnotations([annotation("one")], () => ({
- ok: false,
- message: "Clipboard unavailable",
- }));
-
- expect(outcome).toEqual({ _tag: "stay_open", message: "Clipboard unavailable" });
- });
-
- test("stays open when there is nothing to copy", () => {
- let writes = 0;
- const outcome = copyAnnotations([], () => {
- writes += 1;
- return { ok: true, value: undefined };
- });
-
- expect(outcome).toEqual({ _tag: "stay_open", message: "Nothing to copy." });
- expect(writes).toBe(0);
- });
-});
diff --git a/test/pane-clipboard.test.ts b/test/pane-clipboard.test.ts
deleted file mode 100644
index 3b9160b..0000000
--- a/test/pane-clipboard.test.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- OSC52_COMMON_PAYLOAD_LIMIT_BYTES,
- exceedsCommonOsc52Limit,
- osc52ClipboardSequence,
- paneClipboardWriter,
-} from "../src/pane-clipboard";
-
-describe("OSC 52 encoding", () => {
- test("emits the exact bytes for plain text", () => {
- const sequence = osc52ClipboardSequence("hi");
-
- expect(sequence).toBe("\x1b]52;c;aGk=\x07");
- expect([...Buffer.from(sequence, "utf8")]).toEqual([
- 0x1b, 0x5d, 0x35, 0x32, 0x3b, 0x63, 0x3b, 0x61, 0x47, 0x6b, 0x3d, 0x07,
- ]);
- });
-
- test("emits an empty payload for an empty string", () => {
- expect(osc52ClipboardSequence("")).toBe("\x1b]52;c;\x07");
- });
-
- test("encodes the raw UTF-8 bytes of multi-byte text", () => {
- expect(osc52ClipboardSequence("한글 · é")).toBe("\x1b]52;c;7ZWc6riAIMK3IMOp\x07");
- expect(osc52ClipboardSequence("한글 · é")).toBe(
- `\x1b]52;c;${Buffer.from("한글 · é", "utf8").toString("base64")}\x07`,
- );
- });
-
- test("reports oversized payloads without truncating them", () => {
- const text = "a".repeat(OSC52_COMMON_PAYLOAD_LIMIT_BYTES);
-
- expect(exceedsCommonOsc52Limit("hi")).toBe(false);
- expect(exceedsCommonOsc52Limit(text)).toBe(true);
- expect(osc52ClipboardSequence(text)).toContain(Buffer.from(text, "utf8").toString("base64"));
- });
-});
-
-describe("pane clipboard writer", () => {
- test("emits the sequence after a successful native write", () => {
- const order: string[] = [];
- const write = paneClipboardWriter(
- (text) => {
- order.push(`native:${text}`);
- return { ok: true, value: undefined };
- },
- (sequence) => {
- order.push(`emit:${sequence}`);
- return true;
- },
- );
-
- expect(write("hi")).toEqual({ ok: true, value: undefined });
- expect(order).toEqual(["native:hi", "emit:\x1b]52;c;aGk=\x07"]);
- });
-
- test("a copy the terminal received succeeds even when the native write failed", () => {
- const emitted: string[] = [];
- const write = paneClipboardWriter(
- () => ({ ok: false, message: "No supported clipboard writer is available" }),
- (sequence) => {
- emitted.push(sequence);
- return true;
- },
- );
-
- expect(write("hi")).toEqual({ ok: true, value: undefined });
- expect(emitted).toEqual(["\x1b]52;c;aGk=\x07"]);
- });
-
- test("a copy that reached neither destination fails with the native error", () => {
- const write = paneClipboardWriter(
- () => ({ ok: false, message: "No supported clipboard writer is available" }),
- () => false,
- );
-
- expect(write("hi")).toEqual({
- ok: false,
- message: "No supported clipboard writer is available",
- });
- });
-});
diff --git a/test/paths.test.ts b/test/paths.test.ts
deleted file mode 100644
index f68f806..0000000
--- a/test/paths.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { normalizeWindowsPath, pluginRoot } from "../src/paths";
-
-describe("normalizeWindowsPath", () => {
- test("removes an extended drive-path prefix", () => {
- expect(normalizeWindowsPath(String.raw`\\?\C:\foo`)).toBe(String.raw`C:\foo`);
- });
-
- test("converts an extended UNC path to an ordinary UNC path", () => {
- expect(normalizeWindowsPath(String.raw`\\?\UNC\server\share\foo`)).toBe(
- String.raw`\\server\share\foo`,
- );
- });
-
- test("leaves ordinary Windows and Unix paths unchanged", () => {
- expect(normalizeWindowsPath(String.raw`C:\foo`)).toBe(String.raw`C:\foo`);
- expect(normalizeWindowsPath("/home/user/plugin")).toBe("/home/user/plugin");
- });
-
- test("handles empty and missing values", () => {
- expect(normalizeWindowsPath("")).toBe("");
- expect(normalizeWindowsPath(undefined)).toBeUndefined();
- });
-
- test("also handles slash-separated extended paths", () => {
- expect(normalizeWindowsPath("//?/C:/foo")).toBe("C:/foo");
- expect(normalizeWindowsPath("//?/UNC/server/share/foo")).toBe("//server/share/foo");
- });
-});
-
-describe("pluginRoot", () => {
- test("returns an unset value as undefined and normalizes an extended root", () => {
- const original = process.env.HERDR_PLUGIN_ROOT;
- try {
- delete process.env.HERDR_PLUGIN_ROOT;
- expect(pluginRoot()).toBeUndefined();
- process.env.HERDR_PLUGIN_ROOT = String.raw`\\?\C:\plugin`;
- expect(pluginRoot()).toBe(String.raw`C:\plugin`);
- } finally {
- if (original === undefined) delete process.env.HERDR_PLUGIN_ROOT;
- else process.env.HERDR_PLUGIN_ROOT = original;
- }
- });
-});
diff --git a/test/store.test.ts b/test/store.test.ts
deleted file mode 100644
index e982720..0000000
--- a/test/store.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test";
-import fs from "node:fs";
-import os from "node:os";
-import path from "node:path";
-import {
- appendAnnotation,
- appendArchivedSet,
- loadAnnotations,
- loadArchivedSets,
- mergeAnnotations,
- newestFirstAnnotations,
- newestFirstArchivedSets,
- removeAnnotationsById,
- removeArchivedSet,
-} from "../src/store";
-import type { Annotation, ArchivedAnnotationSet } from "../src/types";
-
-const directories: string[] = [];
-
-function temporaryDirectory(): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-annotate-store-"));
- directories.push(dir);
- return dir;
-}
-
-function annotation(id: string): Annotation {
- return {
- id,
- selectedText: `selection ${id}`,
- comment: `comment ${id}`,
- capturedAt: "2026-08-08T00:00:00Z",
- createdAt: "2026-08-08T00:00:01Z",
- context: {},
- };
-}
-
-function archive(id: string, annotationIds: readonly string[]): ArchivedAnnotationSet {
- return {
- version: 1,
- id,
- archivedAt: `2026-08-26T23:32:0${id.length}Z`,
- annotations: annotationIds.map(annotation),
- };
-}
-
-afterEach(() => {
- for (const dir of directories.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
-});
-
-describe("annotation store", () => {
- test("presents the newest appended annotation first without mutating storage order", () => {
- const stored = [annotation("one"), annotation("two"), annotation("three")];
- expect(newestFirstAnnotations(stored).map(({ id }) => id)).toEqual(["three", "two", "one"]);
- expect(stored.map(({ id }) => id)).toEqual(["one", "two", "three"]);
- });
-
- test("appends and loads records", () => {
- const dir = temporaryDirectory();
- expect(appendAnnotation(dir, annotation("one")).ok).toBeTrue();
- expect(appendAnnotation(dir, annotation("two")).ok).toBeTrue();
- const loaded = loadAnnotations(dir);
- expect(loaded.ok && loaded.value.map(({ id }) => id)).toEqual(["one", "two"]);
- });
-
- test("removes only the requested annotation IDs", () => {
- const dir = temporaryDirectory();
- appendAnnotation(dir, annotation("one"));
- appendAnnotation(dir, annotation("two"));
- appendAnnotation(dir, annotation("three"));
-
- expect(removeAnnotationsById(dir, ["one", "three"]).ok).toBeTrue();
- const loaded = loadAnnotations(dir);
- expect(loaded.ok && loaded.value.map(({ id }) => id)).toEqual(["two"]);
- expect(removeAnnotationsById(dir, ["two"]).ok).toBeTrue();
- const cleared = loadAnnotations(dir);
- expect(cleared.ok && cleared.value).toEqual([]);
- });
-
- test("merges restored annotations without duplicate IDs", () => {
- const dir = temporaryDirectory();
- appendAnnotation(dir, annotation("one"));
-
- const merged = mergeAnnotations(dir, [annotation("one"), annotation("two")]);
- expect(merged).toEqual({ ok: true, value: 1 });
- expect(loadAnnotations(dir)).toEqual({
- ok: true,
- value: [annotation("one"), annotation("two")],
- });
-
- expect(removeAnnotationsById(dir, ["one"]).ok).toBeTrue();
- expect(loadAnnotations(dir)).toEqual({ ok: true, value: [annotation("two")] });
- });
-
- test("rejects malformed active data instead of dropping it", () => {
- const dir = temporaryDirectory();
- fs.writeFileSync(path.join(dir, "annotations.jsonl"), "{broken\n");
- expect(loadAnnotations(dir)).toEqual({
- ok: false,
- message: "Unable to read annotations (invalid data)",
- });
- });
-
- test("fails safely on contention and recovers an abandoned lock", () => {
- const dir = temporaryDirectory();
- const lock = path.join(dir, ".annotations.lock");
- fs.mkdirSync(lock);
-
- expect(appendAnnotation(dir, annotation("one"))).toEqual({
- ok: false,
- message: "Annotations are busy; try again.",
- });
- expect(loadAnnotations(dir)).toEqual({
- ok: false,
- message: "Annotations are busy; try again.",
- });
-
- const stale = new Date(Date.now() - 31_000);
- fs.utimesSync(lock, stale, stale);
- expect(appendAnnotation(dir, annotation("one")).ok).toBeTrue();
- expect(loadAnnotations(dir)).toEqual({ ok: true, value: [annotation("one")] });
- });
-});
-
-describe("archive store", () => {
- test("persists complete sets and presents the newest archive first", () => {
- const dir = temporaryDirectory();
- const first = archive("one", ["annotation-one"]);
- const second = archive("two", ["annotation-two", "annotation-three"]);
-
- expect(appendArchivedSet(dir, first).ok).toBeTrue();
- expect(appendArchivedSet(dir, second).ok).toBeTrue();
- const loaded = loadArchivedSets(dir);
- expect(loaded).toEqual({ ok: true, value: [first, second] });
- expect(loaded.ok && newestFirstArchivedSets(loaded.value).map(({ id }) => id)).toEqual([
- "two",
- "one",
- ]);
- });
-
- test("permanently removes only the selected archived set", () => {
- const dir = temporaryDirectory();
- appendArchivedSet(dir, archive("one", ["annotation-one"]));
- appendArchivedSet(dir, archive("two", ["annotation-two"]));
-
- expect(removeArchivedSet(dir, "one").ok).toBeTrue();
- const loaded = loadArchivedSets(dir);
- expect(loaded.ok && loaded.value.map(({ id }) => id)).toEqual(["two"]);
- });
-
- test("rejects malformed archive data instead of partially loading it", () => {
- const dir = temporaryDirectory();
- fs.writeFileSync(path.join(dir, "archives.jsonl"), "{broken\n");
- expect(loadArchivedSets(dir)).toEqual({
- ok: false,
- message: "Unable to read archives (invalid data)",
- });
- });
-});
diff --git a/test/types.test.ts b/test/types.test.ts
deleted file mode 100644
index ba1e0f8..0000000
--- a/test/types.test.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- parseArchivedAnnotationSet,
- pendingAnnotationFromInvocation,
- selectedTextFromInvocation,
-} from "../src/types";
-
-function persistedAnnotation(id: string): Record {
- return {
- id,
- selectedText: `selection ${id}`,
- comment: `comment ${id}`,
- capturedAt: "2026-08-08T00:00:00Z",
- createdAt: "2026-08-08T00:00:01Z",
- context: {},
- };
-}
-
-describe("selectedTextFromInvocation", () => {
- test("returns Herdr's terminal selection without changing it", () => {
- expect(selectedTextFromInvocation({ selected_text: " selected text\n" })).toBe(
- " selected text\n",
- );
- });
-
- test("ignores missing, invalid, and empty selections", () => {
- expect(selectedTextFromInvocation({})).toBeUndefined();
- expect(selectedTextFromInvocation({ selected_text: 42 })).toBeUndefined();
- expect(selectedTextFromInvocation({ selected_text: " \n\t" })).toBeUndefined();
- expect(selectedTextFromInvocation(null)).toBeUndefined();
- });
-});
-
-describe("pendingAnnotationFromInvocation", () => {
- test("builds the editor fallback with invocation context", () => {
- expect(
- pendingAnnotationFromInvocation(
- {
- selected_text: "selected text",
- workspace_id: "workspace-1",
- focused_pane_cwd: "C:\\work",
- },
- "2026-08-27T00:00:00Z",
- ),
- ).toEqual({
- selectedText: "selected text",
- context: {
- workspace_id: "workspace-1",
- focused_pane_cwd: "C:\\work",
- },
- capturedAt: "2026-08-27T00:00:00Z",
- });
- });
-
- test("returns undefined for a missing or empty invocation selection", () => {
- expect(pendingAnnotationFromInvocation({}, "2026-08-27T00:00:00Z")).toBeUndefined();
- expect(
- pendingAnnotationFromInvocation({ selected_text: " \n" }, "2026-08-27T00:00:00Z"),
- ).toBeUndefined();
- });
-});
-
-describe("parseArchivedAnnotationSet", () => {
- test("parses a complete versioned archive", () => {
- const parsed = parseArchivedAnnotationSet({
- version: 1,
- id: "archive-one",
- archivedAt: "2026-08-26T23:32:00Z",
- annotations: [persistedAnnotation("one")],
- });
-
- expect(parsed?.id).toBe("archive-one");
- expect(parsed?.annotations.map(({ id }) => id)).toEqual(["one"]);
- });
-
- test("rejects empty, partial, and unknown archive versions", () => {
- expect(
- parseArchivedAnnotationSet({
- version: 1,
- id: "empty",
- archivedAt: "2026-08-26T23:32:00Z",
- annotations: [],
- }),
- ).toBeUndefined();
- expect(
- parseArchivedAnnotationSet({
- version: 1,
- id: "partial",
- archivedAt: "2026-08-26T23:32:00Z",
- annotations: [persistedAnnotation("one"), { id: "broken" }],
- }),
- ).toBeUndefined();
- expect(
- parseArchivedAnnotationSet({
- version: 2,
- id: "future",
- archivedAt: "2026-08-26T23:32:00Z",
- annotations: [persistedAnnotation("one")],
- }),
- ).toBeUndefined();
- });
-});
diff --git a/test/width.test.ts b/test/width.test.ts
deleted file mode 100644
index 06ef5a5..0000000
--- a/test/width.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { charWidth, stringWidth, truncateToWidth } from "../src/width";
-
-describe("charWidth", () => {
- test("gives Hangul syllables two cells", () => {
- expect(charWidth("한")).toBe(2);
- expect(charWidth("ㄱ")).toBe(2);
- });
-
- test("gives CJK and fullwidth forms two cells", () => {
- expect(charWidth("漢")).toBe(2);
- expect(charWidth("あ")).toBe(2);
- expect(charWidth("A")).toBe(2);
- });
-
- test("gives Latin and punctuation one cell", () => {
- expect(charWidth("a")).toBe(1);
- expect(charWidth("·")).toBe(1);
- });
-
- test("gives control characters no cells", () => {
- expect(charWidth("\u0007")).toBe(0);
- });
-});
-
-describe("stringWidth", () => {
- test("adds the cells of each character", () => {
- expect(stringWidth("한글abc")).toBe(7);
- expect(stringWidth("")).toBe(0);
- });
-});
-
-describe("truncateToWidth", () => {
- test("never splits a wide character", () => {
- expect(truncateToWidth("한글", 3)).toBe("한");
- expect(truncateToWidth("한글", 4)).toBe("한글");
- });
-
- test("returns nothing when no cells are available", () => {
- expect(truncateToWidth("한", 0)).toBe("");
- });
-});
diff --git a/tsconfig.json b/tsconfig.json
deleted file mode 100644
index 2353c80..0000000
--- a/tsconfig.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2022",
- "module": "ESNext",
- "moduleResolution": "Bundler",
- "strict": true,
- "noEmit": true,
- "types": ["bun"]
- },
- "include": ["src/**/*.ts", "test/**/*.ts"]
-}