Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) #72

Merged
benstull merged 18 commits from session-0065 into main 2026-07-02 23:09:38 +00:00
54 changed files with 3689 additions and 1995 deletions
+89 -8
View File
@@ -14,9 +14,59 @@ catalog (a pure, key-free SDK call) in a notification and the
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
human/Claude attribution (Feature #6), F4 propose/accept diff flow human/Claude attribution (Feature #6), F4 propose/accept diff flow
(Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view (Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view
toggle (Feature #17), F10 interactive review — **write left / review data layer (Feature #17), and the **native-surfaces migration** (the
right** (Feature #29), and F11 — the **preview toolbar as the primary "Coediting Markdown with a Machine" Solution Design) — the current UI, replacing
interaction surface** (Feature #43). the bespoke F7/F10/F11 review webview with VS Code's own chrome end to end. See
**How it works** below for the current experience; the F7/F9/F10/F11 sections
further down are kept as a **historical record** of the superseded webview (each
now says so up top).
## How it works
There is **no bespoke panel**. Every surface below is native VS Code chrome —
the editor title bar, status bar, Comments gutter, source control, diff editor,
and the built-in Markdown preview:
1. **Enter coediting.** Run **"✦ Coedit this Document with Claude"** (command
palette, editor title menu, or editor/explorer context menu) on a Markdown
document — it captures a **baseline** (a clean checkpoint: git HEAD if the
file is tracked and clean, otherwise a snapshot) to measure future changes
against. Until a document is entered, none of the surfaces below attach to
it. **"Stop editing with Claude"** reverses it.
2. **Title bar + status bar are the everyday entry points.** Once coedited, the
editor title bar carries **Review Changes**, **Ask Claude**, **Toggle
Annotations**, and (in snapshot mode) **Mark Changes as Reviewed**; a
`✦ Coediting · N changes` status-bar item shows the pending-change count and
opens the review when clicked — no need to find the Source Control pane.
3. **Ask Claude is comments-first.** **"Ask Claude"** (editor title, or select
text → right-click → "Ask Claude to Edit") opens a focused comment box on
the native **Comments API** — on your selection, or a top-anchored
whole-document thread with none selected. Claude replies in-thread and
offers an edit; accepting the offer turns it into one or more pending
proposals.
4. **Pending changes live in the buffer.** A proposal is **optimistically
applied** straight into the editor (green/blue insertion tint, a struck
deletion hint) with an inline **`✓ Keep` / `✗ Reject`** CodeLens above each
changed block — and, once ≥2 proposals are pending, a top-of-file
**`✓ Keep all (N)` / `✗ Reject all`** pair. Nothing is force-applied: a
proposal whose target text changed underneath it is skipped, never guessed.
5. **Native diff answers "what changed?"** **Review Changes** (editor title, or
`Ctrl+Alt+R` / `Cmd+Alt+R`) opens VS Code's own diff editor — baseline on the
left, your live document on the right — with the platform's inline/side-by-side
toggle, navigation, and accessibility for free. A Source Control gutter/viewlet
entry (quick-diff change bars, "Open Changes") is a bonus home for git users,
never the required path.
6. **The built-in Markdown preview is the annotated read.** "Open Preview to the
Side" (or right-click a markdown file/tab → **"Open Cowriting Review
Preview"**, `cowriting.openReviewPreview`) renders the document with
authorship/change coloring **inside VS Code's own preview** — green = human,
blue = Claude, strikethrough = deleted — toggled by **Toggle Annotations**
(command or the title-bar eye icon). No separate webview, no separate
persistence.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-native-surfaces.md`
(§5 UX Layout is the canonical source for this map). Migration plan:
[`docs/superpowers/plans/2026-07-01-native-surfaces-migration.md`](docs/superpowers/plans/2026-07-01-native-surfaces-migration.md).
## Architecture ## Architecture
@@ -134,7 +184,16 @@ record per the contract; git push/pull is the transport, no re-homing ever.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`.
## F6 — Diff-view toggle (Feature #17, #19) ## F6 — Diff-view toggle (Feature #17, #19) — SUPERSEDED
> **Superseded.** The two-pane `Ctrl+Alt+D` toggle this section describes was
> deleted in #34; only the baseline **data layer** survived, now serving the
> native diff editor (`Ctrl+Alt+R` / "Review Changes" — How it works §5) and the
> built-in preview annotations (§6). The native-surfaces migration's Task 2
> additionally **retired INV-18** (machine-landing auto-advance): a landed
> Claude edit stays a visible change until "Mark Changes as Reviewed" (the
> renamed "Pin Diff Baseline to Now"), it no longer auto-clears. Kept below as a
> historical record.
**`Ctrl+Alt+D`** (the same chord on macOS — not `Cmd`; or **Cowriting: Toggle **`Ctrl+Alt+D`** (the same chord on macOS — not `Cmd`; or **Cowriting: Toggle
Diff View**) flips the focused document into a native `vscode.diff` against a Diff View**) flips the focused document into a native `vscode.diff` against a
@@ -166,7 +225,12 @@ instead of git archaeology.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`.
Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md). Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md).
## F7 — Rendered track-changes preview (Feature #21) ## F7 — Rendered track-changes preview (Feature #21) — SUPERSEDED
> **Superseded (Task 8, native-surfaces migration).** The bespoke webview this
> section describes was deleted; its authorship/change coloring lives on
> **inside VS Code's own built-in Markdown preview** — see **How it works** §6
> above. Kept below as a historical record of the pre-migration design.
**`Ctrl+Alt+R`** (or **Cowriting: Open Track-Changes Preview**) opens a **`Ctrl+Alt+R`** (or **Cowriting: Open Track-Changes Preview**) opens a
read-only webview **beside** a **Markdown** editor that renders the document and read-only webview **beside** a **Markdown** editor that renders the document and
@@ -198,7 +262,12 @@ the manual smoke, not the sealed-sandbox E2E.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`.
Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md). Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md).
## F9 — Authorship view in the preview (Feature ~#27) ## F9 — Authorship view in the preview (Feature ~#27) — SUPERSEDED
> **Superseded (Task 8, native-surfaces migration).** The F7 webview this mode
> lived in was deleted; the built-in preview's annotations (How it works §6)
> always show authorship + change coloring together — there is no separate
> mode toggle. Kept below as a historical record.
The rendered preview (F7) gains a second mode, switched by a `[ Track changes | The rendered preview (F7) gains a second mode, switched by a `[ Track changes |
Authorship ]` toggle in its header. **Authorship** mode re-renders the current Authorship ]` toggle in its header. **Authorship** mode re-renders the current
@@ -239,7 +308,13 @@ a plain PR revert with zero data migration.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`.
Live smoke: [`docs/MANUAL-SMOKE-F8.md`](docs/MANUAL-SMOKE-F8.md). Live smoke: [`docs/MANUAL-SMOKE-F8.md`](docs/MANUAL-SMOKE-F8.md).
## F10 — Interactive review: write left / review right (Feature #29) ## F10 — Interactive review: write left / review right (Feature #29) — SUPERSEDED
> **Superseded (Task 8, native-surfaces migration).** The editor is no longer
> "zero-annotation" — pending proposals now render optimistically applied
> in-buffer with `✓ Keep`/`✗ Reject` CodeLens (F12), and review happens across
> the native diff editor + built-in preview, not one dedicated webview. See
> **How it works** above. Kept below as a historical record.
A clean, **zero-annotation editor** on the left; the rendered preview on the A clean, **zero-annotation editor** on the left; the rendered preview on the
right as the **single interactive review surface**. The editor carries no right as the **single interactive review surface**. The editor carries no
@@ -266,7 +341,13 @@ they are no longer separate user surfaces.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`. Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`.
Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md). Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md).
## F11 — Preview toolbar as the primary interaction surface (Feature #43) ## F11 — Preview toolbar as the primary interaction surface (Feature #43) — SUPERSEDED
> **Superseded (Task 8, native-surfaces migration).** The webview toolbar this
> section describes was deleted; its two controls moved to native chrome —
> **Pin baseline** is now **"Mark Changes as Reviewed"** in the editor title
> bar, and **Ask Claude** is the comments-first ask (How it works §3). Kept
> below as a historical record.
The review preview's **header toolbar** becomes the cockpit for the inner loop. The review preview's **header toolbar** becomes the cockpit for the inner loop.
Beside the existing **Annotations** switch it gains two controls: Beside the existing **Annotations** switch it gains two controls:
@@ -863,7 +863,9 @@ git add src/ package.json test/
git commit -m "feat: gate every surface on CoeditingRegistry (INV-10) — commenting ranges re-assigned on gate change (spec §6.4 v0.2.1)" git commit -m "feat: gate every surface on CoeditingRegistry (INV-10) — commenting ranges re-assigned on gate change (spec §6.4 v0.2.1)"
``` ```
---### Task 5: Extract the edit flow from the webview controller ---
### Task 5: Extract the edit flow from the webview controller
**Files:** **Files:**
- Create: `src/editFlow.ts` - Create: `src/editFlow.ts`
@@ -1227,7 +1229,7 @@ git commit -m "feat!: sunset the review-panel webview — built-in preview + nat
- Create: `test/e2e/suite/fullLoop.test.ts`, `scripts/smoke-native-loop.mjs` - Create: `test/e2e/suite/fullLoop.test.ts`, `scripts/smoke-native-loop.mjs`
- Modify: `package.json` (script `smoke:native`) - Modify: `package.json` (script `smoke:native`)
- [ ] **Step 1: The named full loop (§6.8), stub-turned:** one E2E that walks PUC-7 → PUC-8 → PUC-2 → PUC-1 in a single doc: enter (snapshot) → thread ask (stub turn) → offer → pending proposals decorated (assert `listProposals` + `isApplied`) → tweak by typing inside a pending range (assert re-anchor: proposal still resolvable) → Keep one (assert attribution split via `spansFor`: claude words + human tweak) → Reject one (assert buffer text equals pre-proposal text for that range **exactly** — INV-5) → `cowriting.markReviewed` → change count 0. - [ ] **Step 1: The named full loop (§6.8), stub-turned:** one E2E that walks PUC-7 → PUC-8 → PUC-2 → PUC-1 in a single doc: enter (snapshot) → thread ask (stub turn) → offer → pending proposals decorated (assert `listProposals` + `isApplied`) → tweak by typing inside a pending range (assert the documented anchoring contract: the exact-substring anchor orphans — INV-1/INV-11 — but the proposal is NOT lost: it stays listed and Keep still lands it, because `finalizeInPlace` looks up by id and bypasses resolution by design) → Keep one (assert attribution split via `spansFor`: claude words + human tweak) → Reject one (assert buffer text equals pre-proposal text for that range **exactly** — INV-5; note: the reject candidate must be un-tweaked — reject-after-interior-edit is a known pre-existing F12 INV-5 gap, tracked as a follow-up issue) → `cowriting.markReviewed` → change count 0. *(Corrected during execution: the original text asserted "re-anchor: proposal still resolvable" after an interior tweak, which contradicts the shipped exact-substring anchorer contract.)*
- [ ] **Step 2: Live smoke (rung 3 — the real SDK, manual gate):** `scripts/smoke-native-loop.mjs` mirrors `scripts/smoke-live-turn.mjs` but through the comment loop: launches the EDH, enters a sandbox doc, posts a real comment, waits for the real `@cline/sdk` reply + offer, accepts, prints the created proposal ids. `npm run smoke:native`. This is operator-run (real tokens); the plan's definition of done for rung 3 is one green run reported in the session transcript. - [ ] **Step 2: Live smoke (rung 3 — the real SDK, manual gate):** `scripts/smoke-native-loop.mjs` mirrors `scripts/smoke-live-turn.mjs` but through the comment loop: launches the EDH, enters a sandbox doc, posts a real comment, waits for the real `@cline/sdk` reply + offer, accepts, prints the created proposal ids. `npm run smoke:native`. This is operator-run (real tokens); the plan's definition of done for rung 3 is one green run reported in the session transcript.
- [ ] **Step 3: Verify + commit.** - [ ] **Step 3: Verify + commit.**
+13 -10
View File
@@ -31,13 +31,14 @@ const liveTurnOptions = {
}; };
/** @type {import('esbuild').BuildOptions} */ /** @type {import('esbuild').BuildOptions} */
const previewOptions = { const previewMermaidOptions = {
entryPoints: ["media/preview.ts"], entryPoints: ["media/preview-mermaid.ts"],
outfile: "out/media/preview.js", outfile: "out/media/preview-mermaid.js",
bundle: true, bundle: true,
// The webview is a browser context; mermaid is bundled IN (and ONLY in) this // Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN
// asset so it never bloats the extension-host bundle (the @cline/sdk size // preview's webview (a browser context) — mermaid is bundled IN (and ONLY
// discipline). No externals — everything is shipped to the sealed webview. // in) this asset so it never bloats the extension-host bundle (the
// @cline/sdk size discipline).
platform: "browser", platform: "browser",
format: "iife", format: "iife",
target: "es2020", target: "es2020",
@@ -48,12 +49,14 @@ const previewOptions = {
if (watch) { if (watch) {
const ctx = await context(options); const ctx = await context(options);
const ctxLive = await context(liveTurnOptions); const ctxLive = await context(liveTurnOptions);
const ctxPreview = await context(previewOptions); const ctxPreviewMermaid = await context(previewMermaidOptions);
await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]); await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreviewMermaid.watch()]);
console.log("esbuild: watching…"); console.log("esbuild: watching…");
} else { } else {
await build(options); await build(options);
await build(liveTurnOptions); await build(liveTurnOptions);
await build(previewOptions); await build(previewMermaidOptions);
console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js"); console.log(
"esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview-mermaid.js",
);
} }
+55
View File
@@ -0,0 +1,55 @@
/*
* Task 7 (D3/D21, PUC-3) — authorship + change annotations for the BUILT-IN VS
* Code Markdown preview (`markdown.previewStyles`). Same F10 vocabulary as the
* (sunsetting) custom webview's `media/preview.css`: style = operation
* (underline = inserted, strikethrough = removed), color = author (human
* green, Claude blue) — `cw-del` has no author variant (a single fixed
* struck-red mark; see `previewAnnotations.ts`/`trackChangesModel.ts`'s "del"
* sentinel tag). Light-theme defaults below; `body.vscode-dark` (also applied
* on high-contrast dark) overrides with the same palette the webview preview
* already ships, for continuity across both review surfaces.
*/
.cw-ins-claude {
background: rgba(9, 105, 218, 0.12);
border-bottom: 2px solid #0969da;
text-decoration: none;
}
.cw-ins-human {
background: rgba(26, 127, 55, 0.12);
border-bottom: 2px solid #1a7f37;
text-decoration: none;
}
.cw-del {
background: rgba(207, 34, 46, 0.1);
text-decoration: line-through;
text-decoration-color: #cf222e;
opacity: 0.75;
}
body.vscode-dark .cw-ins-claude,
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-claude {
background: rgba(88, 166, 255, 0.15);
border-bottom-color: #58a6ff;
}
body.vscode-dark .cw-ins-human,
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-human {
background: rgba(63, 185, 80, 0.14);
border-bottom-color: #3fb950;
}
body.vscode-dark .cw-del,
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-del {
background: rgba(248, 81, 73, 0.11);
text-decoration-color: #f85149;
}
/*
* F7.1 (#22) intra-diagram mermaid diff legend, shown beneath a mermaid fence
* `previewAnnotations.ts` re-emitted through `mermaidDiff.ts` (Task 7 §2.6
* parity — same legend markup + colors as the sunset webview's
* `media/preview.css`).
*/
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
+57
View File
@@ -0,0 +1,57 @@
/**
* Task 7 (Q4 mermaid check, D3/D21): the built-in VS Code Markdown preview does
* not render mermaid fences on its own — `previewAnnotations.ts`'s
* `cowritingMarkdownItPlugin` teaches ```mermaid fences to render as
* `<pre class="mermaid">SRC</pre>` (via `options.highlight`, the same
* extension point the built-in preview's own fence rule already calls); this
* script (contributed via `markdown.previewScripts`) is what actually turns
* those into diagrams — mermaid needs a DOM, so it runs here, in the preview's
* webview, never in the extension host. Bundled by esbuild as a standalone
* IIFE → out/media/preview-mermaid.js, so mermaid never enters the host
* bundle (matching the sealed webview's `media/preview.ts` precedent).
*
* Contributed preview scripts are reloaded on every content change (VS Code
* docs), so running at module top-level is sufficient; the
* `vscode.markdown.updateContent` listener is added defensively to also cover
* in-place content updates that don't reload the script (mirrors the proven
* `bierner.markdown-mermaid` extension's own approach).
*
* RESOLVED SCOPE NOTE (Q4 finding, migration plan Task 7 Step 2.6; wired in a
* cross-task review follow-up): this script itself only turns `pre.mermaid`
* nodes into diagrams — it never sees a diff. The intra-diagram diff/legend
* augmentation (F7.1, INV-29..31) happens one step earlier, source-side,
* where it CAN be unit-tested without a DOM: `previewAnnotations.ts`'s
* `cowritingMarkdownItPlugin` re-emits a CHANGED mermaid fence's source
* through `mermaidDiff.ts` (`buildMermaidQueue`, block-level `diffBlocks`
* pairing against the F6/F7 baseline) before this script ever runs, so the
* `pre.mermaid` node this script hands to `mermaid.run` already carries the
* `classDef`/`class`/`linkStyle` styling directives — this script needn't (and
* can't, DOM-less-ly) know a diagram changed at all. Basic mermaid rendering
* (this file) is exercised by the same proven pattern as the real
* `bierner.markdown-mermaid` extension; the augmentation upstream is exercised
* by pure unit tests (`test/previewAnnotations.test.ts`).
*/
import mermaid from "mermaid";
function theme(): "dark" | "default" {
return document.body.classList.contains("vscode-dark") || document.body.classList.contains("vscode-high-contrast")
? "dark"
: "default";
}
async function run(): Promise<void> {
const nodes = Array.from(document.querySelectorAll<HTMLElement>("pre.mermaid"));
if (nodes.length === 0) return;
mermaid.initialize({ startOnLoad: false, theme: theme(), securityLevel: "strict" });
try {
await mermaid.run({ nodes });
} catch {
// mermaid.run already marks failed nodes; ensure a visible chip per failure.
for (const n of nodes) {
if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true");
}
}
}
window.addEventListener("vscode.markdown.updateContent", () => void run());
void run();
-118
View File
@@ -1,118 +0,0 @@
/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
padding: 0 1.2rem 2rem;
line-height: 1.5;
}
#cw-header {
position: sticky;
top: 0;
background: var(--vscode-editor-background);
border-bottom: 1px solid var(--vscode-panel-border);
padding: 0.5rem 0;
font-size: 0.85em;
opacity: 0.85;
display: flex;
gap: 1rem;
}
/* Author-colored track changes — style = operation, color = author.
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
#cw-summary .cw-add { color: #3fb950; }
#cw-summary .cw-del { color: #f85149; }
.cw-blk { position: relative; }
/* base ins/del carry the operation; author classes carry the color */
ins { text-decoration: none; }
del { text-decoration: line-through; opacity: 0.7; }
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B},
both marks must show. cw-ins-* sets text-decoration:none which would otherwise
suppress the parent del's strikethrough. `inherit` restores the parent's
line-through while the child's border-bottom underline is unaffected.
The engine currently emits SIBLING ins/del (never nested) so this rule is a
forward defensive guarantee only. See task-8-report.md for details. */
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; }
/* whole-block ops: an added block is an insertion (its author runs are emitted as
cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral
struck (adjacency heuristic fallback) */
.cw-added { background: transparent; }
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
.cw-changed { outline: none; }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
.cw-badge {
position: absolute;
top: 0;
right: 0;
font-size: 0.7em;
padding: 0 0.4em;
border-radius: 3px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.cw-error {
border: 1px solid var(--vscode-inputValidation-errorBorder);
background: var(--vscode-inputValidation-errorBackground);
color: var(--vscode-errorForeground);
padding: 0.3rem 0.6rem;
border-radius: 3px;
font-size: 0.85em;
}
pre.mermaid { text-align: center; background: transparent; }
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
#cw-header button {
cursor: pointer;
font: inherit;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px;
padding: 0.1em 0.55em;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
#cw-header button:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); }
#cw-header button:disabled { opacity: 0.5; cursor: default; }
/* F10 interactive review — annotations toggle + ✓/✗ proposal blocks. */
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
.cw-proposal {
position: relative;
border-left: 3px solid var(--vscode-panel-border, #555);
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px;
}
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
.cw-actions { position: absolute; top: 0.2em; right: 0.4em; display: inline-flex; gap: 0.25em; }
.cw-actions button {
cursor: pointer; border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px; font-size: 0.9em; line-height: 1; padding: 0.1em 0.35em;
background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground);
}
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
.cw-btngroup { display: inline-flex; }
.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; }
.cw-actions .cw-accept { font-weight: 600; }
.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
/* F7.1 (#22) intra-diagram mermaid diff legend. */
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
-157
View File
@@ -1,157 +0,0 @@
/**
* F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML
* from the extension host and swaps it in; runs mermaid over `.mermaid` blocks
* (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as
* a standalone IIFE → out/media/preview.js, so mermaid never enters the host
* bundle. No network, no LLM.
*/
import mermaid from "mermaid";
// Imported so esbuild emits the sibling out/media/preview.css (the controller
// links it into the sealed shell via asWebviewUri).
import "./preview.css";
declare function acquireVsCodeApi(): { postMessage(m: unknown): void };
interface RenderMessage {
type: "render";
mode: "on" | "off";
html: string;
epoch?: string;
summary?: { added: number; removed: number; proposals: number };
/** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */
authorable?: boolean;
}
const vscodeApi = acquireVsCodeApi();
const body = document.getElementById("cw-body")!;
const header = document.getElementById("cw-epoch")!;
const summary = document.getElementById("cw-summary")!;
const legend = document.getElementById("cw-legend")!;
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null;
const acceptAllEl = document.getElementById("cw-acceptall") as HTMLButtonElement | null;
// F10: the annotations on/off toggle.
annotationsEl?.addEventListener("change", () => {
vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" });
});
// F11 (SLICE-1): Pin baseline — post intent; the host pins via the F6 store (INV-35).
pinEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "pinBaseline" });
});
// F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on
// `selectionchange` (Edit Selection when live text is selected in the preview,
// Edit Document otherwise), and a click resolves the selection to a SOURCE range
// via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping
// duty. A selection that resolves to no live block falls back to document scope.
/** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */
function nearestSrc(node: Node | null): HTMLElement | null {
let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null);
while (el && el !== body) {
if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el;
el = el.parentElement;
}
return null;
}
/** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */
function selectionSrcRange(): { start: number; end: number } | null {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter(
(e): e is HTMLElement => e !== null,
);
if (ends.length === 0) return null; // selection touches no live-source block
const starts = ends.map((e) => Number(e.dataset.srcStart));
const stops = ends.map((e) => Number(e.dataset.srcEnd));
return { start: Math.min(...starts), end: Math.max(...stops) };
}
function updateAskLabel(): void {
if (!askEl) return;
askEl.textContent = selectionSrcRange()
? "✦ Ask Claude to Edit Selection"
: "✦ Ask Claude to Edit Document";
}
document.addEventListener("selectionchange", updateAskLabel);
askEl?.addEventListener("click", () => {
const range = selectionSrcRange();
if (range) {
vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end });
} else {
vscodeApi.postMessage({ type: "askClaude", scope: "document" });
}
});
// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only).
acceptAllEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "acceptAll" });
});
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block.
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return;
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId;
const action = btn.dataset.action;
if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" });
if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" });
if (id && (action === "accept" || action === "reject")) {
vscodeApi.postMessage({ type: action, proposalId: id });
}
});
function themeFor(): "dark" | "default" {
return document.body.classList.contains("vscode-dark") ||
document.body.classList.contains("vscode-high-contrast")
? "dark"
: "default";
}
async function renderMermaid(): Promise<void> {
const nodes = Array.from(body.querySelectorAll<HTMLElement>("pre.mermaid"));
if (nodes.length === 0) return;
mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" });
try {
await mermaid.run({ nodes });
} catch {
// mermaid.run already marks failed nodes; ensure a visible chip per failure.
for (const n of nodes) {
if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true");
}
}
}
window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
const msg = event.data;
if (msg?.type !== "render") return;
body.innerHTML = msg.html;
updateAskLabel(); // new content clears any selection → reset the adaptive label
// F11 (PUC-1/7): disable edit controls on a non-authorable doc (reading stays on).
const authorable = msg.authorable !== false;
if (pinEl) pinEl.disabled = !authorable;
if (askEl) askEl.disabled = !authorable;
const on = msg.mode === "on";
if (annotationsEl) annotationsEl.checked = on;
// #46: Accept all shows only with ≥2 pending proposals, on an authorable doc,
// in the annotated (on) state — a single proposal is just a ✓ in place.
if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2;
// Off-state is a clean preview: hide the review chrome.
header.hidden = !on;
summary.hidden = !on;
legend.hidden = true;
if (on) {
header.textContent = `Review since ${msg.epoch ?? ""}`;
summary.innerHTML =
`<span class="cw-add">+${msg.summary?.added ?? 0}</span> ` +
`<span class="cw-del">${msg.summary?.removed ?? 0}</span> ` +
`<span class="cw-prop">${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}</span>`;
}
void renderMermaid();
});
+125 -21
View File
@@ -18,6 +18,13 @@
"onStartupFinished" "onStartupFinished"
], ],
"contributes": { "contributes": {
"markdown.markdownItPlugins": true,
"markdown.previewStyles": [
"./media/preview-annotations.css"
],
"markdown.previewScripts": [
"./out/media/preview-mermaid.js"
],
"configuration": { "configuration": {
"title": "Cowriting", "title": "Cowriting",
"properties": { "properties": {
@@ -25,6 +32,11 @@
"type": "boolean", "type": "boolean",
"default": true, "default": true,
"description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams." "description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams."
},
"cowriting.annotations": {
"type": "boolean",
"default": true,
"description": "Show authorship + change annotations in the Markdown preview for coedited documents."
} }
} }
}, },
@@ -85,13 +97,20 @@
"category": "Cowriting" "category": "Cowriting"
}, },
{ {
"command": "cowriting.pinDiffBaseline", "command": "cowriting.markReviewed",
"title": "Cowriting: Pin Review Baseline to Now", "title": "Mark Changes as Reviewed",
"category": "Cowriting" "category": "Cowriting",
"icon": "$(check-all)"
}, },
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.reviewChanges",
"title": "Open Cowriting Review Panel", "title": "Review Changes",
"category": "Cowriting",
"icon": "$(git-compare)"
},
{
"command": "cowriting.openReviewPreview",
"title": "Open Cowriting Review Preview",
"category": "Cowriting" "category": "Cowriting"
}, },
{ {
@@ -118,6 +137,34 @@
"command": "cowriting.proposalRejectMenu", "command": "cowriting.proposalRejectMenu",
"title": "Reject Claude Proposal", "title": "Reject Claude Proposal",
"category": "Cowriting" "category": "Cowriting"
},
{
"command": "cowriting.coeditDocument",
"title": "✦ Coedit this Document with Claude",
"category": "Cowriting"
},
{
"command": "cowriting.stopCoediting",
"title": "Stop editing with Claude",
"category": "Cowriting"
},
{
"command": "cowriting.askClaude",
"title": "Ask Claude",
"category": "Cowriting",
"icon": "$(sparkle)"
},
{
"command": "cowriting.makeThreadEdit",
"title": "✦ Make this edit",
"category": "Cowriting",
"icon": "$(sparkle)"
},
{
"command": "cowriting.toggleAnnotations",
"title": "Toggle Annotations",
"category": "Cowriting",
"icon": "$(eye)"
} }
], ],
"menus": { "menus": {
@@ -139,8 +186,12 @@
"when": "false" "when": "false"
}, },
{ {
"command": "cowriting.pinDiffBaseline", "command": "cowriting.reviewChanges",
"when": "editorLangId == markdown" "when": "editorLangId == markdown && cowriting.isCoediting"
},
{
"command": "cowriting.markReviewed",
"when": "editorLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot"
}, },
{ {
"command": "cowriting.edit", "command": "cowriting.edit",
@@ -154,13 +205,25 @@
"command": "cowriting.editDocument", "command": "cowriting.editDocument",
"when": "false" "when": "false"
}, },
{
"command": "cowriting.askClaude",
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{
"command": "cowriting.toggleAnnotations",
"when": "editorLangId == markdown && cowriting.isCoediting"
},
{
"command": "cowriting.makeThreadEdit",
"when": "false"
},
{ {
"command": "cowriting.acceptAllProposals", "command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown" "when": "editorLangId == markdown && cowriting.isCoediting"
}, },
{ {
"command": "cowriting.rejectAllProposals", "command": "cowriting.rejectAllProposals",
"when": "editorLangId == markdown" "when": "editorLangId == markdown && cowriting.isCoediting"
}, },
{ {
"command": "cowriting.proposalAcceptMenu", "command": "cowriting.proposalAcceptMenu",
@@ -173,7 +236,27 @@
], ],
"editor/title": [ "editor/title": [
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.reviewChanges",
"when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "navigation@1"
},
{
"command": "cowriting.askClaude",
"when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "navigation@2"
},
{
"command": "cowriting.toggleAnnotations",
"when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "navigation@3"
},
{
"command": "cowriting.markReviewed",
"when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot",
"group": "navigation@4"
},
{
"command": "cowriting.openReviewPreview",
"when": "editorLangId == markdown", "when": "editorLangId == markdown",
"group": "navigation@9" "group": "navigation@9"
} }
@@ -181,18 +264,18 @@
"editor/title/context": [ "editor/title/context": [
{ {
"command": "cowriting.edit", "command": "cowriting.edit",
"when": "resourceLangId == markdown", "when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "1_cowriting@1" "group": "1_cowriting@1"
}, },
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.openReviewPreview",
"when": "resourceLangId == markdown", "when": "resourceLangId == markdown",
"group": "1_cowriting@3" "group": "1_cowriting@3"
} }
], ],
"explorer/context": [ "explorer/context": [
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.openReviewPreview",
"when": "resourceLangId == markdown", "when": "resourceLangId == markdown",
"group": "navigation@9" "group": "navigation@9"
} }
@@ -200,47 +283,67 @@
"editor/context": [ "editor/context": [
{ {
"command": "cowriting.edit", "command": "cowriting.edit",
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)", "when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting",
"group": "1_cowriting@1" "group": "1_cowriting@1"
}, },
{ {
"command": "cowriting.createThread", "command": "cowriting.createThread",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting",
"group": "1_cowriting@2" "group": "1_cowriting@2"
},
{
"command": "cowriting.coeditDocument",
"when": "editorLangId == markdown && !cowriting.isCoediting",
"group": "1_cowriting@1"
},
{
"command": "cowriting.stopCoediting",
"when": "editorLangId == markdown && cowriting.isCoediting",
"group": "1_cowriting@9"
} }
], ],
"comments/commentThread/context": [ "comments/commentThread/context": [
{ {
"command": "cowriting.reply", "command": "cowriting.reply",
"group": "inline", "group": "inline@1",
"when": "commentController == cowriting.threads" "when": "commentController == cowriting.threads"
},
{
"command": "cowriting.makeThreadEdit",
"group": "inline@2",
"when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
} }
], ],
"comments/commentThread/title": [ "comments/commentThread/title": [
{
"command": "cowriting.makeThreadEdit",
"group": "inline@1",
"when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/"
},
{ {
"command": "cowriting.resolveThread", "command": "cowriting.resolveThread",
"group": "inline", "group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^open$/" "when": "commentController == cowriting.threads && commentThread =~ /\\bopen\\b/"
}, },
{ {
"command": "cowriting.reopenThread", "command": "cowriting.reopenThread",
"group": "inline", "group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" "when": "commentController == cowriting.threads && commentThread =~ /\\bresolved\\b/"
} }
] ]
}, },
"keybindings": [ "keybindings": [
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.reviewChanges",
"key": "ctrl+alt+r", "key": "ctrl+alt+r",
"mac": "cmd+alt+r", "mac": "cmd+alt+r",
"when": "editorLangId == markdown" "when": "editorLangId == markdown && cowriting.isCoediting"
}, },
{ {
"command": "cowriting.edit", "command": "cowriting.edit",
"key": "ctrl+alt+e", "key": "ctrl+alt+e",
"mac": "cmd+alt+e", "mac": "cmd+alt+e",
"when": "editorTextFocus && editorLangId == markdown" "when": "editorTextFocus && editorLangId == markdown && cowriting.isCoediting"
} }
] ]
}, },
@@ -253,6 +356,7 @@
"pretest:e2e": "npm run build && npm run clean:e2e && tsc -p tsconfig.e2e.json", "pretest:e2e": "npm run build && npm run clean:e2e && tsc -p tsconfig.e2e.json",
"test:e2e": "node ./out/test/e2e/runTest.js", "test:e2e": "node ./out/test/e2e/runTest.js",
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs", "smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
"smoke:native": "npm run pretest:e2e && node scripts/smoke-native-loop.mjs",
"vscode:prepublish": "node esbuild.mjs" "vscode:prepublish": "node esbuild.mjs"
}, },
"dependencies": { "dependencies": {
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Task 9 / spec §7.1 rung 3: the manual, real-@cline/sdk smoke for the native
// comment loop -- mirrors scripts/smoke-live-turn.mjs's role for F3 (a live
// login-backed check, deliberately NOT in CI), but this one drives the whole
// EDH (comment -> reply -> offer -> proposal needs vscode's own comment/doc
// APIs, unlike smoke-live-turn.mjs's bare module call) via
// test/e2e/smoke/nativeLoop.ts, launched exactly like test/e2e/runTest.ts
// launches the regular host-E2E suites -- just against a single smoke entry
// point that is NOT swept into `npm run test:e2e` (it lives outside
// test/e2e/suite, which is the only directory suite/index.ts's mocha glob
// searches).
//
// Prereqs: `npm run pretest:e2e` (builds the extension + compiles
// test/e2e/**). `npm run smoke:native` does this for you.
//
// Opens the real sandbox/ folder (the repo's EDH workspace root), writes a
// throwaway doc, posts a real comment via ThreadController.createThreadOnSelection,
// waits for the real @cline/sdk reply + offer, makes + accepts the edit, then
// prints the created proposal ids and deletes the throwaway doc.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { runTests } from "@vscode/test-electron";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function main() {
const projectRoot = path.resolve(__dirname, "..");
const extensionDevelopmentPath = projectRoot;
const extensionTestsPath = path.resolve(projectRoot, "out/test/e2e/smoke/nativeLoop");
const sandbox = path.resolve(projectRoot, "sandbox");
if (!fs.existsSync(`${extensionTestsPath}.js`)) {
console.error(
`smoke-native-loop: ${extensionTestsPath}.js is missing -- run "npm run pretest:e2e" first (or "npm run smoke:native", which does this for you).`,
);
process.exit(1);
}
// Same macOS UNIX-socket-length workaround as test/e2e/runTest.ts (a long
// worktree path + the default .vscode-test/user-data dir can blow past the
// ~103-char limit).
const userDataRoot = process.platform === "win32" ? os.tmpdir() : "/tmp";
const userDataDir = fs.mkdtempSync(path.join(userDataRoot, "cwud-smoke-"));
try {
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [sandbox, "--disable-extensions", "--user-data-dir", userDataDir],
});
console.log("smoke-native-loop: PASS");
process.exit(0);
} catch (err) {
console.error(
`smoke-native-loop: FAILED (expected when Claude Code is absent/signed out, or the sandbox/ folder is missing): ${
err instanceof Error ? err.message : String(err)
}`,
);
process.exit(1);
} finally {
fs.rmSync(userDataDir, { recursive: true, force: true });
}
}
main();
+39 -7
View File
@@ -21,6 +21,7 @@ import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
import type { VersionGuard } from "./versionGuard"; import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { AuthorSpan } from "./trackChangesModel"; import type { AuthorSpan } from "./trackChangesModel";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** Test-facing snapshot of live attribution state for a document. */ /** Test-facing snapshot of live attribution state for a document. */
export interface RenderedSpan { export interface RenderedSpan {
@@ -56,8 +57,11 @@ export class AttributionController implements vscode.Disposable {
/** /**
* F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real * F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real
* (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit * (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit
* ingress, so this is the sole signal — DiffViewController subscribes to * ingress, so this is the sole signal. Native-surfaces migration (INV-7/
* advance the baseline; no call-site wiring, no future driver can forget it. * INV-18 retirement, spec §6.4): DiffViewController no longer subscribes
* here to advance the baseline — a landed edit stays a visible
* change-since-baseline until commit (head mode) or "Mark Changes as
* Reviewed" (snapshot mode). Other consumers may still subscribe.
*/ */
private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>(); private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>();
readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event; readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
@@ -66,12 +70,35 @@ export class AttributionController implements vscode.Disposable {
private readonly store: SidecarRouter, private readonly store: SidecarRouter,
private readonly rootDir: string | undefined, private readonly rootDir: string | undefined,
private readonly guard: VersionGuard, private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
) { ) {
this.disposables.push(this.statusItem, this.output, this.applyEmitter); this.disposables.push(this.statusItem, this.output, this.applyEmitter);
this.disposables.push( this.disposables.push(
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()), vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()),
// PUC-7 restore-on-enter: `loadAll` is the only path that (re)populates
// s.spans/s.orphans from the sidecar — without this, a document's FIRST
// entry into coediting (registry.enter(), extension.ts) never runs it,
// so pre-existing committed authorship never surfaces until the next
// edit. Mirrors ThreadController's own registry subscription; `loadAll`
// already gates on isCoediting, and exit intentionally does nothing here
// (no data to wipe — EditorProposalController's own gate clears the
// decorations that read spansFor). Registered before ProposalController/
// EditorProposalController (construction order in extension.ts) so their
// renders see fresh spans on the same enter transition.
this.registry.onDidChange(({ uri, coediting }) => {
if (!coediting) {
// Finding 5 (final whole-branch review): exit hides the status-bar
// item too — otherwise a stale "N orphaned attributions" warning
// for a doc that's no longer even coediting stays pinned in the bar
// until some unrelated render happens to fire for it.
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
return;
}
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.loadAll(doc);
}),
); );
} }
@@ -101,6 +128,7 @@ export class AttributionController implements vscode.Disposable {
/** Load the sidecar and re-resolve every attribution (live span | orphan). */ /** Load the sidecar and re-resolve every attribution (live span | orphan). */
loadAll(document: vscode.TextDocument): void { loadAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return; if (!this.isTracked(document)) return;
if (!this.registry.isCoediting(document.uri)) return;
const docPath = this.keyOf(document); const docPath = this.keyOf(document);
const s = this.state(docPath); const s = this.state(docPath);
const artifact = this.store.load(docPath); const artifact = this.store.load(docPath);
@@ -142,6 +170,7 @@ export class AttributionController implements vscode.Disposable {
private onDidChange(e: vscode.TextDocumentChangeEvent): void { private onDidChange(e: vscode.TextDocumentChangeEvent): void {
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return; if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
if (!this.registry.isCoediting(e.document.uri)) return;
const docPath = this.keyOf(e.document); const docPath = this.keyOf(e.document);
if (!e.document.isDirty && this.matchesDisk(e.document)) { if (!e.document.isDirty && this.matchesDisk(e.document)) {
// Disk sync (revert / external reload): buffer now equals the file on // Disk sync (revert / external reload): buffer now equals the file on
@@ -291,9 +320,10 @@ export class AttributionController implements vscode.Disposable {
); );
} }
if (ok && opts?.landBaseline !== false) { if (ok && opts?.landBaseline !== false) {
// F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48) // F6 (INV-18, retired as a baseline advance — spec §6.4/INV-7): fire the
// suppresses this for optimistic apply: the proposed text is in the buffer // machine-landing signal. F12 (INV-48) suppresses this for optimistic
// but the change stays PENDING (baseline at pre-proposal) until accept. // apply: the proposed text is in the buffer but the change stays PENDING
// until accept.
this.applyEmitter.fire({ document }); this.applyEmitter.fire({ document });
} }
return ok; return ok;
@@ -302,8 +332,10 @@ export class AttributionController implements vscode.Disposable {
/** /**
* F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used * F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used
* by finalize-in-place, where the proposed text already landed in the buffer via * by finalize-in-place, where the proposed text already landed in the buffer via
* optimistic apply (`landBaseline:false`) and accept only needs to advance the * optimistic apply (`landBaseline:false`) and accept marks the landing so the
* F6 baseline so the now-accepted change stops reading as pending. * now-accepted change is recorded (INV-7: the baseline itself no longer
* advances on landing — it advances only on commit / "Mark Changes as
* Reviewed").
*/ */
signalLanded(document: vscode.TextDocument): void { signalLanded(document: vscode.TextDocument): void {
if (this.isTracked(document)) this.applyEmitter.fire({ document }); if (this.isTracked(document)) this.applyEmitter.fire({ document });
+17 -2
View File
@@ -7,11 +7,19 @@
* key is a hash of the document URI (the controller derives it), not a * key is a hash of the document URI (the controller derives it), not a
* workspace-relative path; an untitled buffer has no durable identity and is * workspace-relative path; an untitled buffer has no durable identity and is
* never persisted here (in-memory only — see DiffViewController). * never persisted here (in-memory only — see DiffViewController).
*
* INV-7 (native-surfaces migration, spec §6.4): the baseline is now either the
* git-HEAD blob (never persisted — see GitBaselineAdapter/DiffViewController)
* or a snapshot captured on coediting-entry / re-pinned by "Mark Changes as
* Reviewed". `"opened"`/`"machine-landing"` are legacy reasons from the
* shipped #17/#48 baseline; `normalizeReason` migrates them on load so old
* on-disk baselines keep round-tripping under the new vocabulary.
*/ */
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
export type BaselineReason = "opened" | "machine-landing" | "pinned"; export type BaselineReason = "entered" | "pinned" | "head";
type LegacyBaselineReason = BaselineReason | "opened" | "machine-landing";
export interface Baseline { export interface Baseline {
/** the document URI (the identity the key is derived from) — for round-trip/debug. */ /** the document URI (the identity the key is derived from) — for round-trip/debug. */
@@ -22,6 +30,12 @@ export interface Baseline {
reason: BaselineReason; reason: BaselineReason;
} }
/** Migrate legacy on-disk reasons to the current vocabulary (§6.4 INV-7). */
export function normalizeReason(reason: LegacyBaselineReason): BaselineReason {
if (reason === "opened" || reason === "machine-landing") return "entered";
return reason;
}
export class BaselineStore { export class BaselineStore {
/** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */ /** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */
constructor(private readonly storageDir: string) {} constructor(private readonly storageDir: string) {}
@@ -34,7 +48,8 @@ export class BaselineStore {
load(key: string): Baseline | null { load(key: string): Baseline | null {
const p = this.baselinePath(key); const p = this.baselinePath(key);
if (!fs.existsSync(p)) return null; if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline; const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
return { ...raw, reason: normalizeReason(raw.reason) };
} }
/** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */ /** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */
+57
View File
@@ -0,0 +1,57 @@
/**
* CoeditingRegistry — the INV-10 activation gate (spec §6.4). Holds the set of
* documents the writer has explicitly entered into coediting; every surface
* (SCM/QuickDiff, comments, preview annotations, edit commands, decorations)
* checks membership before attaching. Persisted in workspaceState so a reload
* restores the set (the durable sidecar carries the artifacts; this carries
* only the mode).
*/
import * as vscode from "vscode";
const STATE_KEY = "cowriting.coeditingSet";
export class CoeditingRegistry implements vscode.Disposable {
private readonly set: Set<string>;
private readonly emitter = new vscode.EventEmitter<{ uri: string; coediting: boolean }>();
readonly onDidChange = this.emitter.event;
/** Set by the SCM surface (Task 3) so syncContext can expose the baseline mode. */
baselineModeOf: ((uri: vscode.Uri) => "head" | "snapshot" | undefined) | undefined;
constructor(private readonly state: vscode.Memento) {
this.set = new Set(state.get<string[]>(STATE_KEY, []));
}
enter(uri: vscode.Uri): void {
const key = uri.toString();
if (this.set.has(key)) return;
this.set.add(key);
void this.state.update(STATE_KEY, [...this.set]);
this.emitter.fire({ uri: key, coediting: true });
}
exit(uri: vscode.Uri): void {
const key = uri.toString();
if (!this.set.delete(key)) return;
void this.state.update(STATE_KEY, [...this.set]);
this.emitter.fire({ uri: key, coediting: false });
}
isCoediting(uri: vscode.Uri): boolean {
return this.set.has(uri.toString());
}
list(): string[] {
return [...this.set];
}
syncContext(editor: vscode.TextEditor | undefined): void {
const coediting = !!editor && this.isCoediting(editor.document.uri);
void vscode.commands.executeCommand("setContext", "cowriting.isCoediting", coediting);
const mode = coediting && editor ? this.baselineModeOf?.(editor.document.uri) : undefined;
void vscode.commands.executeCommand("setContext", "cowriting.baselineMode", mode ?? "");
}
dispose(): void {
this.emitter.dispose();
}
}
+121 -38
View File
@@ -1,9 +1,16 @@
/** /**
* DiffViewController — F6 baseline data layer (spec §6.4/§6.7). Owns the baseline * DiffViewController — the baseline router (native-surfaces migration, spec
* lifecycle (initialize at first sight / advance at every machine landing / pin * §6.4, INV-7). Resolves each coedited document's review baseline to one of two
* on demand) and serves it to F7/F10 via `getBaseline` + the additive * modes: **head** (git-tracked — the baseline is the `HEAD` blob, re-read on
* `onDidChangeBaseline` event. A pure data layer: never mutates the document, * every commit, never persisted) or **snapshot** (untracked/no-repo — captured
* sidecar, or attribution state (INV-19). * on coediting-entry, re-pinned on demand by "Mark Changes as Reviewed").
* Baselines are established ONLY for documents the CoeditingRegistry has
* entered (INV-10) — opening a file no longer captures one (the retired
* `ensureBaseline`/#17 behavior). The shipped machine-landing advance (#48,
* INV-18) is retired too: a landed Claude edit stays a visible change-since-
* baseline until the file is committed (head mode) or reviewed (snapshot
* mode) — spec INV-7's note, D21. A pure data layer: never mutates the
* document, sidecar, or attribution state (INV-19).
* *
* The F6 two-pane `vscode.diff` *view* (toggle UI + the `cowriting-baseline:` * The F6 two-pane `vscode.diff` *view* (toggle UI + the `cowriting-baseline:`
* virtual document) was removed in #34 once F10 made the rendered preview the * virtual document) was removed in #34 once F10 made the rendered preview the
@@ -12,37 +19,57 @@
* The baseline works on ANY text document, not just workspace files — it needs no * The baseline works on ANY text document, not just workspace files — it needs no
* `.threads/` sidecar, only a stable doc identity + a storage home. So it has * `.threads/` sidecar, only a stable doc identity + a storage home. So it has
* its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from * its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in * F2's workspace `isTracked`. Persistable docs (`file:`) keep their SNAPSHOT
* VS Code's per-extension GLOBAL storage keyed by a hash of the document URI; * baseline in VS Code's per-extension GLOBAL storage keyed by a hash of the
* untitled buffers have no durable identity, so their baseline is in-memory * document URI (INV-7: head-mode baselines are storage-free — never
* only (lost on reload) — the same degrade the storage-unavailable path uses. * persisted); untitled buffers have no durable identity, so their baseline is
* in-memory only (lost on reload) — the same degrade the storage-unavailable
* path uses.
*/ */
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import * as vscode from "vscode"; import * as vscode from "vscode";
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore"; import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";
import type { GitBaselineAdapter } from "./gitBaseline";
import type { CoeditingRegistry } from "./coeditingRegistry";
export class DiffViewController implements vscode.Disposable { export class DiffViewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = []; private readonly disposables: vscode.Disposable[] = [];
/** Source of truth for the baseline, keyed by `document.uri.toString()`. */ /** Source of truth for the baseline, keyed by `document.uri.toString()`. */
private readonly baselines = new Map<string, Baseline>(); private readonly baselines = new Map<string, Baseline>();
/** F7 (additive): fires on every baseline capture (open / advance / pin) so /** Which mode each established document resolved to (INV-7). */
* the track-changes preview refreshes without polling. Carries the real private readonly modes = new Map<string, "head" | "snapshot">();
* document URI. */ /** F7 (additive): fires on every baseline capture (establish / head-refresh /
* pin) so the track-changes preview refreshes without polling. Carries the
* real document URI. */
private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>(); private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>();
readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event; readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event;
private storageWarned = false; private storageWarned = false;
constructor(private readonly store: BaselineStore | null) { constructor(
private readonly store: BaselineStore | null,
private readonly git: GitBaselineAdapter,
registry: CoeditingRegistry,
) {
this.disposables.push( this.disposables.push(
this.onDidChangeBaselineEmitter, this.onDidChangeBaselineEmitter,
vscode.commands.registerCommand("cowriting.pinDiffBaseline", () => vscode.commands.registerCommand("cowriting.markReviewed", () =>
this.pinCommand(vscode.window.activeTextEditor), this.pinCommand(vscode.window.activeTextEditor),
), ),
// F6 captures a baseline for any diffable doc the moment it is first seen, // INV-10: a baseline is established only once its document ENTERS
// independent of the workspace gate (so "opened" is the open-time text). // coediting; exiting keeps the stored baseline as-is (PUC-7).
vscode.workspace.onDidOpenTextDocument((d) => this.ensureBaseline(d)), registry.onDidChange(({ uri, coediting }) => {
if (!coediting) return;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) void this.establish(doc);
}),
// A commit on a watched repo may advance a head-mode baseline (D13).
git.onDidChangeHead(() => void this.refreshHeadBaselines()),
); );
for (const d of vscode.workspace.textDocuments) this.ensureBaseline(d); // Restore already-coediting documents (e.g. after an extension host reload).
for (const key of registry.list()) {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (doc) void this.establish(doc);
}
} }
// ---- diffability / identity -------------------------------------------------------- // ---- diffability / identity --------------------------------------------------------
@@ -51,7 +78,7 @@ export class DiffViewController implements vscode.Disposable {
private isDiffable(document: vscode.TextDocument): boolean { private isDiffable(document: vscode.TextDocument): boolean {
return document.uri.scheme === "file" || document.uri.scheme === "untitled"; return document.uri.scheme === "file" || document.uri.scheme === "untitled";
} }
/** Only `file:` docs have a durable identity to persist a baseline against. */ /** Only `file:` docs have a durable identity to persist a SNAPSHOT baseline against. */
private isPersistable(document: vscode.TextDocument): boolean { private isPersistable(document: vscode.TextDocument): boolean {
return document.uri.scheme === "file"; return document.uri.scheme === "file";
} }
@@ -64,36 +91,66 @@ export class DiffViewController implements vscode.Disposable {
return createHash("sha256").update(uriKey).digest("hex"); return createHash("sha256").update(uriKey).digest("hex");
} }
// ---- baseline lifecycle (§6.4) ----------------------------------------------------- // ---- baseline lifecycle (§6.4, INV-7) -----------------------------------------------
/** First sight of a diffable doc: load the stored baseline, else capture `opened`. */ /**
ensureBaseline(document: vscode.TextDocument): void { * Resolve this document's baseline mode and (re)establish its baseline.
* git-tracked (a HEAD blob resolves) → **head** mode, baseline = HEAD, never
* persisted. Otherwise → **snapshot** mode: load a previously-stored
* snapshot, else capture the buffer now (`"entered"`). Called once when a
* document enters coediting (registry.enter) and at startup for documents
* already coediting.
*/
async establish(document: vscode.TextDocument): Promise<void> {
if (!this.isDiffable(document)) return; if (!this.isDiffable(document)) return;
const key = this.uriKey(document); const key = this.uriKey(document);
if (this.baselines.has(key)) return; const head = await this.git.headFor(document.uri);
if (this.store && this.isPersistable(document)) { if (head) {
try { this.modes.set(key, "head");
const stored = this.store.load(this.storageKey(key)); this.setBaseline(key, head.text, "head");
if (stored) {
this.baselines.set(key, stored);
return; return;
} }
} catch { this.modes.set(key, "snapshot");
this.warnStorageOnce(); if (this.store && this.isPersistable(document)) {
const stored = this.tryLoad(key); // existing load path, legacy reasons normalized
if (stored) {
this.baselines.set(key, stored);
this.fire(key);
return;
} }
} }
this.capture(document, "opened"); this.capture(document, "entered");
} }
/** Machine landing (INV-18): re-capture so landed text never shows as a change. */ /** The mode a document's baseline resolved to, or undefined before `establish`. */
advance(document: vscode.TextDocument): void { modeOf(uriString: string): "head" | "snapshot" | undefined {
if (!this.isDiffable(document)) return; return this.modes.get(uriString);
this.capture(document, "machine-landing");
} }
/** Human pin: baseline := now; the preview's change-marks empty (left = right). */ /** Re-read HEAD for every head-mode document; a commit advances the baseline (D13). */
private async refreshHeadBaselines(): Promise<void> {
for (const [key, mode] of this.modes) {
if (mode !== "head") continue;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (!doc) continue;
const head = await this.git.headFor(doc.uri);
if (head && head.text !== this.baselines.get(key)?.text) {
this.setBaseline(key, head.text, "head"); // commit advanced the baseline (D13)
}
}
}
/** Human pin ("Mark Changes as Reviewed", D14): snapshot mode only — a
* git-tracked (head-mode) baseline advances only by commit. */
pin(document: vscode.TextDocument): void { pin(document: vscode.TextDocument): void {
if (!this.isDiffable(document)) return; if (!this.isDiffable(document)) return;
const key = this.uriKey(document);
if (this.modes.get(key) !== "snapshot") {
void vscode.window.showWarningMessage(
"Cowriting: this document is git-tracked — commit to advance the baseline.",
);
return;
}
this.capture(document, "pinned"); this.capture(document, "pinned");
} }
@@ -103,12 +160,38 @@ export class DiffViewController implements vscode.Disposable {
const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason }; const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason };
this.baselines.set(key, baseline); this.baselines.set(key, baseline);
if (this.store && this.isPersistable(document)) { if (this.store && this.isPersistable(document)) {
this.trySave(key, baseline);
}
this.fire(key);
}
/** Set a baseline from resolved text directly (head mode — no document buffer read). */
private setBaseline(key: string, text: string, reason: BaselineReason): void {
const baseline: Baseline = { uri: key, text, capturedAt: new Date().toISOString(), reason };
this.baselines.set(key, baseline);
// head-mode baselines are storage-free (INV-7); persist only snapshots.
if (reason !== "head" && this.store) this.trySave(key, baseline);
this.fire(key);
}
private tryLoad(key: string): Baseline | null {
try { try {
this.store.save(this.storageKey(key), baseline); return this.store!.load(this.storageKey(key));
} catch {
this.warnStorageOnce();
return null;
}
}
private trySave(key: string, baseline: Baseline): void {
try {
this.store!.save(this.storageKey(key), baseline);
} catch { } catch {
this.warnStorageOnce(); this.warnStorageOnce();
} }
} }
private fire(key: string): void {
this.onDidChangeBaselineEmitter.fire({ uri: key }); this.onDidChangeBaselineEmitter.fire({ uri: key });
} }
@@ -122,7 +205,7 @@ export class DiffViewController implements vscode.Disposable {
private pinCommand(editor: vscode.TextEditor | undefined): void { private pinCommand(editor: vscode.TextEditor | undefined): void {
if (!editor || !this.isDiffable(editor.document)) { if (!editor || !this.isDiffable(editor.document)) {
void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its review baseline."); void vscode.window.showWarningMessage("Cowriting: focus a text editor to mark its changes as reviewed.");
return; return;
} }
this.pin(editor.document); this.pin(editor.document);
+102
View File
@@ -0,0 +1,102 @@
/**
* EditFlow — the host edit-turn flow (F11/F12), extracted from the review
* webview controller ahead of its sunset (native-surfaces migration, spec
* §6.10). Owns the low-level turn→proposal(s) cut (`runEditAndPropose`,
* INV-39/40): never mutates the document directly — every turn lands as one or
* more F4 proposals via `ProposalController.propose` (INV-10). Reachable, from
* Task 6 on, from the thread controller (`ThreadController.runMakeEdit`) —
* vscode-API-only, no webview state. The `cowriting.editDocument` command lives
* in extension.ts (Finding-1 fix) so it can hand off to
* `ThreadController.askClaude()`. Task 8 deleted this class's own
* `askClaude`/`askEditInstruction` (the review webview's toolbar ask) once
* their only caller — the webview — was sunset alongside them.
*/
import * as vscode from "vscode";
import type { ProposalController } from "./proposalController";
import { diffToBlockHunks } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
/**
* F11: a host edit turn (selection/document text + instruction → rewrite).
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
* the arg is optional so existing test stubs that ignore it stay valid.
*/
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
export type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
export class EditFlow implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
/**
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/
private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts);
};
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
private nextTurnSeq(): number {
return ++this.turnSeq;
}
constructor(private readonly proposals: ProposalController) {}
/**
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
* Never mutates the document (INV-10). Returns the created proposal ids.
*/
async runEditAndPropose(
document: vscode.TextDocument,
target: EditTarget,
instruction: string,
opts?: RunEditTurnOptions,
): Promise<string[]> {
const full = document.getText();
// One turnId per gesture — the document case's N hunk-proposals all share it,
// so a single rewrite groups as one agent turn (parity with editSelection).
const turnId = `turn-${this.nextTurnSeq()}`;
const provenance = (turn: EditTurnResult) =>
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
if (target.kind === "range") {
const selected = full.slice(target.start, target.end);
const turn = await this.editTurn(instruction, selected, opts);
if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full, opts);
const ids: string[] = [];
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) —
// not per word. Each is tagged `granularity:"block"` so accept reconciles
// attribution per word inside the block (INV-40).
for (const h of diffToBlockHunks(full, turn.replacement)) {
const fp = buildFingerprint(full, { start: h.start, end: h.end });
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
turnId,
instruction,
granularity: "block",
});
if (id) ids.push(id);
}
return ids;
}
// ---- test seam (§6.4) ----
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
setEditTurnForTest(fn: EditTurn): void {
this.editTurn = fn;
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}
-147
View File
@@ -1,147 +0,0 @@
import * as vscode from "vscode";
import { randomBytes } from "crypto";
/**
* The multi-line instruction input for "Ask Claude to Edit" — BOTH the selection
* and the whole-document case. A small focused webview (a tall, resizable
* textarea + Send) opened in a split pane BELOW the document — not a comment
* thread, and not the top QuickInput (which is single-line only; VS Code has no
* multi-line input at the command-palette location). For a selection edit the
* document stays in the pane above with the selection still highlighted (VS
* Code's inactive-selection style), so the user can see exactly what Claude will
* edit; the caller has already captured the selection, so we never touch it.
*
* The split collapses when the input is submitted or cancelled, and focus is
* handed back to the document so the collapsing split doesn't reveal the bottom
* panel. `header` names the scope ("Ask Claude to Edit This Selection" /
* "…This Document").
*
* The webview only collects text and posts it to the host — no SDK or secret
* surface lives in it (INV-8/35); the sealed CSP allows no network. Because a
* webview CAN read its own textarea, Cancel / Escape confirms ONLY when there is
* text to lose (closing the tab is an explicit dismiss, no prompt). Resolves with
* the typed instruction, or `undefined` if cancelled / closed / left empty.
*/
export async function promptEditInstruction(header: string): Promise<string | undefined> {
// Remember the document editor we came from so we can hand focus back to it
// when the input closes — otherwise, when the empty split group below collapses,
// focus falls into the bottom panel (Output / Debug Console / …) and it pops
// open. Restoring editor focus leaves whatever panel state the user had untouched.
const source = vscode.window.activeTextEditor;
// Open a split editor group BELOW the current one and host the input there, so
// it sits under the document instead of covering it as a tab. The new group is
// empty, so disposing the panel on submit/cancel leaves it empty and VS Code
// collapses the split (the `workbench.editor.closeEmptyGroups` default). Falls
// back to a tab in the active group if the split command is unavailable.
try {
await vscode.commands.executeCommand("workbench.action.newGroupBelow");
} catch {
/* no split — the panel opens as a tab in the active group instead */
}
return new Promise((resolve) => {
const panel = vscode.window.createWebviewPanel(
"cowriting.askClaudeInput",
header,
{ viewColumn: vscode.ViewColumn.Active, preserveFocus: false },
{ enableScripts: true, retainContextWhenHidden: false },
);
let settled = false;
const done = (value: string | undefined): void => {
if (settled) return;
settled = true;
resolve(value);
panel.dispose();
// Hand focus back to the originating document so the collapsing split
// doesn't leave focus in (and reveal) the bottom panel.
if (source) {
void vscode.window.showTextDocument(source.document, {
viewColumn: source.viewColumn ?? vscode.ViewColumn.One,
preserveFocus: false,
});
}
};
panel.webview.onDidReceiveMessage((m: { type?: string; text?: string }) => {
const text = (m?.text ?? "").trim();
if (m?.type === "submit") {
done(text ? text : undefined);
} else if (m?.type === "cancel") {
// Confirm only if there's something to lose (we can read the textarea here).
if (!text) {
done(undefined);
return;
}
void vscode.window
.showWarningMessage("Discard your Ask-Claude instruction?", { modal: true }, "Discard")
.then((pick) => {
if (pick === "Discard") done(undefined);
// else: leave the panel open so the operator can keep editing.
});
}
});
// Closing the tab is an explicit dismiss — cancel without a prompt.
panel.onDidDispose(() => done(undefined));
panel.webview.html = htmlFor(header);
});
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
}
function htmlFor(header: string): string {
const nonce = randomBytes(16).toString("base64");
// Sealed CSP: no network; inline style only; the one script is nonce-gated.
const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';`;
const title = escapeHtml(header);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<title>${title}</title>
<style>
body { padding: 14px 16px; font-family: var(--vscode-font-family); color: var(--vscode-foreground); }
h2 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
textarea {
width: 100%; min-height: 160px; resize: vertical; box-sizing: border-box;
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 8px;
font-family: var(--vscode-editor-font-family); font-size: var(--vscode-editor-font-size); line-height: 1.4;
}
textarea::placeholder { color: var(--vscode-input-placeholderForeground); }
textarea:focus { outline: 1px solid var(--vscode-focusBorder); border-color: var(--vscode-focusBorder); }
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; gap: 12px; }
.hint { color: var(--vscode-descriptionForeground); font-size: 12px; }
button {
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px;
}
button:hover { background: var(--vscode-button-hoverBackground); }
</style>
</head>
<body>
<h2>${title}</h2>
<textarea id="inst" placeholder="e.g. tighten the intro, add a conclusion, and fix the heading levels" autofocus></textarea>
<div class="row">
<span class="hint">⌘↵ / Ctrl+↵ to send · Esc to cancel</span>
<button id="send">Send to Claude</button>
</div>
<script nonce="${nonce}">
const api = acquireVsCodeApi();
const ta = document.getElementById('inst');
const focus = () => ta.focus();
focus();
window.addEventListener('focus', focus);
const submit = () => api.postMessage({ type: 'submit', text: ta.value });
const cancel = () => api.postMessage({ type: 'cancel', text: ta.value });
document.getElementById('send').addEventListener('click', submit);
ta.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
</script>
</body>
</html>`;
}
+36 -7
View File
@@ -17,6 +17,7 @@ import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController"; import type { AttributionController } from "./attributionController";
import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel"; import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { CoeditingRegistry } from "./coeditingRegistry";
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider { export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
private readonly disposables: vscode.Disposable[] = []; private readonly disposables: vscode.Disposable[] = [];
@@ -51,13 +52,14 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
private readonly proposals: ProposalController, private readonly proposals: ProposalController,
private readonly diffView: DiffViewController, private readonly diffView: DiffViewController,
private readonly attribution: AttributionController, private readonly attribution: AttributionController,
private readonly registry: CoeditingRegistry,
) { ) {
this.disposables.push( this.disposables.push(
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter, this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter,
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this), vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)), this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)), vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
// Refresh committed decorations when the baseline advances (pin / machine-landing / open). // Refresh committed decorations when the baseline changes (establish / pin / head-refresh).
this.diffView.onDidChangeBaseline(({ uri }) => { this.diffView.onDidChangeBaseline(({ uri }) => {
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri); const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed); if (ed) this.renderEditor(ed);
@@ -67,6 +69,13 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
const ed = vscode.window.activeTextEditor; const ed = vscode.window.activeTextEditor;
if (ed && e.document === ed.document) this.scheduleRender(ed); if (ed && e.document === ed.document) this.scheduleRender(ed);
}), }),
// INV-10: re-render (and refresh CodeLens) on every gate change — exit clears
// decorations/CodeLens via renderEditor's own gate check, re-entry redraws.
this.registry.onDidChange(({ uri }) => {
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
this.lensEmitter.fire();
}),
// the four QuickPick-backed menu commands // the four QuickPick-backed menu commands
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)), vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)), vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
@@ -106,6 +115,9 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
private async onProposalsChanged(uri: string): Promise<void> { private async onProposalsChanged(uri: string): Promise<void> {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return; if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return;
// INV-10: a doc that exited coediting between the schedule and this tick gets
// no optimistic-apply/decorations/CodeLens.
if (!this.registry.isCoediting(doc.uri)) return;
const key = this.proposals.keyFor(doc); const key = this.proposals.keyFor(doc);
for (const v of this.proposals.listProposals(doc)) { for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) { if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
@@ -125,6 +137,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []); for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []);
}; };
if (doc.languageId !== "markdown") return clearAll(); if (doc.languageId !== "markdown") return clearAll();
// INV-10: a non-entered doc gets no decorations at all.
if (!this.registry.isCoediting(doc.uri)) return clearAll();
const key = this.proposals.keyFor(doc); const key = this.proposals.keyFor(doc);
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] }; const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] }; const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] };
@@ -202,18 +216,33 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
} }
} }
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */ /**
* CodeLensProvider (Task 8, PUC-2 wording): a `✓ Keep` / `✗ Reject` pair above
* each applied block, plus — when ≥2 proposals are pending on the doc — a
* top-of-file `✓ Keep all (N)` / `✗ Reject all` pair routed directly at the
* batch commands (no QuickPick detour for the common "review is done" case).
*/
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
if (document.languageId !== "markdown") return []; if (document.languageId !== "markdown") return [];
if (!this.registry.isCoediting(document.uri)) return [];
const key = this.proposals.keyFor(document); const key = this.proposals.keyFor(document);
const applied = this.proposals
.listProposals(document)
.filter((v) => v.anchorStart !== null && this.proposals.isApplied(key, v.id));
const lenses: vscode.CodeLens[] = []; const lenses: vscode.CodeLens[] = [];
for (const v of this.proposals.listProposals(document)) { if (applied.length >= 2) {
if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue; const top = new vscode.Range(0, 0, 0, 0);
const pos = document.positionAt(v.anchorStart); lenses.push(
new vscode.CodeLens(top, { title: `✓ Keep all (${applied.length})`, command: "cowriting.acceptAllProposals" }),
new vscode.CodeLens(top, { title: "✗ Reject all", command: "cowriting.rejectAllProposals" }),
);
}
for (const v of applied) {
const pos = document.positionAt(v.anchorStart!);
const line = new vscode.Range(pos.line, 0, pos.line, 0); const line = new vscode.Range(pos.line, 0, pos.line, 0);
lenses.push( lenses.push(
new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }), new vscode.CodeLens(line, { title: "✓ Keep", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }),
new vscode.CodeLens(line, { title: "Reject", command: "cowriting.proposalRejectMenu", arguments: [v.id] }), new vscode.CodeLens(line, { title: "Reject", command: "cowriting.proposalRejectMenu", arguments: [v.id] }),
); );
} }
return lenses; return lenses;
+269 -141
View File
@@ -10,10 +10,15 @@ import { BaselineStore } from "./baselineStore";
import { GlobalSidecarStore } from "./globalSidecarStore"; import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter"; import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController"; import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview"; import { GitBaselineAdapter } from "./gitBaseline";
import { EditFlow } from "./editFlow";
import { LiveProgressUi } from "./liveProgressUi"; import { LiveProgressUi } from "./liveProgressUi";
import { EditorProposalController } from "./editorProposalController"; import { EditorProposalController } from "./editorProposalController";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; import { isAuthorable, routeEdit } from "./workspacePath";
import { CoeditingRegistry } from "./coeditingRegistry";
import { ScmSurfaceController } from "./scmSurface";
import type MarkdownIt from "markdown-it";
import { cowritingMarkdownItPlugin, type AnnotationInputs } from "./previewAnnotations";
const CHANNEL_NAME = "Cowriting (Cline SDK)"; const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -23,10 +28,19 @@ export interface CowritingApi {
proposalController: ProposalController; proposalController: ProposalController;
versionGuard: VersionGuard; versionGuard: VersionGuard;
diffViewController: DiffViewController; diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController; editFlow: EditFlow;
sidecarRouter: SidecarRouter; sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi; liveProgressUi: LiveProgressUi;
editorProposalController: EditorProposalController; editorProposalController: EditorProposalController;
coeditingRegistry: CoeditingRegistry;
scmSurfaceController: ScmSurfaceController;
/** Task 7 test seam: the REAL production `inputsFor`, so E2E exercises the
* actual registry/diffView/attribution/proposals/config wiring (not a
* hand-rolled reimplementation of it). */
previewAnnotationHost: { inputsFor(env: unknown): AnnotationInputs | undefined };
/** Task 7 (D3/D21): the markdown-it plugin factory VS Code's built-in preview
* loads via `markdown.markdownItPlugins` (package.json). */
extendMarkdownIt: (md: unknown) => unknown;
} }
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -59,19 +73,66 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}), }),
); );
// --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT --- // --- PUC-7 (native-surfaces migration): the INV-10 opt-in gate ---
// The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is // Every later native surface (SCM/QuickDiff, comments, preview annotations,
// the single review surface); only the baseline store survives, consumed by // edit commands, decorations) checks isCoediting before attaching. Persisted
// F7/F10. It tracks a baseline for ANY diffable doc (file or untitled), so it // in workspaceState so a reload restores the set.
// is constructed regardless of whether a folder is open. Baseline lives in VS const coeditingRegistry = new CoeditingRegistry(context.workspaceState);
// Code's per-extension GLOBAL storage (always present), never the repo context.subscriptions.push(coeditingRegistry);
// (INV-19). The machine-landing advance is wired below. The controller context.subscriptions.push(
// self-wires baseline capture on open. vscode.commands.registerCommand("cowriting.coeditDocument", () => {
const ed = vscode.window.activeTextEditor;
if (!ed || ed.document.languageId !== "markdown" || !isAuthorable(ed.document.uri.scheme)) {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to coedit it.");
return;
}
coeditingRegistry.enter(ed.document.uri);
coeditingRegistry.syncContext(ed);
}),
vscode.commands.registerCommand("cowriting.stopCoediting", () => {
const ed = vscode.window.activeTextEditor;
if (!ed) return;
coeditingRegistry.exit(ed.document.uri);
coeditingRegistry.syncContext(ed);
}),
vscode.window.onDidChangeActiveTextEditor((ed) => coeditingRegistry.syncContext(ed)),
);
coeditingRegistry.syncContext(vscode.window.activeTextEditor);
// --- Baseline router (native-surfaces migration, spec §6.4, INV-7) — workspace-
// INDEPENDENT --- The two-pane vscode.diff VIEW was removed in #34 (F10's
// rendered preview is the single review surface); only the baseline data
// layer survives, consumed by F7/F10. It tracks a baseline for ANY diffable
// doc (file or untitled), so it is constructed regardless of whether a folder
// is open. A SNAPSHOT baseline lives in VS Code's per-extension GLOBAL
// storage (always present), never the repo (INV-19); a git-tracked doc's HEAD
// baseline is never persisted (INV-7). Baselines are established only for
// documents the CoeditingRegistry has entered — see the registry wiring
// inside DiffViewController's constructor.
const gitBaseline = new GitBaselineAdapter();
context.subscriptions.push(gitBaseline);
const baselineStorageDir = context.globalStorageUri?.fsPath; const baselineStorageDir = context.globalStorageUri?.fsPath;
const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null; const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null;
const diffViewController = new DiffViewController(baselineStore); const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry);
context.subscriptions.push(diffViewController); context.subscriptions.push(diffViewController);
// Task 1 hook: expose the resolved baseline mode ("head"/"snapshot") to the
// registry's context-key sync, and re-sync `cowriting.baselineMode` whenever
// a baseline is (re)captured (entry, head-refresh, pin) — the "Mark Changes
// as Reviewed" menu entry (snapshot-only, Task 3) is gated on that key.
coeditingRegistry.baselineModeOf = (uri) => diffViewController.modeOf(uri.toString());
context.subscriptions.push(
diffViewController.onDidChangeBaseline(() => coeditingRegistry.syncContext(vscode.window.activeTextEditor)),
);
// --- Task 3: the native diff surface (spec §6.4/§5, INV-13) — QuickDiff
// gutter bars, the cowriting-baseline: content provider, "Review Changes",
// and the status-bar change count. Constructed after diffViewController
// (consumes getBaseline/modeOf/onDidChangeBaseline) and the registry
// (consumes isCoediting/onDidChange), gated on INV-10. ---
const scmSurfaceController = new ScmSurfaceController(coeditingRegistry, diffViewController);
context.subscriptions.push(scmSurfaceController);
// F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage // F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage
// home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed // home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed
// workspace-independently; the router falls back to it for any non-in-folder // workspace-independently; the router falls back to it for any non-in-folder
@@ -92,39 +153,51 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one // F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
// warning per doc across the three co-owning controllers. // warning per doc across the three co-owning controllers.
const versionGuard = new VersionGuard(sidecarRouter); const versionGuard = new VersionGuard(sidecarRouter);
const threadController = new ThreadController(sidecarRouter, root, versionGuard);
context.subscriptions.push(threadController);
// --- F3: live attribution (Feature #6) --- // --- F3: live attribution (Feature #6) ---
const attributionController = new AttributionController(sidecarRouter, root, versionGuard); const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry);
context.subscriptions.push(attributionController); context.subscriptions.push(attributionController);
// --- F4: propose/accept (Feature #12) — constructed before the preview so F10 // --- F4: propose/accept (Feature #12) ---
// can route ✓/✗ through it --- const proposalController = new ProposalController(
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); sidecarRouter,
attributionController,
root,
versionGuard,
coeditingRegistry,
);
context.subscriptions.push(proposalController); context.subscriptions.push(proposalController);
// --- F7/F10: the review preview is the single interactive review surface --- // --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the
// Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped
// INV-20). Constructed AFTER attribution (reads F3 spans) and proposals (routes // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut
// F4 accept/reject from the webview ✓/✗). // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the thread controller,
const trackChangesPreviewController = new TrackChangesPreviewController( // which consumes it too (Task 6: the comment-loop's "make this edit" offer
diffViewController, // calls runEditAndPropose). ---
context.extensionUri, const editFlow = new EditFlow(proposalController);
attributionController, context.subscriptions.push(editFlow);
proposalController,
liveProgressUi, // --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→
); // offer→proposal loop. Consumes editFlow (offer → pending proposals) and
context.subscriptions.push(trackChangesPreviewController); // liveProgressUi (the shared "asking Claude…" notification + output channel). ---
const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi);
context.subscriptions.push(threadController);
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer, // --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). --- // decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
const editorProposalController = new EditorProposalController(proposalController, diffViewController, attributionController); const editorProposalController = new EditorProposalController(
proposalController,
diffViewController,
attributionController,
coeditingRegistry,
);
context.subscriptions.push(editorProposalController); context.subscriptions.push(editorProposalController);
// #46 (INV-42): accept every pending proposal on the active doc in one gesture // #46 (INV-42): accept every pending proposal on the active doc in one gesture
// (also reachable from the preview toolbar's "Accept all" button). Reuses the // (also reachable from the top-of-file "Keep all (N)" CodeLens, Task 8 PUC-2).
// batched F4 seam + reports applied-vs-skipped. // Reuses the batched F4 seam + reports applied-vs-skipped, routing directly to
// ProposalController (native-surfaces migration: no preview-controller
// indirection).
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document; const doc = vscode.window.activeTextEditor?.document;
@@ -132,11 +205,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals."); void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals.");
return; return;
} }
await trackChangesPreviewController.acceptAll(doc); const { applied, skipped } = await proposalController.acceptAllProposals(doc);
if (applied === 0 && skipped === 0) return;
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
void vscode.window.showInformationMessage(
`Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`,
);
}), }),
); );
// #64 (INV-53): reject every pending proposal on the active doc in one gesture. // #64 (INV-53): reject every pending proposal on the active doc in one gesture
// (native-surfaces migration: routes directly to ProposalController).
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => { vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document; const doc = vscode.window.activeTextEditor?.document;
@@ -144,16 +223,21 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals."); void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
return; return;
} }
await trackChangesPreviewController.rejectAll(doc); const { reverted } = await proposalController.rejectAll(doc);
if (reverted > 0) {
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
);
}
}), }),
); );
// --- F6 machine-landing wiring — now for ANY authorable doc --- // NOTE (native-surfaces migration, INV-7/INV-18 retirement): the shipped
// The seam's single machine-landing signal advances the F6 baseline (INV-18); // machine-landing baseline advance (#48) — wiring
// the seam can now fire on out-of-folder files too, so wire it unconditionally. // attributionController.onDidApplyAgentEdit → diffViewController.advance —
context.subscriptions.push( // was removed here. A landed Claude edit now stays a visible change-since-
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)), // baseline until the file is committed (head mode) or reviewed via
); // "Mark Changes as Reviewed" (snapshot mode).
// One SHARED sidecar watcher for both controllers; self-writes are suppressed // One SHARED sidecar watcher for both controllers; self-writes are suppressed
// centrally in the repo store (only repo `.threads/` sidecars are watched — // centrally in the repo store (only repo `.threads/` sidecars are watched —
@@ -216,114 +300,58 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
), ),
); );
// F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one // Task 6 (D19): the comments-first ask replaces the old inline turn
// claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4, // plumbing here — the ask now IS a comment. threadController.askClaude()
// INV-10); the applyAgentEdit seam (INV-9) now fires only on accept. // opens a focused comment box on the current selection (or, with no
// selection, a top-anchored whole-document thread); the comment→reply→
// offer→proposal loop (ThreadController.respondInThread/makeThreadEdit)
// takes it from there. Kept as its own command (rather than folded into
// cowriting.edit) for the host E2E harness and the internal seams.
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.editSelection", async () => { vscode.commands.registerCommand("cowriting.editSelection", async () => {
const editor = vscode.window.activeTextEditor; await threadController.askClaude();
// F8: authoring works on any file: or untitled: doc (the router decides }),
// where its artifact is stored). Each failure still names its real reason );
// (no editor / no selection / a non-{file,untitled} read-only view) — not
// always "select some text" (#24's per-condition messaging). // Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument`
const reason = selectionRejection({ // used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompted via
hasEditor: !!editor, // `askEditInstruction` — a rejecting stub after Task 6 sunset the input webview
selectionEmpty: editor?.selection.isEmpty ?? true, // (spec §6.10; both `askClaude`/`askEditInstruction` were deleted from EditFlow in
scheme: editor?.document.uri.scheme ?? "", // Task 8 once their only caller, the review webview, died too). Reached with no
}); // selection (the common case for `cowriting.edit`), that was a SILENT dead end: the
if (reason) { // reject fired before EditFlow.askClaude's own try/catch and was `void`'d, so no
void vscode.window.showWarningMessage(reason); // toast, no thread, nothing. This command now resolves
// its target document exactly as before (a tab's clicked URI, falling back to the
// active editor), focuses it, and hands off to the SAME comments-first ask as
// `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a
// whole-document ask when the (now-focused) editor's selection is empty (D19). Kept
// registered + hidden from the palette for the host E2E harness and #42's
// tab-targeting behavior; `EditFlow.runEditAndPropose` (untouched) stays the E2E seam
// for driving a turn without the native comment UI — see f12Reach.test.ts /
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => {
const doc = uri
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
(await vscode.workspace.openTextDocument(uri))
: vscode.window.activeTextEditor?.document;
if (!doc || doc.languageId !== "markdown") {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
return; return;
} }
if (!editor) return; // unreachable once reason is null, but narrows the type if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) {
const document = editor.document; await vscode.window.showTextDocument(doc);
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
// Capture the selection text + anchor BEFORE prompting — the inline prompt
// moves the cursor to the document top (where its box anchors), which would
// otherwise collapse the selection we act on (spec §6.5 PUC-1: anchor
// captured pre-turn so mid-turn edits can't skew it).
const selectedText = document.getText(selection);
const fp = buildFingerprint(document.getText(), {
start: document.offsetAt(selection.start),
end: document.offsetAt(selection.end),
});
// The instruction prompt is the multi-line split-below webview box (shared
// with the document case, via the preview controller); the document above
// keeps the selection highlighted while it's open. selectedText/fp were
// captured above, so moving focus to the box doesn't affect what we edit.
const instruction = await trackChangesPreviewController.askEditInstruction(
"Ask Claude to Edit This Selection",
);
if (!instruction) return;
const turnId = `turn-${Date.now().toString(36)}`;
try {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const { runEditTurn } = await import("./liveTurn");
const ui = liveProgressUi.begin(instruction, progress, token);
let turn;
try {
turn = await runEditTurn(instruction, selectedText, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
if (token.isCancellationRequested) {
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
return;
}
throw err;
}
if (turn.replacement === "") {
void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
);
return;
}
if (turn.replacement === selectedText) {
void vscode.window.showInformationMessage(
"Cowriting: Claude proposed no change to the selection.",
);
return;
}
// F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation.
// The seam now fires only on accept (ProposalController, INV-9).
const id = await proposalController.propose(
document,
fp,
turn.replacement,
{
kind: "agent",
id: "claude",
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
},
{ turnId, instruction },
);
if (id) {
void vscode.window.showInformationMessage(
"Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
);
}
},
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
} }
await threadController.askClaude();
}), }),
); );
// The single user-facing "Ask Claude to Edit" gesture (one command, one // The single user-facing "Ask Claude to Edit" gesture (one command, one
// keybinding, one menu entry). It routes to the selection flow (editSelection) // keybinding, one menu entry). It routes to the selection flow (editSelection)
// or the whole-document flow (editDocument) by selection/context — see // or the whole-document flow (editDocument) by selection/context — see
// routeEdit. The two underlying commands stay registered for the host E2E // routeEdit. Both underlying commands now end at ThreadController.askClaude()
// harness and the internal seams, but are hidden from the palette. // (comments-first ask, D19); they stay registered for the host E2E harness and
// the internal seams, but are hidden from the palette.
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => { vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
const editor = vscode.window.activeTextEditor; const editor = vscode.window.activeTextEditor;
@@ -341,9 +369,47 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}), }),
); );
// Task 8 (native-surfaces migration, spec §6.10): the minimal gateway replacing
// the sunset review-webview's #41 right-click entries. Resolves the clicked
// doc (or falls back to the active editor, mirroring editDocument/#41), enters
// coediting if not already (so a fresh doc gets a baseline to review against —
// no-op if already entered or not authorable), then hands off to VS Code's
// OWN "Open Preview to the Side" — the built-in preview IS the review surface
// now (Task 7's annotations render inside it).
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.openReviewPreview", async (uri?: vscode.Uri) => {
const doc = uri
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
(await vscode.workspace.openTextDocument(uri))
: vscode.window.activeTextEditor?.document;
if (!doc || doc.languageId !== "markdown") {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to review it.");
return;
}
if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) {
await vscode.window.showTextDocument(doc);
}
if (!coeditingRegistry.isCoediting(doc.uri) && isAuthorable(doc.uri.scheme)) {
await vscode.commands.executeCommand("cowriting.coeditDocument");
}
await vscode.commands.executeCommand("markdown.showPreviewToSide", doc.uri);
}),
);
// Render threads + attributions for already-open editors, and on future opens. // Render threads + attributions for already-open editors, and on future opens.
// INV-10: gated on the registry too — belt-and-suspenders with the per-controller
// gates (renderAll/loadAll already early-return), but keeps the intent explicit
// at the call site and avoids the no-op calls for a non-entered doc.
const renderIfOpen = (doc: vscode.TextDocument) => { const renderIfOpen = (doc: vscode.TextDocument) => {
if (isAuthorable(doc.uri.scheme)) { if (isAuthorable(doc.uri.scheme) && coeditingRegistry.isCoediting(doc.uri)) {
// Finding 3 (final whole-branch review): DiffViewController only
// establishes a baseline on the registry's enter TRANSITION and, at
// startup, for documents already open. A doc that is a PERSISTED
// coediting member but wasn't open yet at either of those moments (e.g.
// lazily restored after a window reload) reaches this handler with no
// baseline/mode — establish it now so QuickDiff/Review-Changes/Mark-
// Reviewed never see a silent empty-baseline gap.
if (diffViewController.modeOf(doc.uri.toString()) === undefined) void diffViewController.establish(doc);
threadController.renderAll(doc); threadController.renderAll(doc);
attributionController.loadAll(doc); attributionController.loadAll(doc);
proposalController.renderAll(doc); proposalController.renderAll(doc);
@@ -352,16 +418,78 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
vscode.workspace.textDocuments.forEach(renderIfOpen); vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
// --- Task 7 (D3/D21, PUC-3): annotations in the BUILT-IN Markdown preview ---
// `env.currentDocument` (VS Code's markdown-it render env) is an OBSERVED
// preview-engine behavior, not a documented contract, so `lastCoeditedUri`
// is the fallback that keeps single-doc correctness when it's absent — the
// last editor that WAS coediting when it lost focus (e.g. focus moved to the
// preview pane itself) stays the annotation target until a different
// coedited doc becomes active.
let lastCoeditedUri: string | undefined;
const trackLastCoedited = (ed: vscode.TextEditor | undefined) => {
if (ed && coeditingRegistry.isCoediting(ed.document.uri)) lastCoeditedUri = ed.document.uri.toString();
};
trackLastCoedited(vscode.window.activeTextEditor);
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(trackLastCoedited),
// The active editor doesn't itself change when `cowriting.coeditDocument`
// enters IT into coediting — re-check on every gate change too, so the
// very first render after entering already has an annotation target.
coeditingRegistry.onDidChange(() => trackLastCoedited(vscode.window.activeTextEditor)),
);
const previewAnnotationHost = {
inputsFor(env: unknown): AnnotationInputs | undefined {
const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString();
// Finding 1 (final whole-branch review): the `lastCoeditedUri` fallback
// is for an ABSENT env only (env.currentDocument didn't resolve) — a
// present envUri is authoritative for the doc actually being previewed
// and must never fall back to a different (or since-exited) document's
// inputs. Either way, the winning key must currently be a coediting
// member (INV-10) — an exited doc's stale `lastCoeditedUri` fails this
// check too, closing the "preview stays annotated after stopCoediting"
// gap.
const key = envUri === undefined ? lastCoeditedUri : envUri;
if (!key || !coeditingRegistry.list().includes(key)) return undefined;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (!doc) return undefined;
const baseline = diffViewController.getBaseline(key);
return {
baselineText: baseline?.text,
baselineReason: baseline?.reason,
spans: attributionController.spansFor(doc),
proposals: proposalController.listProposals(doc),
enabled: vscode.workspace.getConfiguration("cowriting").get<boolean>("annotations", true),
};
},
};
// Toggle: flips `cowriting.annotations` + refreshes the built-in preview
// (VS Code re-invokes extendMarkdownIt's plugin on the next render — no
// extension-side re-render call exists beyond the standard refresh command).
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.toggleAnnotations", async () => {
const config = vscode.workspace.getConfiguration("cowriting");
const current = config.get<boolean>("annotations", true);
await config.update("annotations", !current, vscode.ConfigurationTarget.Global);
await vscode.commands.executeCommand("markdown.preview.refresh");
}),
);
return { return {
threadController, threadController,
attributionController, attributionController,
proposalController, proposalController,
versionGuard, versionGuard,
diffViewController, diffViewController,
trackChangesPreviewController, editFlow,
sidecarRouter, sidecarRouter,
liveProgressUi, liveProgressUi,
editorProposalController, editorProposalController,
coeditingRegistry,
scmSurfaceController,
previewAnnotationHost,
extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost),
}; };
} }
+131
View File
@@ -0,0 +1,131 @@
/**
* GitBaselineAdapter — resolves a document's git-HEAD baseline via the built-in
* Git extension API (spec §6.4, INV-7). Spike-verified: Repository.show('HEAD',
* path) + state.onDidChange work as assumed; the change event is coarse, so
* re-reads key off state.HEAD.commit; a nested repo may need openRepository().
*
* Hardening (Task 2 E2E, host-verified): for a repo that is NOT a workspace
* folder (the common case here — F8/#19 precedent, coediting works on any
* file), the git extension's own filesystem watcher does not reliably notice
* an out-of-band commit (e.g. a `git commit` run from a terminal, or in the
* host E2E) — `repo.state.onDidChange` can simply never fire. We additionally
* watch `.git/logs/HEAD` (the reflog VS Code itself also watches for
* in-workspace repos) with Node's own `fs.watch` and nudge `repo.status()`,
* which reliably re-syncs `state.HEAD` and lets the existing onDidChange path
* do the rest. Best-effort: any failure here just falls back to the passive
* listener.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
interface GitRepository {
rootUri: vscode.Uri;
state: { HEAD?: { commit?: string }; onDidChange: vscode.Event<void> };
show(ref: string, path: string): Promise<string>;
status(): Promise<void>;
}
interface GitAPI {
repositories: GitRepository[];
getRepository(uri: vscode.Uri): GitRepository | null;
openRepository(root: vscode.Uri): Promise<GitRepository | null>;
onDidOpenRepository: vscode.Event<GitRepository>;
}
export interface HeadBaseline { text: string; commit: string }
export class GitBaselineAdapter implements vscode.Disposable {
private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable
private readonly hooked = new WeakSet<GitRepository>();
private readonly lastCommit = new Map<string, string>(); // repo root → HEAD commit
private readonly emitter = new vscode.EventEmitter<void>();
readonly onDidChangeHead = this.emitter.event;
private readonly disposables: vscode.Disposable[] = [this.emitter];
private async gitApi(): Promise<GitAPI | null> {
if (this.api !== undefined) return this.api;
try {
const ext = vscode.extensions.getExtension("vscode.git");
if (!ext) return (this.api = null);
const exports = ext.isActive ? ext.exports : await ext.activate();
this.api = exports.getAPI(1) as GitAPI;
this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r)));
for (const r of this.api.repositories) this.hook(r);
} catch {
this.api = null;
}
return this.api;
}
/** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */
async headFor(uri: vscode.Uri): Promise<HeadBaseline | null> {
if (uri.scheme !== "file") return null;
const git = await this.gitApi();
if (!git) return null;
let repo = git.getRepository(uri);
if (!repo) repo = await this.discoverNested(git, uri);
if (!repo) return null;
this.hook(repo);
try {
const text = await repo.show("HEAD", uri.fsPath);
return { text, commit: repo.state.HEAD?.commit ?? "" };
} catch {
return null; // untracked, ignored, or no commit yet → snapshot mode
}
}
/** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */
private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise<GitRepository | null> {
let dir = path.dirname(uri.fsPath);
for (let i = 0; i < 32; i++) {
if (fs.existsSync(path.join(dir, ".git"))) {
await git.openRepository(vscode.Uri.file(dir));
return git.getRepository(uri);
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */
private hook(repo: GitRepository): void {
if (this.hooked.has(repo)) return;
this.hooked.add(repo);
this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? "");
this.disposables.push(
repo.state.onDidChange(() => {
const key = repo.rootUri.toString();
const commit = repo.state.HEAD?.commit ?? "";
if (this.lastCommit.get(key) !== commit) {
this.lastCommit.set(key, commit);
this.emitter.fire();
}
}),
);
this.watchReflog(repo);
}
/** Best-effort nudge: watch `.git/logs/HEAD` directly and force `repo.status()`
* on any write, so an out-of-band commit is picked up even when the git
* extension's own watcher doesn't fire (see the class doc). */
private watchReflog(repo: GitRepository): void {
try {
const reflog = path.join(repo.rootUri.fsPath, ".git", "logs", "HEAD");
if (!fs.existsSync(reflog)) return;
let pending: ReturnType<typeof setTimeout> | undefined;
const watcher = fs.watch(reflog, () => {
if (pending) clearTimeout(pending);
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
});
this.disposables.push({ dispose: () => watcher.close() });
} catch {
// best-effort only — the passive repo.state.onDidChange listener still applies.
}
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}
+300
View File
@@ -0,0 +1,300 @@
/**
* previewAnnotations — Task 7 (D3/D21, PUC-3): a pure, vscode-free markdown-it
* plugin that annotates the BUILT-IN VS Code Markdown preview with the F10
* authorship/change-mark vocabulary (`.cw-ins-claude` / `.cw-ins-human` /
* `.cw-del`) — the native-surfaces migration's replacement for the sunset
* webview's review body (spec §6.4/§6.10). Reuses the shipped sentinel
* discipline (`injectSentinels`/`sentinelsToSpans`, #33/#47-hardened) instead
* of inventing a new one: `annotateSource` computes PUA-sentinel-wrapped
* markdown SOURCE (never HTML) against the F6/F7 baseline + F3 spans + F4
* pending proposals; `cowritingMarkdownItPlugin` wires that transform into the
* shared `markdown-it` instance (the SAME instance VS Code's built-in preview
* uses via `markdown.markdownItPlugins`) as (a) a core rule — swap `state.src`
* before `normalize` — and (b) a full-render wrapper that turns surviving
* sentinel pairs into `<span class="cw-…">`.
*
* Implementation note (resolved drift from the migration plan's Task 7 sketch,
* which described (b) as "a text renderer rule"): `sentinelsToSpans` is a
* stateful, single-pass walk over one HTML string — installing it as a
* PER-TOKEN `renderer.rules.text` hook would reset that state at every text
* token boundary and silently break the "survives emphasis/tag boundaries"
* invariant (#33 CASE3) for any span whose sentinel-open and sentinel-close
* land in DIFFERENT text tokens (e.g. either side of a `**bold**` run — the
* exact scenario CASE3 exists to cover). Wrapping `md.renderer.render` instead
* runs the walk ONCE over the fully-assembled HTML, exactly like the F9/F10
* `colorByAuthor` precedent (`render(injected)` — a whole-block HTML string,
* not a token fragment) — this is what actually delivers the stated invariant,
* robust to callers that invoke `md.render()` directly or `md.parse()` +
* `md.renderer.render()` separately (VS Code's own scroll-sync `data-line`
* injection uses the latter).
*
* `host.inputsFor(env)` is the ONE thin, vscode-touching seam (owned by
* `extension.ts`): `env.currentDocument` is an OBSERVED VS Code preview-engine
* behavior, not a documented contract, so the host falls back to the last
* actively-coedited document when it is absent (see extension.ts's
* `lastCoeditedUri`).
*
* Deliberate choice: the mermaid-fence rendering (the `options.highlight`
* override installed below) applies to EVERY markdown preview, coedited or
* not — it is not gated on coediting state. Gating it would make a diagram's
* rendering flip (mermaid <-> plain fence) the instant a doc enters or exits
* coediting, which is a worse surprise than always rendering mermaid diagrams
* as diagrams; only the change-tracking OVERLAY on top (`buildMermaidQueue`'s
* diff augmentation) is gated on having a baseline to diff against.
*/
import type MarkdownIt from "markdown-it";
import { diffMermaid } from "./mermaidDiff";
import {
diffBlocks,
injectSentinels,
landedTextOf,
MERMAID_LEGEND,
mermaidFenceBody,
sentinelsToSpans,
splitBlocksWithRanges,
wordEditHunks,
type AuthorSpan,
type ProposalView,
type TaggedSpan,
} from "./trackChangesModel";
export interface AnnotationInputs {
baselineText: string | undefined;
baselineReason: "entered" | "pinned" | "head" | undefined;
spans: AuthorSpan[];
proposals: ProposalView[];
enabled: boolean;
}
/**
* Inject the sentinel vocabulary into `text` block-by-block (INV-23: code/
* mermaid fences stay atomic — never sentinel-injected, never word-refined) so
* the delimiter-run + block-marker safety `injectSentinels` carries applies
* PER BLOCK, not just at the very top of the whole document (its clamps are
* anchored to a block's own start, matching every other caller — `colorByAuthor`
* is always invoked per block too).
*/
function injectAllSentinels(text: string, marks: TaggedSpan[]): string {
if (marks.length === 0) return text;
const blocks = splitBlocksWithRanges(text);
if (blocks.length === 0) return text;
let out = "";
let cursor = 0;
for (const b of blocks) {
out += text.slice(cursor, b.start); // preserve inter-block gaps (blank lines) verbatim
if (b.type === "prose") {
const blockMarks = marks.filter((m) => m.end > b.start && m.start < b.end);
out += blockMarks.length ? injectSentinels(b.raw, b.start, blockMarks) : b.raw;
} else {
out += b.raw; // INV-23: fences are never annotated
}
cursor = b.end;
}
out += text.slice(cursor); // trailing content after the last block
return out;
}
/**
* Pure: markdown source -> sentinel-annotated source (authorship + change +
* proposal marks vs the F6/F7 baseline). Skips everything when annotations are
* disabled, or (the #48 rule) once the baseline was just PINNED with zero
* LANDED diff — pending F4 proposals are unaffected by that gate (matching the
* shipped preview's "pin→clean, proposals still show", #48/INV-33).
*/
export function annotateSource(src: string, inputs: AnnotationInputs): string {
if (!inputs.enabled) return src;
const proposals = inputs.proposals ?? [];
const baselineText = inputs.baselineText ?? src; // no baseline → no change-marks
const landedText = landedTextOf(src, proposals);
const pinnedZeroDiff = inputs.baselineReason === "pinned" && landedText === baselineText;
const insSpans: TaggedSpan[] = [];
const deletions: { at: number; text: string }[] = [];
const appliedRanges: { start: number; end: number }[] = [];
// (c) pending F4 proposals already sit applied in `src` (F12 optimistic
// apply, INV-48) — mark the applied range ins-<author> (`p.author` — a
// proposal defaults to "claude" per `ProposalView.author`, but a human can
// also be the proposer, e.g. a human-authored suggestion routed through the
// same F4 pending-proposal path) and resurface the pre-apply original as a
// deletion at the same point.
for (const p of proposals) {
if (p.anchorStart === null || p.anchorEnd === null) continue;
appliedRanges.push({ start: p.anchorStart, end: p.anchorEnd });
if (p.anchorEnd > p.anchorStart) insSpans.push({ start: p.anchorStart, end: p.anchorEnd, tag: p.author ?? "claude" });
const original = p.original ?? p.replaced;
if (original) deletions.push({ at: p.anchorStart, text: original });
}
// (a)/(b) committed changes-since-baseline. Each hunk (wordEditHunks(src,
// baseline) — start/end in SRC coords, `replacement` = the baseline-side text
// for that span) yields (a) insertion marks from the F3 `spans` CLIPPED to the
// hunk's changed range — a hunk may straddle more than one author (e.g. two
// adjacent words typed by different authors with nothing committed between
// them), so spans are clipped individually, never the whole hunk tagged with
// one author — and (b) the baseline's dropped text reinserted at the hunk
// start. A hunk owned by an applied pending proposal is already marked above.
if (!pinnedZeroDiff && landedText !== baselineText) {
for (const h of wordEditHunks(src, baselineText)) {
if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue;
for (const s of inputs.spans) {
const start = Math.max(s.start, h.start);
const end = Math.min(s.end, h.end);
if (end > start) insSpans.push({ start, end, tag: s.author });
}
if (h.replacement) deletions.push({ at: h.start, text: h.replacement });
}
}
if (insSpans.length === 0 && deletions.length === 0) return src;
// Splice deletion reinsertions into the source high→low (so earlier offsets
// stay valid), shifting every mark recorded so far and appending a "del" mark
// over the freshly-inserted text.
const marks: TaggedSpan[] = insSpans.map((s) => ({ ...s }));
let text = src;
for (const d of [...deletions].sort((a, b) => b.at - a.at)) {
text = text.slice(0, d.at) + d.text + text.slice(d.at);
for (const m of marks) {
if (m.start >= d.at) m.start += d.text.length;
if (m.end >= d.at) m.end += d.text.length;
}
marks.push({ start: d.at, end: d.at + d.text.length, tag: "del" });
}
return injectAllSentinels(text, marks);
}
/** One `buildMermaidQueue` entry: a changed mermaid fence's own body (for
* matching, `mermaidFenceBody` normalization — no trailing newline) paired
* with its `diffMermaid`-augmented replacement source. `consumed` is reset by
* the `render` wrapper (see `cowritingMarkdownItPlugin`) so the SAME entries
* can back a re-render without a fresh parse. */
interface MermaidQueueEntry {
readonly curBody: string;
readonly source: string;
consumed: boolean;
}
/**
* Per-render queue of mermaid-fence overrides: one entry per `changed` atomic
* mermaid op (Task 7 §2.6 parity: reuses `diffBlocks`' baseline/current block
* pairing, the SAME pairing `renderReview`/`renderOp` used in the sunset
* webview, so a changed mermaid fence is re-emitted through `diffMermaid`
* exactly as it was there — INV-23 fences stay atomic, INV-29..31 styling
* directives) that has a diffable before/current pair AND a successful
* `diffMermaid` augmentation. `[]` when there is no baseline to diff against,
* annotations are disabled, or the landed text equals the baseline (covers
* the #48 pinned-zero-diff case too, since `diffBlocks` on identical text
* yields no `changed` ops).
*
* Diffed against `landedText` (proposals reverted), matching the "committed
* change since baseline" boundary `annotateSource`'s own `wordEditHunks(src,
* baselineText)` gate already draws (a pending, not-yet-accepted proposal
* isn't a landed change) — a fence a pending proposal's anchor overlaps is
* left unaugmented, same as the rest of that gate.
*
* NOT positional: `src` (what markdown-it actually parses fences from) and
* `landedText` (what this function diffs) can disagree on mermaid-fence COUNT
* or ORDER — e.g. a pending proposal optimistically applied in `src` (F12,
* INV-48) inserts a whole new mermaid fence that `landedText` (proposals
* reverted) doesn't have, or `annotateSource`'s committed-deletion reinsert
* splices an extra fence into the rendered source. `options.highlight`
* (`cowritingMarkdownItPlugin`) matches each queue entry to a fence by BODY,
* not by position — a fence with no matching entry renders verbatim (the
* queue is a bag of `changed` fences to find, not a strict per-fence
* override list), so misalignment degrades to verbatim exactly as intended,
* never substitutes the wrong diagram's augmentation into the wrong fence.
*
* Pure, vscode-free (INV-6).
*/
function buildMermaidQueue(src: string, inputs: AnnotationInputs): MermaidQueueEntry[] {
if (!inputs.enabled || inputs.baselineText === undefined) return [];
const baselineText = inputs.baselineText;
const landedText = landedTextOf(src, inputs.proposals ?? []);
if (landedText === baselineText) return [];
const entries: MermaidQueueEntry[] = [];
for (const op of diffBlocks(baselineText, landedText)) {
if (op.kind !== "changed" || op.block.type !== "mermaid") continue; // unchanged/added/removed: no override needed
const curBody = mermaidFenceBody(op.block.raw);
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
if (curBody === null || beforeBody === null) continue;
const result = diffMermaid(beforeBody, curBody);
if (result.kind === "augmented") entries.push({ curBody, source: result.source, consumed: false }); // INV-30 fallback: verbatim
}
return entries;
}
/**
* markdown-it plugin factory: the host supplies `AnnotationInputs` per render
* (`host.inputsFor(env)`; `undefined` = no coediting context for this render —
* source passed through untouched). Installs (a) a core rule BEFORE `normalize`
* that swaps `state.src` for `annotateSource(...)`, and (b) a full-render
* wrapper (see module docstring) that turns surviving sentinel pairs into
* `cw-…` spans. Also teaches ```mermaid fences to render as `<pre class="mermaid">`
* (Q4/Task 7 Step 2.6) via `options.highlight` — the same extension point the
* built-in preview's own fence rule already calls for syntax highlighting — so
* the bundled `media/preview-mermaid.js` previewScript has something to run
* mermaid over (the built-in preview does not render mermaid on its own). A
* CHANGED mermaid fence's source is re-emitted through `diffMermaid` first
* (`buildMermaidQueue`, Task 7 §2.6 parity) so mermaid renders the intra-
* diagram diff (INV-29..31), not just the current diagram. `options.highlight`
* has no `env`/`state` parameter of its own, so the per-render queue is
* threaded through a closure variable set by the core rule (which DOES see
* `state.env`) and consumed BY BODY MATCH, not fence position (see
* `buildMermaidQueue`), by `highlight`: each fence's `code` is matched against
* unconsumed queue entries in order, the first match is consumed (so a later
* fence with the same body still finds its own entry), and a fence with no
* match renders verbatim WITHOUT consuming anything (so a later fence can
* still match). markdown-it's `parse` (core rules) always completes before
* `renderer.render` invokes `highlight`, and a single md instance never
* renders concurrently, so the closure handoff itself is safe — but VS Code
* may call `md.parse()` and `md.renderer.render()` separately and re-render
* cached tokens without a fresh parse (the same caller pattern the module
* docstring notes for scroll-sync), which would otherwise replay a queue whose
* entries were already marked consumed by the prior render; the `render`
* wrapper resets every entry's `consumed` flag so each render pass starts with
* the full queue available again.
*/
export function cowritingMarkdownItPlugin(
md: MarkdownIt,
host: { inputsFor(env: unknown): AnnotationInputs | undefined },
): MarkdownIt {
let mermaidQueue: MermaidQueueEntry[] = [];
md.core.ruler.before("normalize", "cowriting-annotate", (state) => {
const inputs = host.inputsFor(state.env);
mermaidQueue = inputs ? buildMermaidQueue(state.src, inputs) : [];
if (!inputs) return;
state.src = annotateSource(state.src, inputs);
});
const baseRender = md.renderer.render.bind(md.renderer);
md.renderer.render = (tokens, options, env) => {
for (const entry of mermaidQueue) entry.consumed = false; // re-render-without-reparse safety net
return sentinelsToSpans(baseRender(tokens, options, env), "ins");
};
const baseHighlight = md.options.highlight;
md.options.highlight = (code, lang, attrs) => {
if (lang?.trim().toLowerCase() === "mermaid") {
const normalized = code.replace(/\n+$/, ""); // token.content keeps a trailing LF; mermaidFenceBody doesn't
const entry = mermaidQueue.find((e) => !e.consumed && e.curBody === normalized);
if (entry) entry.consumed = true;
const legend = entry ? MERMAID_LEGEND : "";
return `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(entry?.source ?? code)}</pre>${legend}`;
}
return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code);
};
return md;
}
/** Minimal HTML-escape for a mermaid fence body (no markdown parsing — verbatim diagram source). */
function escapeMermaidSource(source: string): string {
return source
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n+$/, "");
}
+41 -3
View File
@@ -19,6 +19,7 @@ import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard"; import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import { wordEditHunks, type ProposalView } from "./trackChangesModel"; import { wordEditHunks, type ProposalView } from "./trackChangesModel";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** Test-facing snapshot of what is currently rendered for a document. */ /** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedProposal { export interface RenderedProposal {
@@ -56,11 +57,36 @@ export class ProposalController implements vscode.Disposable {
private readonly attribution: AttributionController, private readonly attribution: AttributionController,
private readonly rootDir: string | undefined, private readonly rootDir: string | undefined,
private readonly guard: VersionGuard, private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
) { ) {
this.disposables.push(this.statusItem); this.disposables.push(this.statusItem);
this.disposables.push(this.onDidChangeProposalsEmitter); this.disposables.push(this.onDidChangeProposalsEmitter);
this.disposables.push( this.disposables.push(
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
// PUC-7 restore-on-enter (mirrors ThreadController/AttributionController):
// `renderAll` is the only path that recomputes state.live/unresolved AND
// fires onDidChangeProposals — the event EditorProposalController listens
// to for optimistic-apply (decorations/CodeLens). Without this, a FIRST
// entry into coediting (registry.enter()) never fires it, so pre-existing
// pending proposals stay un-applied/undecorated until the next edit.
// `renderAll` already gates on isCoediting; exit intentionally does
// nothing here (it clears only the live/unresolved recompute, not the
// persisted artifact — EditorProposalController's own gate hides the
// decorations). Registered after AttributionController's own subscription
// (construction order in extension.ts) so decorateCommitted's spansFor
// read sees fresh attribution on the same enter transition.
this.registry.onDidChange(({ uri, coediting }) => {
if (!coediting) {
// Finding 5 (final whole-branch review): exit hides the status-bar
// item too — otherwise a stale "N stale proposals" warning for a
// doc that's no longer even coediting stays pinned in the bar until
// some unrelated render happens to fire for it.
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
return;
}
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.renderAll(doc);
}),
); );
} }
@@ -380,8 +406,13 @@ export class ProposalController implements vscode.Disposable {
/** /**
* F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is * F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is
* already in the buffer, so this only advances the F6 baseline (machine-landing, * already in the buffer, so this only marks the attribution landing (via
* via `attribution.signalLanded`) and clears the proposal. No re-application. * `attribution.signalLanded`) and clears the proposal. No re-application.
* Native-surfaces migration (INV-7/INV-18 retirement, spec §6.4): this no
* longer advances the baseline — a landed proposal stays a visible change-
* since-baseline until commit (head mode) or "Mark Changes as Reviewed"
* (snapshot mode); nothing here subscribes to `signalLanded` for baseline
* purposes anymore (the `onDidApplyAgentEdit → advance` wire was removed).
*/ */
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> { async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId); const hit = this.byId(docPath, proposalId);
@@ -454,7 +485,13 @@ export class ProposalController implements vscode.Disposable {
// ---- PUC-4: load / external change / resolve-or-flag -------------------------------- // ---- PUC-4: load / external change / resolve-or-flag --------------------------------
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */ /** Load + (re)render every pending proposal at its resolved anchor (or flagged).
* INV-10: the RENDER portion (live/unresolved recompute, the status bar, and
* the onDidChangeProposals fire that drives F12's optimistic-apply/CodeLens/
* decorations) is gated on coediting — `propose()` itself stays ungated (it
* always persists), and so does the state LOAD below: `acceptById`/`rejectById`
* key off `this.docs` (populated here) regardless of coediting, since deciding
* an already-persisted proposal is not a "rendering" surface. */
renderAll(document: vscode.TextDocument): void { renderAll(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return; if (!this.isTracked(document)) return;
const docPath = this.keyOf(document); const docPath = this.keyOf(document);
@@ -462,6 +499,7 @@ export class ProposalController implements vscode.Disposable {
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
state.live.clear(); state.live.clear();
state.unresolved.clear(); state.unresolved.clear();
if (!this.registry.isCoediting(document.uri)) return;
const text = document.getText(); const text = document.getText();
for (const proposal of state.artifact.proposals) { for (const proposal of state.artifact.proposals) {
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
+120
View File
@@ -0,0 +1,120 @@
/**
* ScmSurfaceController — the native diff surface (spec §6.4/§5, INV-13).
* Owns: the "Cowriting" SourceControl + QuickDiffProvider (gutter bars), the
* cowriting-baseline: TextDocumentContentProvider, the "Review Changes" native
* diff command, and the "✦ Coediting · N changes" status-bar item. Gated on
* the CoeditingRegistry (INV-10): non-entered docs resolve no original.
*/
import * as path from "node:path";
import * as vscode from "vscode";
import type { CoeditingRegistry } from "./coeditingRegistry";
import type { DiffViewController } from "./diffViewController";
import { countLineHunks } from "./trackChangesModel";
export const BASELINE_SCHEME = "cowriting-baseline";
export function baselineUriFor(docUri: vscode.Uri): vscode.Uri {
const name = path.basename(docUri.path) || "untitled.md";
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: `/${name}`, query: encodeURIComponent(docUri.toString()) });
}
export class ScmSurfaceController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly statusItem: vscode.StatusBarItem;
private readonly counts = new Map<string, number>();
private readonly baselineEmitter = new vscode.EventEmitter<vscode.Uri>();
private readonly recountTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly registry: CoeditingRegistry,
private readonly diffView: DiffViewController,
) {
this.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
this.statusItem.command = "cowriting.reviewChanges";
this.statusItem.tooltip = "Cowriting — click to review changes (baseline ⟷ live)";
this.disposables.push(this.statusItem, this.baselineEmitter);
this.disposables.push(
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, {
onDidChange: this.baselineEmitter.event,
provideTextDocumentContent: (uri) =>
this.diffView.getBaseline(decodeURIComponent(uri.query))?.text ?? "",
}),
);
const sc = vscode.scm.createSourceControl("cowriting", "✦ Cowriting");
sc.quickDiffProvider = {
provideOriginalResource: (uri: vscode.Uri) =>
this.registry.isCoediting(uri) && this.diffView.getBaseline(uri.toString())
? baselineUriFor(uri)
: null,
};
this.disposables.push(sc);
this.disposables.push(
vscode.commands.registerCommand("cowriting.reviewChanges", () => this.openReview()),
this.diffView.onDidChangeBaseline(({ uri }) => {
this.baselineEmitter.fire(baselineUriFor(vscode.Uri.parse(uri)));
this.scheduleRecount(uri);
}),
this.registry.onDidChange(({ uri, coediting }) => {
if (coediting) this.scheduleRecount(uri);
this.refreshStatus();
}),
vscode.workspace.onDidChangeTextDocument((e) => {
if (this.registry.isCoediting(e.document.uri)) this.scheduleRecount(e.document.uri.toString());
}),
vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()),
);
this.refreshStatus();
}
changeCount(uriString: string): number {
return this.counts.get(uriString) ?? 0;
}
private async openReview(): Promise<void> {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
void vscode.window.showWarningMessage("Cowriting: not coediting this document.");
return;
}
this.baselineEmitter.fire(baselineUriFor(ed.document.uri));
await vscode.commands.executeCommand(
"vscode.diff",
baselineUriFor(ed.document.uri),
ed.document.uri,
`${path.basename(ed.document.uri.path)} — Coediting (baseline ⟷ live)`,
{ preview: true },
);
}
private scheduleRecount(uriString: string): void {
const prev = this.recountTimers.get(uriString);
if (prev !== undefined) clearTimeout(prev);
this.recountTimers.set(uriString, setTimeout(() => {
this.recountTimers.delete(uriString);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
const baseline = this.diffView.getBaseline(uriString);
if (!doc || !baseline) return;
this.counts.set(uriString, countLineHunks(baseline.text, doc.getText()));
this.refreshStatus();
}, 150));
}
private refreshStatus(): void {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
this.statusItem.hide();
return;
}
const n = this.counts.get(ed.document.uri.toString()) ?? 0;
this.statusItem.text = `$(sparkle) Coediting · ${n} change${n === 1 ? "" : "s"}`;
this.statusItem.show();
}
dispose(): void {
for (const t of this.recountTimers.values()) clearTimeout(t);
for (const d of this.disposables) d.dispose();
}
}
+293 -16
View File
@@ -15,6 +15,10 @@ import { gitUserEmail } from "./identity";
import { addThread, appendMessage, setStatus } from "./threadModel"; import { addThread, appendMessage, setStatus } from "./threadModel";
import type { VersionGuard } from "./versionGuard"; import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { CoeditingRegistry } from "./coeditingRegistry";
import type { EditFlow, EditTarget } from "./editFlow";
import type { LiveProgressUi } from "./liveProgressUi";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
/** Test-facing snapshot of what is currently rendered for a document. */ /** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedThread { export interface RenderedThread {
@@ -22,8 +26,22 @@ export interface RenderedThread {
status: "open" | "resolved"; status: "open" | "resolved";
orphaned: boolean; orphaned: boolean;
range: { startLine: number; endLine: number }; range: { startLine: number; endLine: number };
/** Finding 2 (final whole-branch review): the raw `contextValue` VS Code's
* `comments/commentThread/*` `when`-clauses key on — exposed directly so
* E2E can assert the status token survives an offer transition without a
* DOM/UI query. */
contextValue: string;
} }
/** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */
const REPLY_PROMPT = (ask: string): string =>
"Reply conversationally (2-4 sentences) to this remark about the text, then, if the remark implies " +
"an edit, state the edit you would make. Remark: " +
ask;
/** F6.x/Task 6: the injectable low-level turn runner behind respondInThread. */
type TurnRunner = (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
interface DocState { interface DocState {
docPath: string; docPath: string;
uri: vscode.Uri; uri: vscode.Uri;
@@ -34,25 +52,45 @@ interface DocState {
live: Map<string, OffsetRange>; live: Map<string, OffsetRange>;
/** thread id -> orphaned flag for the current render. */ /** thread id -> orphaned flag for the current render. */
orphaned: Map<string, boolean>; orphaned: Map<string, boolean>;
/** Finding 2 (final whole-branch review): thread id -> in-flight offer-flow
* token ("offer" once a machine reply lands, "offerdone" once spent),
* tracked SEPARATELY from the open/resolved/orphaned status so setting one
* never clobbers the other — both are folded into one space-separated
* `contextValue` string by `applyContextValue` (package.json when-clauses
* match either token with an unanchored regex). Ephemeral: reset on every
* render (a fresh vsThread never carries a stale offer). */
offerToken: Map<string, "offer" | "offerdone">;
} }
export class ThreadController implements vscode.Disposable { export class ThreadController implements vscode.Disposable {
private readonly controller: vscode.CommentController; private readonly controller: vscode.CommentController;
private readonly disposables: vscode.Disposable[] = []; private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath private readonly docs = new Map<string, DocState>(); // keyed by docPath
/** thread -> the pending machine "offer" (D8): the ask it answered, ready to become an edit. */
private readonly offers = new WeakMap<vscode.CommentThread, { ask: string; threadId: string }>();
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
private turnRunner: TurnRunner | undefined;
/** Kept as ONE object; re-assigned on every registry change so VS Code re-queries
* (spec §6.4 v0.2.1 — reassignment is the API's only "ranges changed" signal). */
private readonly rangeProvider: vscode.CommentingRangeProvider = {
provideCommentingRanges: (document) => {
if (!isAuthorable(document.uri.scheme)) return [];
if (!this.registry.isCoediting(document.uri)) return [];
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
},
};
constructor( constructor(
private readonly store: SidecarRouter, private readonly store: SidecarRouter,
private readonly rootDir: string | undefined, private readonly rootDir: string | undefined,
private readonly guard: VersionGuard, private readonly guard: VersionGuard,
private readonly registry: CoeditingRegistry,
private readonly editFlow: EditFlow,
private readonly liveProgressUi: LiveProgressUi,
) { ) {
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
this.controller.commentingRangeProvider = { this.controller.commentingRangeProvider = this.rangeProvider;
provideCommentingRanges: (document) => {
if (!isAuthorable(document.uri.scheme)) return [];
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
},
};
this.disposables.push(this.controller); this.disposables.push(this.controller);
this.disposables.push( this.disposables.push(
@@ -64,8 +102,30 @@ export class ThreadController implements vscode.Disposable {
vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) => vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) =>
this.setThreadStatus(t, "open"), this.setThreadStatus(t, "open"),
), ),
// D19 (spec §6.4 v0.2.1): the comments-first "Ask Claude" gesture — a
// focused comment box, not a webview.
vscode.commands.registerCommand("cowriting.askClaude", () => this.askClaude()),
// D8: turn a machine "offer" thread into pending F4 proposal(s) (INV-5).
vscode.commands.registerCommand(
"cowriting.makeThreadEdit",
(arg: { thread: vscode.CommentThread } | vscode.CommentThread) => this.makeEditFromThreadArg(arg),
),
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
// INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate
// change, and hide/restore rendered threads — exit hides only (dispose the
// rendered vsThreads), re-enter restores (renderAll reloads from the
// sidecar, which is the durable source of truth regardless of gate state).
this.registry.onDidChange(({ uri, coediting }) => {
this.controller.commentingRangeProvider = this.rangeProvider;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc) return;
if (coediting) {
this.renderAll(doc);
} else {
this.disposeRendered(this.keyOf(doc));
}
}),
); );
} }
@@ -100,17 +160,44 @@ export class ThreadController implements vscode.Disposable {
vsThreads: new Map(), vsThreads: new Map(),
live: new Map(), live: new Map(),
orphaned: new Map(), orphaned: new Map(),
offerToken: new Map(),
}; };
this.docs.set(docPath, state); this.docs.set(docPath, state);
} }
return state; return state;
} }
// ---- D19: comments-first ask -----------------------------------------------------
/**
* D19 (spec §6.4 v0.2.1): "Ask Claude" opens a focused comment box on the
* active editor's selection, or, when there is no selection, a top-anchored
* whole-document thread. The ask itself IS a comment — respondInThread (D10)
* takes it from there once the human submits it via cowriting.reply.
*/
async askClaude(): Promise<void> {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
return;
}
if (ed.selection.isEmpty) {
// whole-document ask → top-anchored thread (D19)
ed.selection = new vscode.Selection(0, 0, 0, 0);
ed.revealRange(new vscode.Range(0, 0, 0, 0));
}
// Spike finding: workbench.action.addComment is the only path that opens the
// comment widget WITH its input focused (no thread.focus() API exists).
this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh
await vscode.commands.executeCommand("workbench.action.addComment");
}
// ---- PUC-1: create on selection ------------------------------------------------- // ---- PUC-1: create on selection -------------------------------------------------
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> { async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
const editor = vscode.window.activeTextEditor; const editor = vscode.window.activeTextEditor;
if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined; if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined;
if (!this.registry.isCoediting(editor.document.uri)) return undefined;
if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined; if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined;
const document = editor.document; const document = editor.document;
const state = this.ensureState(document); const state = this.ensureState(document);
@@ -119,22 +206,175 @@ export class ThreadController implements vscode.Disposable {
end: document.offsetAt(editor.selection.end), end: document.offsetAt(editor.selection.end),
}; };
const fp = buildFingerprint(document.getText(), offsets); const fp = buildFingerprint(document.getText(), offsets);
const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody }); const author = this.currentAuthor();
const { threadId } = addThread(state.artifact, fp, { author, body: firstBody });
this.persist(state); this.persist(state);
this.renderThread(document, state, threadId, offsets, false); const vsThread = this.renderThread(document, state, threadId, offsets, false);
// D10: the first message of a new thread is also "a comment on a coedited
// doc" — run the same respond loop as a reply. Finding 2 guard: only for a
// human-authored message (never re-trigger the loop off the machine's own
// words — see reply() below for the matching guard).
if (author.kind !== "agent") {
void this.respondInThread(vsThread, state, threadId, firstBody);
}
return threadId; return threadId;
} }
// ---- PUC-2: reply / resolve ----------------------------------------------------- // ---- PUC-2: reply / resolve -----------------------------------------------------
reply(r: vscode.CommentReply): void { reply(r: vscode.CommentReply): void {
const threadId = this.threadIdOf(r.thread); const vsThread = r.thread;
const state = this.stateOfThread(r.thread); if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) return;
if (!threadId || !state) return; const document = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
if (!document) return;
const state = this.ensureState(document);
if (this.guard.isReadOnly(state.docPath)) return; if (this.guard.isReadOnly(state.docPath)) return;
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); const author = this.currentAuthor();
let threadId = this.threadIdOf(vsThread);
if (threadId) {
appendMessage(state.artifact, threadId, { author, body: r.text });
} else {
// D10/D19: a brand-new thread created via the native "+"/comment-widget
// gesture (not via createThreadOnSelection) — VS Code hands us a real,
// still-unregistered CommentThread; register it now.
const range = vsThread.range ?? new vscode.Range(0, 0, 0, 0);
const offsets: OffsetRange = {
start: document.offsetAt(range.start),
end: document.offsetAt(range.end),
};
const fp = buildFingerprint(document.getText(), offsets);
const created = addThread(state.artifact, fp, { author, body: r.text });
threadId = created.threadId;
state.vsThreads.set(threadId, vsThread);
state.live.set(threadId, offsets);
state.orphaned.set(threadId, false);
// Finding 2 (final whole-branch review): a thread born via the native
// "+" gesture skipped renderThread entirely, so it never got a status
// contextValue at all — Resolve/Reopen never appeared for it. Stamp one
// now, same as every other thread.
this.applyContextValue(state, threadId, vsThread);
}
this.persist(state); this.persist(state);
this.refreshComments(r.thread, state, threadId); this.refreshComments(vsThread, state, threadId);
// D10: every HUMAN comment on a coedited doc runs a turn. Finding 2 guard:
// `cowriting.reply` is the ingress for both a genuine human reply and (in
// principle) a re-dispatched machine message — never loop the machine's own
// reply back through respondInThread (it would double-turn on Claude's words
// and re-flip an already-"offer" thread).
if (author.kind !== "agent") {
void this.respondInThread(vsThread, state, threadId, r.text);
}
}
/**
* D10/PUC-8: run one reply turn for `ask`, then append the machine's reply
* (INV-8 onBehalfOf provenance) and flip the thread into an "offer" — ready
* for makeThreadEdit to cut it into pending proposal(s) (D8, INV-5).
*/
private async respondInThread(
vsThread: vscode.CommentThread,
state: DocState,
threadId: string,
ask: string,
): Promise<void> {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
if (!doc) return;
const contextText = this.threadContextText(doc, vsThread);
try {
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true },
async (progress, token) => {
const run = this.turnRunner ?? (await import("./liveTurn")).runEditTurn;
const ui = this.liveProgressUi.begin(ask, progress, token);
let turn: EditTurnResult;
try {
turn = await run(REPLY_PROMPT(ask), contextText, { onProgress: ui.onProgress, signal: ui.signal });
} catch (err) {
if (token.isCancellationRequested) return;
throw err;
}
// machine reply, onBehalfOf provenance (INV-8)
appendMessage(state.artifact, threadId, {
author: {
kind: "agent",
id: "claude",
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId, onBehalfOf: this.currentAuthor() },
},
body: turn.replacement,
});
this.persist(state);
this.refreshComments(vsThread, state, threadId);
// Finding 2: fold "offer" in alongside the status token — see
// `applyContextValue` — instead of clobbering it (when-key:
// `commentThread =~ /\boffer\b/`).
state.offerToken.set(threadId, "offer");
this.applyContextValue(state, threadId, vsThread);
this.offers.set(vsThread, { ask, threadId });
},
);
} catch (err) {
void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`);
}
}
/** The passage a ranged thread discusses, or the whole document for a top-anchored (D19) thread. */
private threadContextText(doc: vscode.TextDocument, vsThread: vscode.CommentThread): string {
const range = vsThread.range;
if (range && !range.isEmpty) return doc.getText(range);
return doc.getText();
}
/** D19: a ranged thread targets its range; a top-anchored (whole-doc-ask) thread targets the document. */
private targetOf(doc: vscode.TextDocument, vsThread: vscode.CommentThread): EditTarget {
const range = vsThread.range;
if (!range || range.isEmpty) return { kind: "document" };
return { kind: "range", start: doc.offsetAt(range.start), end: doc.offsetAt(range.end) };
}
// ---- D8: offer -> pending proposal(s) (INV-5) -------------------------------------
private async makeEditFromThreadArg(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise<void> {
const vsThread = "thread" in arg ? arg.thread : arg;
await this.runMakeEdit(vsThread);
}
private async runMakeEdit(vsThread: vscode.CommentThread): Promise<string[]> {
const offer = this.offers.get(vsThread);
if (!offer || !this.registry.isCoediting(vsThread.uri)) return [];
const doc = await vscode.workspace.openTextDocument(vsThread.uri);
const target = this.targetOf(doc, vsThread);
const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask);
// Finding 2 (final whole-branch review): fold "offerdone" in alongside the
// status token rather than clobbering it — see `applyContextValue`.
const state = this.stateOfThread(vsThread);
if (state) {
state.offerToken.set(offer.threadId, "offerdone");
this.applyContextValue(state, offer.threadId, vsThread);
}
// Finding 3 (seam hygiene): drop the offer once it's spent — otherwise the
// `makeThreadEdit(threadId, docPath)` test seam (or a stray second UI click,
// now dead per contextValue but still a valid direct call) could re-run the
// same edit a second time. A future offer on this thread is a fresh
// respondInThread() call, which re-populates the WeakMap entry.
this.offers.delete(vsThread);
if (ids.length) {
void vscode.window.showInformationMessage(
`Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`,
);
}
return ids;
}
/** Test seam (Task 6): run the offer for a thread without the UI button. */
async makeThreadEdit(threadId: string, docPath: string): Promise<string[]> {
const vsThread = this.docs.get(docPath)?.vsThreads.get(threadId);
if (!vsThread) return [];
return this.runMakeEdit(vsThread);
}
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
setTurnRunnerForTest(fn: TurnRunner): void {
this.turnRunner = fn;
} }
private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void { private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void {
@@ -145,13 +385,14 @@ export class ThreadController implements vscode.Disposable {
setStatus(state.artifact, threadId, status); setStatus(state.artifact, threadId, status);
this.persist(state); this.persist(state);
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
vsThread.contextValue = status; this.applyContextValue(state, threadId, vsThread); // preserves any in-flight offer token
} }
// ---- PUC-3/4: render, reload, re-anchor ----------------------------------------- // ---- PUC-3/4: render, reload, re-anchor -----------------------------------------
/** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */ /** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */
renderAll(document: vscode.TextDocument): void { renderAll(document: vscode.TextDocument): void {
if (!this.registry.isCoediting(document.uri)) return;
const docPath = this.keyOf(document); const docPath = this.keyOf(document);
const state = this.ensureState(document); const state = this.ensureState(document);
// fresh artifact from disk (reload / external change) // fresh artifact from disk (reload / external change)
@@ -160,6 +401,7 @@ export class ThreadController implements vscode.Disposable {
state.vsThreads.clear(); state.vsThreads.clear();
state.live.clear(); state.live.clear();
state.orphaned.clear(); state.orphaned.clear();
state.offerToken.clear();
const text = document.getText(); const text = document.getText();
for (const thread of state.artifact.threads) { for (const thread of state.artifact.threads) {
const fp = state.artifact.anchors[thread.anchorId]?.fingerprint; const fp = state.artifact.anchors[thread.anchorId]?.fingerprint;
@@ -225,7 +467,7 @@ export class ThreadController implements vscode.Disposable {
threadId: string, threadId: string,
offsets: OffsetRange, offsets: OffsetRange,
orphaned: boolean, orphaned: boolean,
): void { ): vscode.CommentThread {
const thread = state.artifact.threads.find((t) => t.id === threadId)!; const thread = state.artifact.threads.find((t) => t.id === threadId)!;
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
const vsThread = this.controller.createCommentThread( const vsThread = this.controller.createCommentThread(
@@ -234,12 +476,34 @@ export class ThreadController implements vscode.Disposable {
thread.messages.map((m) => this.toComment(m.body, m.author.id)), thread.messages.map((m) => this.toComment(m.body, m.author.id)),
); );
vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined; vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined;
vsThread.contextValue = orphaned ? "orphaned" : thread.status;
vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
state.vsThreads.set(threadId, vsThread); state.vsThreads.set(threadId, vsThread);
state.live.set(threadId, offsets); state.live.set(threadId, offsets);
state.orphaned.set(threadId, orphaned); state.orphaned.set(threadId, orphaned);
state.offerToken.delete(threadId); // a freshly-created vsThread never carries a stale offer
this.applyContextValue(state, threadId, vsThread);
return vsThread;
}
/**
* Finding 2 (final whole-branch review): `contextValue` is the ONE string
* VS Code's `comments/commentThread/*` `when`-clauses can key on, but two
* INDEPENDENT things need to show through it — the open/resolved/orphaned
* status (Resolve/Reopen) and the offer-flow state (makeThreadEdit). Folding
* both into one space-separated string (`"open offer"`, `"resolved"`, …)
* with package.json `when`s matched by an unanchored `=~ /\btoken\b/` regex
* lets either flip without erasing the other — setting the offer flag alone
* used to stomp the status token entirely (every human comment now triggers
* a reply, so every thread on a coedited doc lost its Resolve/Reopen menu
* the moment the machine answered).
*/
private applyContextValue(state: DocState, threadId: string, vsThread: vscode.CommentThread): void {
const orphaned = !!state.orphaned.get(threadId);
const thread = state.artifact.threads.find((t) => t.id === threadId);
const statusToken = orphaned ? "orphaned" : (thread?.status ?? "open");
const offerToken = state.offerToken.get(threadId);
vsThread.contextValue = offerToken ? `${statusToken} ${offerToken}` : statusToken;
} }
private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void { private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
@@ -269,6 +533,18 @@ export class ThreadController implements vscode.Disposable {
return undefined; return undefined;
} }
/** PUC-7 (INV-10): exit hides only — dispose the rendered vsThreads for a doc
* without touching its sidecar-backed artifact (re-enter restores via renderAll). */
private disposeRendered(docPath: string): void {
const state = this.docs.get(docPath);
if (!state) return;
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
state.vsThreads.clear();
state.live.clear();
state.orphaned.clear();
state.offerToken.clear();
}
// ---- test-facing surface -------------------------------------------------------- // ---- test-facing surface --------------------------------------------------------
getRendered(docPath: string): RenderedThread[] { getRendered(docPath: string): RenderedThread[] {
@@ -283,6 +559,7 @@ export class ThreadController implements vscode.Disposable {
status: t.status, status: t.status,
orphaned: !!state.orphaned.get(id), orphaned: !!state.orphaned.get(id),
range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 }, range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 },
contextValue: typeof vsThread.contextValue === "string" ? vsThread.contextValue : "",
}); });
} }
return out; return out;
+94 -36
View File
@@ -193,6 +193,30 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
return ops; return ops;
} }
/**
* #64/Task 3 (INV-6): the number of line-grain hunks between `oldText` and
* `newText` — a run of one or more contiguous non-equal lines counts as ONE
* hunk (the status-bar "N changes" count). Same LCS walk `diffBlocks` uses
* (jsdiff `diffArrays`), at line grain instead of block grain. Pure,
* vscode-free, deterministic.
*/
export function countLineHunks(oldText: string, newText: string): number {
const before = oldText.split(/\r?\n/);
const after = newText.split(/\r?\n/);
const changes = diffArrays(before, after);
let hunks = 0;
let inHunk = false;
for (const ch of changes) {
if (ch.added || ch.removed) {
if (!inHunk) hunks++;
inHunk = true;
} else {
inHunk = false;
}
}
return hunks;
}
/** A contiguous changed region of `currentText` and its replacement (F11). */ /** A contiguous changed region of `currentText` and its replacement (F11). */
export interface EditHunk { export interface EditHunk {
/** char offset of the hunk's first changed char in currentText. */ /** char offset of the hunk's first changed char in currentText. */
@@ -445,8 +469,13 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
return defaultFence(tokens, idx, options, env, self); return defaultFence(tokens, idx, options, env, self);
}; };
/** Inner source of a ```mermaid fence (drops the fence lines), or null. */ /**
function mermaidFenceBody(raw: string): string | null { * Inner source of a ```mermaid fence (drops the fence lines), or null. Exported
* so `previewAnnotations.ts` can pair a `diffBlocks` mermaid `changed` op's
* before/current fence bodies for `diffMermaid` without reinventing this
* extraction (Task 7 §2.6 parity).
*/
export function mermaidFenceBody(raw: string): string | null {
const lines = raw.split(/\r?\n/); const lines = raw.split(/\r?\n/);
const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/); const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/);
if (!open || open[3]?.toLowerCase() !== "mermaid") return null; if (!open || open[3]?.toLowerCase() !== "mermaid") return null;
@@ -455,8 +484,12 @@ function mermaidFenceBody(raw: string): string | null {
return body.join("\n"); return body.join("\n");
} }
/** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */ /**
const MERMAID_LEGEND = * F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block.
* Exported for reuse by `previewAnnotations.ts`'s built-in-preview mermaid
* diff wiring (Task 7 §2.6 parity — same legend markup, both surfaces).
*/
export const MERMAID_LEGEND =
'<div class="cw-mermaid-legend">' + '<div class="cw-mermaid-legend">' +
'<span class="cw-leg cw-leg-add">added</span>' + '<span class="cw-leg cw-leg-add">added</span>' +
'<span class="cw-leg cw-leg-chg">changed</span>' + '<span class="cw-leg cw-leg-chg">changed</span>' +
@@ -548,11 +581,14 @@ export interface RenderOptions {
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */ /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
render?: (src: string) => string; render?: (src: string) => string;
/** /**
* #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes * #48: the baseline was just PINNED (`reason === "pinned"` — "Mark Changes as
* since that pin, the on-render is fully clean — no authorship coloring — so a * Reviewed" in snapshot mode). With zero changes since that pin, the
* pin reads as "this is my clean starting point". Distinct from a baseline * on-render is fully clean — no authorship coloring — so a pin reads as
* advanced by a machine-landing (accept), which keeps its authorship coloring * "this is my clean starting point". Distinct from an ordinary accepted
* (F10 INV-33). Only consulted by `renderReview`. * proposal, which — since the native-surfaces migration retired the
* machine-landing baseline advance (INV-18/INV-7) — stays a visible change-
* since-baseline and keeps its authorship coloring (F10 INV-33). Only
* consulted by `renderReview`.
*/ */
pinned?: boolean; pinned?: boolean;
} }
@@ -631,15 +667,33 @@ export interface AuthorSpan {
author: AuthorKind; author: AuthorKind;
} }
/**
* Task 7 (preview annotations, native-surfaces migration): the sentinel
* vocabulary generalizes beyond per-author spans to a plain "del" tag — a
* re-surfaced baseline deletion has no author variant (CSS carries one fixed
* `.cw-del`, the F10 vocabulary). `injectSentinels`/`sentinelsToSpans` are
* exported so `previewAnnotations.ts` can drive the SAME token-aware
* discipline (#33/#47) for its 3-way ins-claude/ins-human/del marks;
* `colorByAuthor` (F9/F10) keeps its narrower per-author call, unaffected.
*/
export type SentinelTag = AuthorKind | "del";
export interface TaggedSpan {
start: number;
end: number;
tag: SentinelTag;
}
// Private-Use-Area sentinels (never appear in real content; markdown-it passes // Private-Use-Area sentinels (never appear in real content; markdown-it passes
// them through as plain text). Paired open/close per author. // them through as plain text). Paired open/close per tag.
const SENT = { const SENT: Record<SentinelTag, { open: string; close: string }> = {
claude: { open: "", close: "" }, claude: { open: "", close: "" },
human: { open: "", close: "" }, human: { open: "", close: "" },
} as const; del: { open: "", close: "" },
};
const SENT_TAGS = Object.keys(SENT) as SentinelTag[];
function isCloseSentinel(m: string): boolean { function isCloseSentinel(m: string): boolean {
return m === SENT.claude.close || m === SENT.human.close; return SENT_TAGS.some((t) => SENT[t].close === m);
} }
// Markdown emphasis / code delimiters whose RUNS must never be split by an // Markdown emphasis / code delimiters whose RUNS must never be split by an
@@ -685,8 +739,8 @@ function blockContentStart(raw: string): number {
return 0; return 0;
} }
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */ /** Inject paired sentinels into a block's raw text for the (tagged) spans clipped to it. */
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string { export function injectSentinels(raw: string, blockStart: number, spans: TaggedSpan[]): string {
const inserts: { at: number; marker: string }[] = []; const inserts: { at: number; marker: string }[] = [];
// Open sentinels must not precede block-level markdown markers (heading/list/ // Open sentinels must not precede block-level markdown markers (heading/list/
// blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it // blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it
@@ -696,8 +750,8 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart))); const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart)));
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart)); const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
inserts.push({ at: lo, marker: SENT[s.author].open }); inserts.push({ at: lo, marker: SENT[s.tag].open });
inserts.push({ at: hi, marker: SENT[s.author].close }); inserts.push({ at: hi, marker: SENT[s.tag].close });
} }
// Apply high offset → low so earlier offsets stay valid. At an equal offset // Apply high offset → low so earlier offsets stay valid. At an equal offset
// (one span's close == the next's open), apply opens BEFORE closes so the // (one span's close == the next's open), apply opens BEFORE closes so the
@@ -708,34 +762,34 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
return out; return out;
} }
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = { const SENTINEL_OF: Record<string, { tag: SentinelTag; open: boolean } | undefined> = {};
[SENT.claude.open]: { author: "claude", open: true }, for (const tag of SENT_TAGS) {
[SENT.claude.close]: { author: "claude", open: false }, SENTINEL_OF[SENT[tag].open] = { tag, open: true };
[SENT.human.open]: { author: "human", open: true }, SENTINEL_OF[SENT[tag].close] = { tag, open: false };
[SENT.human.close]: { author: "human", open: false }, }
}; const ALL_SENTINELS = new RegExp(`[${SENT_TAGS.map((t) => SENT[t].open + SENT[t].close).join("")}]`, "g");
const ALL_SENTINELS = new RegExp(
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
"g",
);
/** /**
* #33: token-aware replacement of the rendered author sentinels with `cw-ins-*` * #33: token-aware replacement of the rendered sentinels with `cw-*` spans. A
* spans. A naive global string-replace (the old approach) could emit a span that * naive global string-replace (the old approach) could emit a span that
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a * CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and * boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
* emits the author span only around TEXT runs, CLOSING it before any `<tag>` and * emits the span only around TEXT runs, CLOSING it before any `<tag>` and
* REOPENING it after, so a span is always well-nested within the inline elements * REOPENING it after, so a span is always well-nested within the inline elements
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray * (one `<span>` segment per text run). Tags are copied verbatim (with any stray
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic. * sentinel stripped, so no Private-Use-Area char ever leaks). `kind` picks the
* author-class prefix (`cw-by-*`/`cw-ins-*`); the tag-less "del" mark (Task 7,
* no author variant) always renders the fixed `cw-del` regardless of `kind`.
* Pure, deterministic.
*/ */
function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string { export function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const out: string[] = []; const out: string[] = [];
let current: AuthorKind | null = null; // which author region we're inside let current: SentinelTag | null = null; // which region we're inside
let spanOpen = false; // whether a <span> is currently open in `out` let spanOpen = false; // whether a <span> is currently open in `out`
const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`);
const openSpan = () => { const openSpan = () => {
if (current && !spanOpen) { if (current && !spanOpen) {
out.push(`<span class="cw-${kind}-${current}">`); out.push(`<span class="${classFor(current)}">`);
spanOpen = true; spanOpen = true;
} }
}; };
@@ -749,7 +803,7 @@ function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const ch = html[i]; const ch = html[i];
const sentinel = SENTINEL_OF[ch]; const sentinel = SENTINEL_OF[ch];
if (sentinel) { if (sentinel) {
if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char if (sentinel.open) current = sentinel.tag; // span opens lazily before the next text char
else { else {
closeSpan(); closeSpan();
current = null; current = null;
@@ -786,7 +840,11 @@ export function colorByAuthor(
kind: "by" | "ins" = "by", kind: "by" | "ins" = "by",
): string { ): string {
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length); const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
const injected = injectSentinels(raw, blockStart, overlapping); const injected = injectSentinels(
raw,
blockStart,
overlapping.map((s) => ({ start: s.start, end: s.end, tag: s.author })),
);
return sentinelsToSpans(render(injected), kind); return sentinelsToSpans(render(injected), kind);
} }
-537
View File
@@ -1,537 +0,0 @@
/**
* TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one
* sealed webview panel per markdown document, beside the source editor. On open /
* debounced edit / F6 baseline-epoch change it reads the baseline (from the
* reused DiffViewController) + the live buffer, runs the pure render engine, and
* posts the HTML. Pure read-only: never mutates the document, sidecar, or
* baseline (INV-20). The webview is sealed: local assets only, strict CSP,
* per-load nonce, no network (INV-21).
*/
import * as path from "node:path";
import { randomBytes } from "node:crypto";
import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
import { promptEditInstruction } from "./editInstructionInput";
/**
* F11: a host edit turn (selection/document text + instruction → rewrite).
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
* the arg is optional so existing test stubs that ignore it stay valid.
*/
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
const VIEW_TYPE = "cowriting.trackChangesPreview";
const DEBOUNCE_MS = 150;
/**
* Inbound webview→host messages (intent only — the sealed webview never mutates,
* INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11
* adds the toolbar intents (pin baseline / ask Claude).
*/
type ToolbarMsg =
| { type: "setMode"; mode: "on" | "off" }
| { type: "accept"; proposalId: string }
| { type: "reject"; proposalId: string }
| { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number }
| { type: "acceptAll" }
| { type: "rejectAll" };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly panels = new Map<string, vscode.WebviewPanel>();
private readonly lastModel = new Map<string, BlockOp[]>();
private readonly debounces = new Map<string, NodeJS.Timeout>();
/** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */
private readonly mode = new Map<string, "on" | "off">();
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
/**
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/
private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts);
};
/**
* The instruction prompt (the multi-line split-below webview box) for BOTH the
* selection and document cases. A field so host E2E can stub it — the webview
* DOM can't run in CI (mirrors `editTurn`). Also used by the editor's
* `cowriting.editSelection` command (via this controller).
*/
askEditInstruction: (header: string) => Promise<string | undefined> = promptEditInstruction;
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
private nextTurnSeq(): number {
return ++this.turnSeq;
}
constructor(
private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi,
) {
this.disposables.push(
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
// the palette / keybinding pass nothing → fall back to the active editor.
// #41: the explorer/tab right-click also passes the clicked Uri, which may
// not be an open document yet — open it so we preview the clicked file, not
// whatever happens to be the active editor.
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", async (uri?: vscode.Uri) => {
if (uri) {
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
this.show(open ?? (await vscode.workspace.openTextDocument(uri)));
return;
}
this.show(vscode.window.activeTextEditor?.document);
}),
// F11: document-scoped Ask-Claude (also reused by #42's reach gateways).
// Edits a markdown doc; the rewrite is diffed into F4 proposals.
// #42 (INV-38): the editor/title/context (tab) entry passes the clicked
// tab's resource Uri — target THAT document, opening it if it isn't already
// an open buffer (mirrors showTrackChangesPreview's #41 resolution); the
// palette / keybinding / editor/context pass nothing → the active editor.
vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => {
const doc = uri
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
(await vscode.workspace.openTextDocument(uri))
: vscode.window.activeTextEditor?.document;
if (!doc || !this.isMarkdown(doc)) {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
return;
}
void this.askClaude(doc, { kind: "document" });
}),
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
this.proposals.onDidChangeProposals(({ uri }) => {
this.refreshByUri(uri);
this.updateStatus(uri);
}),
this.statusItem,
);
this.statusItem.command = "cowriting.showTrackChangesPreview";
}
private isMarkdown(document: vscode.TextDocument): boolean {
return document.languageId === "markdown";
}
/** Open or reveal the preview for a markdown document (PUC-1). */
show(document: vscode.TextDocument | undefined): void {
if (!document || !this.isMarkdown(document)) {
void vscode.window.showWarningMessage(
"Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).",
);
return;
}
const key = document.uri.toString();
const existing = this.panels.get(key);
if (existing) {
existing.reveal(vscode.ViewColumn.Beside);
this.refresh(document);
return;
}
const name = path.basename(document.uri.path) || "untitled";
const panel = vscode.window.createWebviewPanel(
VIEW_TYPE,
`Review: ${name}`,
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
{
enableScripts: true,
retainContextWhenHidden: false,
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")],
},
);
panel.webview.html = this.shellHtml(panel.webview);
panel.onDidDispose(
() => {
this.panels.delete(key);
this.lastModel.delete(key);
this.mode.delete(key);
// A panel is gone: re-show the off-panel indicator if proposals remain.
this.updateStatus(key);
},
null,
this.disposables,
);
// F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions,
// and (F11) the toolbar intents (pin baseline / ask Claude) back to the host.
panel.webview.onDidReceiveMessage(
(m: ToolbarMsg) => this.handleWebviewMessage(document, m),
null,
this.disposables,
);
this.panels.set(key, panel);
// A panel is now open for this doc — the off-panel indicator is redundant.
this.hideStatus();
this.refresh(document);
}
/**
* Route an inbound webview intent through the existing seams (INV-35): the
* annotations toggle + ✓/✗ proposal decisions (F10) and the toolbar gestures
* (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the
* active editor — the preview knows its bound doc (§6.7).
*/
private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void {
const key = document.uri.toString();
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
this.mode.set(key, m.mode);
this.refresh(document);
} else if (m?.type === "accept" && m.proposalId) {
void this.proposals
.acceptById(this.proposals.keyFor(document), m.proposalId)
.then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) {
void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document));
} else if (m?.type === "pinBaseline") {
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
this.diffView.pin(document);
} else if (m?.type === "askClaude") {
const target: EditTarget =
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
void this.askClaude(document, target);
} else if (m?.type === "acceptAll") {
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
void this.acceptAll(document);
} else if (m?.type === "rejectAll") {
void this.rejectAll(document);
}
}
/**
* #46 (INV-42): apply every pending proposal on the document through the F4
* accept seam (orphan-skip) and report the applied-vs-skipped tally. No
* confirmation dialog — VS Code undo restores (parity with single accept).
* Public so the `cowriting.acceptAllProposals` command can reach it for the
* active doc (not only the webview button).
*/
async acceptAll(document: vscode.TextDocument): Promise<void> {
const { applied, skipped } = await this.proposals.acceptAllProposals(document);
this.refresh(document);
if (applied === 0 && skipped === 0) return;
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
void vscode.window.showInformationMessage(
`Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`,
);
}
/** #64 (INV-53): revert every pending proposal on the document; report the count. */
async rejectAll(document: vscode.TextDocument): Promise<void> {
const { reverted } = await this.proposals.rejectAll(document);
this.refresh(document);
if (reverted > 0) {
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
);
}
}
/**
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
*/
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
// Both scopes use the same multi-line split-below webview box; only the header
// (and the downstream proposal logic) differs. For a selection the document
// above keeps the selection highlighted while the box is open.
const header =
target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection";
const instruction = await this.askEditInstruction(header);
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const ui = this.liveProgressUi.begin(instruction, progress, token);
try {
return await this.runEditAndPropose(document, target, instruction, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
if (token.isCancellationRequested) return [] as string[];
throw err;
}
},
);
if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
} else {
void vscode.window.showInformationMessage(
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
}
}
/**
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
* Never mutates the document (INV-10). Returns the created proposal ids.
*/
async runEditAndPropose(
document: vscode.TextDocument,
target: EditTarget,
instruction: string,
opts?: RunEditTurnOptions,
): Promise<string[]> {
const full = document.getText();
// One turnId per gesture — the document case's N hunk-proposals all share it,
// so a single rewrite groups as one agent turn (parity with editSelection).
const turnId = `turn-${this.nextTurnSeq()}`;
const provenance = (turn: EditTurnResult) =>
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
if (target.kind === "range") {
const selected = full.slice(target.start, target.end);
const turn = await this.editTurn(instruction, selected, opts);
if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full, opts);
const ids: string[] = [];
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) —
// not per word. Each is tagged `granularity:"block"` so accept reconciles
// attribution per word inside the block (INV-40).
for (const h of diffToBlockHunks(full, turn.replacement)) {
const fp = buildFingerprint(full, { start: h.start, end: h.end });
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
turnId,
instruction,
granularity: "block",
});
if (id) ids.push(id);
}
return ids;
}
private onEdit(document: vscode.TextDocument): void {
const key = document.uri.toString();
if (!this.panels.has(key)) return;
const pending = this.debounces.get(key);
if (pending) clearTimeout(pending);
this.debounces.set(
key,
setTimeout(() => {
this.debounces.delete(key);
this.refresh(document);
}, DEBOUNCE_MS),
);
}
private refreshByUri(uri: string): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.refresh(doc);
}
/** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */
refresh(document: vscode.TextDocument): void {
const key = document.uri.toString();
const panel = this.panels.get(key);
if (!panel) return;
const mode = this.mode.get(key) ?? "on";
const current = document.getText();
const baseline = this.diffView.getBaseline(key);
const baselineText = baseline?.text ?? current; // no baseline → no change-marks
const ops = diffBlocks(baselineText, current);
this.lastModel.set(key, ops);
// F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed).
const authorable = isAuthorable(document.uri.scheme);
if (mode === "off") {
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable });
return;
}
const spans = this.attribution.spansFor(document);
const proposals = this.proposals.listProposals(document);
// F12/#64 (INV-50): count added/removed against the LANDED text (current minus
// pending proposals), matching the body — a pending change shows once, as a
// proposal, and is not also tallied as a landed add/remove.
const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals));
const summary = {
added: landedOps.filter((o) => o.kind === "added").length,
removed: landedOps.filter((o) => o.kind === "removed").length,
proposals: proposals.length,
};
void panel.webview.postMessage({
type: "render",
mode,
html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }),
epoch: this.epochLabel(baseline),
summary,
authorable,
});
}
/** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */
private updateStatus(uri: string): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc) {
this.hideStatus();
return;
}
const n = this.proposals.listProposals(doc).length;
if (n === 0 || this.panels.has(uri)) {
this.hideStatus();
return;
}
this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`;
this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals";
this.statusItem.show();
}
/**
* Hide the off-panel indicator AND clear its text, so the `statusText()` seam
* is honest: a hidden indicator reports `undefined` (not its stale last value).
*/
private hideStatus(): void {
this.statusItem.text = "";
this.statusItem.hide();
}
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
if (!baseline) return "opened (no baseline yet)";
const time = new Date(baseline.capturedAt).toLocaleTimeString();
switch (baseline.reason) {
case "machine-landing":
return `Claude landed ${time}`;
case "pinned":
return `pinned ${time}`;
default:
return `opened ${time}`;
}
}
private shellHtml(webview: vscode.Webview): string {
const nonce = randomBytes(16).toString("base64");
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"),
);
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"),
);
// Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for
// mermaid's dynamically injected <style> tags; scripts are nonce-gated and
// strictly local (no remote/CDN script source).
const csp =
`default-src 'none'; ` +
`img-src ${webview.cspSource} data:; ` +
`font-src ${webview.cspSource}; ` +
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
`script-src 'nonce-${nonce}';`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<link href="${styleUri}" rel="stylesheet" />
<title>Track changes</title>
</head>
<body>
<div id="cw-header">
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
<button id="cw-pin" type="button" title="Pin the review baseline to now (clears the change-marks)">⌖ Pin baseline</button>
<button id="cw-ask" type="button" title="Ask Claude to edit (the selection if any, else the whole document)">✦ Ask Claude to Edit Document</button>
<button id="cw-acceptall" type="button" hidden title="Accept every pending Claude proposal on this document">✓✓ Accept all</button>
<span id="cw-epoch">Review</span>
<span id="cw-summary"></span>
<span id="cw-legend"></span>
</div>
<div id="cw-body"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
// ---- test seam (§6.4) ----
isOpen(uriString: string): boolean {
return this.panels.has(uriString);
}
/**
* F11 test seam: deliver an inbound webview message to the real routing, as if
* the sealed webview had posted it. Exercises message→seam wiring without a
* live webview DOM (which is manual-smoke only). No-op if no doc/panel.
*/
receiveMessage(uriString: string, m: ToolbarMsg): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m);
}
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
setEditTurnForTest(fn: EditTurn): void {
this.editTurn = fn;
}
/**
* F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude)
* are enabled — true only for an authorable doc. The annotations toggle is
* always active (reading is always allowed). False if no panel/doc.
*/
editControlsEnabled(uriString: string): boolean {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
return doc ? isAuthorable(doc.uri.scheme) : false;
}
getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString);
}
/** F10 test seam: the review HTML the panel would post for a doc (on-state). */
renderHtmlFor(uriString: string): string {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (!doc) return "";
const current = doc.getText();
const baseline = this.diffView.getBaseline(uriString);
return renderReview(
baseline?.text ?? current,
current,
this.attribution.spansFor(doc),
this.proposals.listProposals(doc),
{ pinned: baseline?.reason === "pinned" },
);
}
/** F10: current annotations mode for a panel (default on). */
getMode(uriString: string): "on" | "off" {
return this.mode.get(uriString) ?? "on";
}
/** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */
setMode(uriString: string, mode: "on" | "off"): void {
this.mode.set(uriString, mode);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc) this.refresh(doc);
}
/** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */
statusText(): string | undefined {
return this.statusItem.text || undefined;
}
dispose(): void {
for (const t of this.debounces.values()) clearTimeout(t);
for (const p of this.panels.values()) p.dispose();
for (const d of this.disposables) d.dispose();
}
}
+39 -3
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { BaselineStore, type Baseline } from "../src/baselineStore"; import { BaselineStore, normalizeReason, type Baseline } from "../src/baselineStore";
let dir: string; let dir: string;
@@ -16,9 +16,23 @@ afterEach(() => {
const KEY = "a1b2c3"; // a stand-in for the controller's sha256(uri) key const KEY = "a1b2c3"; // a stand-in for the controller's sha256(uri) key
function sample(uri = "file:///ws/notes/chapter-1.md"): Baseline { function sample(uri = "file:///ws/notes/chapter-1.md"): Baseline {
return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }; return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" };
} }
describe("normalizeReason (INV-7 legacy migration)", () => {
it("migrates legacy 'opened' → 'entered'", () => {
expect(normalizeReason("opened")).toBe("entered");
});
it("migrates legacy 'machine-landing' → 'entered'", () => {
expect(normalizeReason("machine-landing")).toBe("entered");
});
it("passes current reasons through unchanged", () => {
expect(normalizeReason("entered")).toBe("entered");
expect(normalizeReason("pinned")).toBe("pinned");
expect(normalizeReason("head")).toBe("head");
});
});
describe("BaselineStore", () => { describe("BaselineStore", () => {
it("returns null for a key with no baseline", () => { it("returns null for a key with no baseline", () => {
expect(new BaselineStore(dir).load(KEY)).toBeNull(); expect(new BaselineStore(dir).load(KEY)).toBeNull();
@@ -36,9 +50,31 @@ describe("BaselineStore", () => {
expect(store.load(KEY)).toEqual(b); expect(store.load(KEY)).toEqual(b);
}); });
it("migrates a legacy on-disk reason ('opened') to 'entered' on load", () => {
const store = new BaselineStore(dir);
fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true });
fs.writeFileSync(
store.baselinePath(KEY),
JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }),
"utf8",
);
expect(store.load(KEY)?.reason).toBe("entered");
});
it("migrates a legacy on-disk reason ('machine-landing') to 'entered' on load", () => {
const store = new BaselineStore(dir);
fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true });
fs.writeFileSync(
store.baselinePath(KEY),
JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "machine-landing" }),
"utf8",
);
expect(store.load(KEY)?.reason).toBe("entered");
});
it("overwrites in place: the newest epoch wins, no history kept", () => { it("overwrites in place: the newest epoch wins, no history kept", () => {
const store = new BaselineStore(dir); const store = new BaselineStore(dir);
store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }); store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" });
store.save(KEY, { uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" }); store.save(KEY, { uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
expect(store.load(KEY)).toEqual({ uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" }); expect(store.load(KEY)).toEqual({ uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
}); });
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vitest";
// The pure set logic lives in a vscode-free helper the class wraps, so unit-test the class
// with a Memento stub (vitest runs vscode-free; the module imports vscode only for types +
// EventEmitter — mock it).
vi.mock("vscode", () => ({
EventEmitter: class {
private handlers: Array<(e: unknown) => void> = [];
event = (h: (e: unknown) => void) => { this.handlers.push(h); return { dispose() {} }; };
fire(e: unknown) { for (const h of this.handlers) h(e); }
dispose() {}
},
Uri: { parse: (s: string) => ({ toString: () => s }) },
commands: { executeCommand: vi.fn() },
window: { activeTextEditor: undefined },
}));
import * as vscode from "vscode";
import { CoeditingRegistry } from "../src/coeditingRegistry";
function memento(): { store: Map<string, unknown> } & { get: any; update: any } {
const store = new Map<string, unknown>();
return {
store,
get: (k: string, dflt?: unknown) => (store.has(k) ? store.get(k) : dflt),
update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); },
} as any;
}
describe("CoeditingRegistry", () => {
it("enter/exit flips membership and fires onDidChange", () => {
const reg = new CoeditingRegistry(memento() as any);
const events: Array<{ uri: string; coediting: boolean }> = [];
reg.onDidChange((e) => events.push(e as any));
const uri = { toString: () => "file:///a.md" } as any;
expect(reg.isCoediting(uri)).toBe(false);
reg.enter(uri);
expect(reg.isCoediting(uri)).toBe(true);
reg.exit(uri);
expect(reg.isCoediting(uri)).toBe(false);
expect(events).toEqual([
{ uri: "file:///a.md", coediting: true },
{ uri: "file:///a.md", coediting: false },
]);
});
it("persists the set across construction (reload survival)", () => {
const m = memento();
const reg1 = new CoeditingRegistry(m as any);
reg1.enter({ toString: () => "file:///a.md" } as any);
const reg2 = new CoeditingRegistry(m as any);
expect(reg2.isCoediting({ toString: () => "file:///a.md" } as any)).toBe(true);
expect(reg2.list()).toEqual(["file:///a.md"]);
});
it("enter is idempotent (no duplicate events)", () => {
const reg = new CoeditingRegistry(memento() as any);
const events: unknown[] = [];
reg.onDidChange((e) => events.push(e));
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
reg.enter(uri);
expect(events).toHaveLength(1);
});
it("exit is idempotent (no duplicate events on double exit)", () => {
const reg = new CoeditingRegistry(memento() as any);
const events: unknown[] = [];
reg.onDidChange((e) => events.push(e));
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
reg.exit(uri);
reg.exit(uri);
expect(events).toHaveLength(2); // enter, then exit (second exit fires nothing)
});
it("syncContext: active editor with coediting doc sets isCoediting=true", () => {
const reg = new CoeditingRegistry(memento() as any);
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", true);
});
it("syncContext: undefined editor sets isCoediting=false", () => {
const reg = new CoeditingRegistry(memento() as any);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
reg.syncContext(undefined);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", false);
});
it("syncContext: non-coediting doc sets isCoediting=false", () => {
const reg = new CoeditingRegistry(memento() as any);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const uri = { toString: () => "file:///nonexistent.md" } as any;
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.isCoediting", false);
});
it("syncContext: baselineMode defaults to empty string when not coediting", () => {
const reg = new CoeditingRegistry(memento() as any);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const uri = { toString: () => "file:///a.md" } as any;
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "");
});
it("syncContext: baselineMode uses hook result when coediting and hook is set", () => {
const reg = new CoeditingRegistry(memento() as any);
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
reg.baselineModeOf = vi.fn((): "head" | "snapshot" | undefined => "snapshot");
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "snapshot");
});
it("syncContext: baselineMode defaults to empty string when hook returns undefined", () => {
const reg = new CoeditingRegistry(memento() as any);
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
reg.baselineModeOf = vi.fn((): "head" | "snapshot" | undefined => undefined);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledWith("setContext", "cowriting.baselineMode", "");
});
it("syncContext: calls executeCommand twice (isCoediting and baselineMode)", () => {
const reg = new CoeditingRegistry(memento() as any);
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
const mockExecuteCommand = vscode.commands.executeCommand as any;
mockExecuteCommand.mockClear();
const editor = { document: { uri } } as any;
reg.syncContext(editor);
expect(mockExecuteCommand).toHaveBeenCalledTimes(2);
});
});
+77
View File
@@ -0,0 +1,77 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import { settleUntil } from "../suite/helpers";
/**
* Task 9 (spec §7.1 rung 3 — the real @cline/sdk, manual gate). Drives the
* SAME comments-first ask -> reply -> offer -> proposal loop as
* test/e2e/suite/commentLoop.test.ts / fullLoop.test.ts, but with BOTH turns
* left UNSTUBBED: ThreadController's reply loop and EditFlow's edit turn hit
* the real local `claude` login (INV-8 -- the extension itself holds no
* credentials). Not part of `npm run test:e2e` / CI -- driven only by
* `scripts/smoke-native-loop.mjs` (`npm run smoke:native`), mirroring
* `scripts/smoke-live-turn.mjs`'s "manual gate, real SDK" role for F3.
*
* There is no VS Code API to drive the native comment-widget's OWN submit
* gesture from outside the UI (see commentLoop.test.ts's "Finding 1" note) --
* this calls the exact seam a real comment submission reaches
* (ThreadController.createThreadOnSelection / .makeThreadEdit), which is the
* closest a headless script gets to "post a real comment" on a live doc.
*/
export async function run(): Promise<void> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin");
if (!ext) throw new Error("extension not found -- is extensionDevelopmentPath wired correctly?");
const api = (await ext.activate()) as CowritingApi;
if (!api?.threadController || !api?.editFlow || !api?.proposalController || !api?.sidecarRouter) {
throw new Error("extension did not export the expected controllers");
}
const folder = vscode.workspace.workspaceFolders?.[0];
if (!folder) throw new Error("no workspace folder -- launch with the sandbox/ folder open");
const target = path.join(folder.uri.fsPath, `.smoke-native-loop-${process.pid}.md`);
const ORIGINAL = "The quick brown fox jumps over the lazy dog paragraph.";
fs.writeFileSync(target, `# Native-loop smoke\n\n${ORIGINAL}\n`, "utf8");
try {
const uri = vscode.Uri.file(target);
const doc = await vscode.workspace.openTextDocument(uri);
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
const docKey = api.proposalController.keyFor(doc);
const start = doc.getText().indexOf(ORIGINAL);
ed.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + ORIGINAL.length));
console.log("[smoke-native-loop] posting a real comment (createThreadOnSelection)...");
const threadId = await api.threadController.createThreadOnSelection(
"Rewrite this sentence to be more concise.",
);
if (!threadId) throw new Error("createThreadOnSelection refused (not coediting, or no selection)");
console.log(`[smoke-native-loop] thread created: ${threadId} -- waiting for the real @cline/sdk reply (this calls the live SDK; may take a while)...`);
await settleUntil(() => {
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === threadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 180000);
console.log("[smoke-native-loop] reply landed -- thread flipped to \"offer\". Making the edit (real SDK)...");
const ids = await api.threadController.makeThreadEdit(threadId, docKey);
if (ids.length === 0) throw new Error("makeThreadEdit produced no proposals (the turn returned an empty/no-op replacement)");
console.log(`[smoke-native-loop] proposal id(s) created: ${ids.join(", ")}`);
await settleUntil(() => ids.every((id) => api.proposalController.isApplied(docKey, id)), 10000);
for (const id of ids) {
const ok = await api.proposalController.finalizeInPlace(docKey, id);
console.log(`[smoke-native-loop] accepted proposal ${id}: ${ok}`);
}
console.log(`[smoke-native-loop] PASS -- ${ids.length} proposal(s) created and accepted: ${ids.join(", ")}`);
} finally {
try {
fs.rmSync(target, { force: true });
} catch {
// best-effort cleanup -- never let cleanup mask the real result
}
}
}
@@ -79,22 +79,25 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
// F6 (#19) baseline data layer is workspace-INDEPENDENT: it captures a baseline // F6 (#19) baseline data layer is workspace-INDEPENDENT: it captures a baseline
// for an untitled buffer even with no folder open (the two-pane VIEW was // for an untitled buffer even with no folder open (the two-pane VIEW was
// removed in #34; only the data layer remains). pinDiffBaseline stays real. // removed in #34; only the data layer remains). markReviewed stays real.
// Native-surfaces migration (Task 2, INV-7): the baseline is established on
// coediting ENTRY, not merely on open.
test("F6 baseline data layer works with no folder open (untitled buffer)", async () => { test("F6 baseline data layer works with no folder open (untitled buffer)", async () => {
const all = await vscode.commands.getCommands(true); const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline registered"); assert.ok(all.includes("cowriting.markReviewed"), "markReviewed registered");
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
const untitled = await vscode.workspace.openTextDocument({ content: "no-folder scratch\n", language: "markdown" }); const untitled = await vscode.workspace.openTextDocument({ content: "no-folder scratch\n", language: "markdown" });
await vscode.window.showTextDocument(untitled); await vscode.window.showTextDocument(untitled);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
const key = untitled.uri.toString(); const key = untitled.uri.toString();
const baseline = api.diffViewController.getBaseline(key); const baseline = api.diffViewController.getBaseline(key);
assert.ok(baseline, "baseline captured for the untitled buffer with no folder"); assert.ok(baseline, "baseline captured for the untitled buffer with no folder");
assert.strictEqual(baseline!.reason, "opened"); assert.strictEqual(baseline!.reason, "entered");
// pin resets the baseline to now — works folder-less. // markReviewed resets the baseline to now — works folder-less.
await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); await vscode.commands.executeCommand("cowriting.markReviewed");
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "pin works with no folder"); assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "markReviewed works with no folder");
}); });
}); });
+3
View File
@@ -18,6 +18,9 @@ async function openDoc(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: attribution tracking is gated on coediting (Task 4) — idempotent
// across this order-dependent suite's repeated openDoc() calls.
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc; return doc;
} }
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
+18 -14
View File
@@ -3,6 +3,7 @@ import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as vscode from "vscode"; import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension"; import type { CowritingApi } from "../../../src/extension";
import { renderHtmlFor } from "./helpers";
const WS = process.env.E2E_WORKSPACE!; const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 300)); const settle = () => new Promise((r) => setTimeout(r, 300));
@@ -10,38 +11,39 @@ const settle = () => new Promise((r) => setTimeout(r, 300));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal"); assert.ok(api?.attributionController && api?.proposalController, "exports attribution + proposal");
return api; return api;
} }
// F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test. // F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test.
// F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview // F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview
// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. After the // now author-colors Claude's PENDING proposal blocks as cw-ins-claude. This
// proposal is accepted the baseline advances (INV-18/F12) and the text becomes // suite checks that render BEFORE accepting (the class to check is cw-ins-claude,
// "unchanged" → renders plain. The class to check is cw-ins-claude (proposal block), // the proposal block, NOT cw-by-claude — the old F9 authorship render) and that
// NOT cw-by-claude (the old F9 authorship render). Owns its own markdown doc. // the attribution data layer survives the accept; it does not exercise the F6
// baseline (see diffView.test.ts / baselineRouter.test.ts for the post-accept
// baseline behavior, INV-7/D21). Owns its own markdown doc. Task 8: the render
// probe is now the pure `renderReview` directly (the webview that used to wrap
// it, `renderHtmlFor`, is gone).
suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => { suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => {
const DOC_REL = "docs/f10claude.md"; const DOC_REL = "docs/f10claude.md";
const TARGET = "The sentence Claude will compose over."; const TARGET = "The sentence Claude will compose over.";
const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam."; const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam.";
test("a pending Claude proposal carries cw-ins-claude; accepted proposal leaves attribution data intact; mode defaults to on", async () => { test("a pending Claude proposal carries cw-ins-claude; accepted proposal leaves attribution data intact", async () => {
const abs = path.join(WS, DOC_REL); const abs = path.join(WS, DOC_REL);
fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8"); fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8");
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: entering coediting is required for attribution tracking + F12
// optimistic-apply to fire — this suite never entered it (pre-migration).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
const api = await getApi(); const api = await getApi();
const key = uri.toString(); const key = uri.toString();
// open the preview — annotations default ON (F10/INV-33)
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on");
// Claude proposes via the seam (F12 optimistic-apply; proposal stays PENDING) // Claude proposes via the seam (F12 optimistic-apply; proposal stays PENDING)
const start = doc.getText().indexOf(TARGET); const start = doc.getText().indexOf(TARGET);
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", { const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
@@ -59,13 +61,15 @@ suite("F10 review preview — pending Claude proposal renders cw-ins-claude in t
// PENDING proposal: the preview renders the proposal block with cw-ins-claude. // PENDING proposal: the preview renders the proposal block with cw-ins-claude.
// After accepting, the baseline advances (INV-18) → the text becomes "unchanged" // After accepting, the baseline advances (INV-18) → the text becomes "unchanged"
// and renders plain — so we check BEFORE accepting. // and renders plain — so we check BEFORE accepting.
const pendingHtml = api.trackChangesPreviewController.renderHtmlFor(key); const pendingHtml = renderHtmlFor(api, doc, key);
assert.ok( assert.ok(
pendingHtml.includes("cw-ins-claude"), pendingHtml.includes("cw-ins-claude"),
"pending Claude proposal block carries cw-ins-claude in the on-state render", "pending Claude proposal block carries cw-ins-claude in the on-state render",
); );
// accept via the seam — baseline advances (machine-landing, INV-18/F12) // accept via the seam — the proposal lands; the baseline no longer auto-advances
// (INV-18/#48 retired, spec INV-7), but that isn't asserted here (no coediting
// entry / baseline established for this doc — only the attribution data layer).
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle(); await settle();
+87
View File
@@ -0,0 +1,87 @@
import * as assert from "node:assert";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as vscode from "vscode";
import { activateApi, settle, settleUntil } from "./helpers";
const WS = process.env.E2E_WORKSPACE!;
suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
test("snapshot mode: enter captures; markReviewed re-pins clean", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "one\n" });
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot");
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n");
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n"));
// Task 3 (INV-13): the status-bar/SCM change count tracks the live edit.
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 1);
await vscode.commands.executeCommand("cowriting.markReviewed");
const b = api.diffViewController.getBaseline(doc.uri.toString());
assert.strictEqual(b?.text, "one\ntwo\n");
assert.strictEqual(b?.reason, "pinned");
// A pin re-baselines to the clean current text, so the change count resets.
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0);
});
test("head mode: baseline = HEAD; commit advances it", async function () {
this.timeout(30000);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-git-"));
const git = (...a: string[]) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" });
git("init", "-q");
fs.writeFileSync(path.join(dir, "doc.md"), "committed\n");
git("add", "doc.md");
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1");
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(dir, "doc.md")));
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settleUntil(() => api.diffViewController.modeOf(doc.uri.toString()) === "head", 10000);
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "committed\n");
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "uncommitted\n"));
await doc.save();
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-aqm", "c2");
await settleUntil(
() => api.diffViewController.getBaseline(doc.uri.toString())?.text === "committed\nuncommitted\n",
15000,
);
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head");
});
// Finding 3 (final whole-branch review, session native-surfaces-exec):
// DiffViewController only establishes a baseline on the registry's ENTER
// transition (a doc it can find already open) and, at startup, for docs
// already open at that moment. A registry member entered while its document
// was NOT yet open (the real-world case: a persisted coediting set restored
// after a window reload, whose member is only lazily opened later) used to
// never get a baseline at all — reproduced here directly via
// `coeditingRegistry.enter(uri)` on a URI with no open document yet (the
// registry API doesn't require one), then a genuine later open.
test("a registry member entered before its doc was open gets its baseline established once the doc IS opened", async () => {
const api = await activateApi();
const rel = "docs/finding3-establish-on-open.md";
const abs = path.join(WS, rel);
const content = "# late open\n\nestablish-on-open should catch this.\n";
fs.writeFileSync(abs, content, "utf8");
const uri = vscode.Uri.file(abs);
// Membership without an open document — DiffViewController's enter-
// transition handler no-ops (`textDocuments.find` misses), reproducing
// the gap: a coediting member with no mode/baseline yet.
api.coeditingRegistry.enter(uri);
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "sanity: registry membership recorded");
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), undefined, "sanity: the gap is reproduced");
// A genuine later open — the fix's `renderIfOpen`/`onDidOpenTextDocument`
// path must establish the baseline it missed.
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settleUntil(() => api.diffViewController.modeOf(uri.toString()) !== undefined, 10000);
assert.strictEqual(api.diffViewController.modeOf(uri.toString()), "snapshot");
assert.strictEqual(api.diffViewController.getBaseline(uri.toString())?.text, content);
});
});
+223
View File
@@ -0,0 +1,223 @@
import * as assert from "node:assert";
import * as fs from "node:fs";
import * as path from "node:path";
import MarkdownIt from "markdown-it";
import * as vscode from "vscode";
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
import { activateApi, settle, settleUntil } from "./helpers";
const WS = process.env.E2E_WORKSPACE!;
async function api() {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
return (await ext.activate()) as import("../../../src/extension").CowritingApi;
}
suite("coediting registry (PUC-7)", () => {
test("enter → isCoediting true; exit → false; persists in list()", async () => {
const { coeditingRegistry } = await api();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# t\n\nbody\n" });
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), true);
assert.ok(coeditingRegistry.list().includes(doc.uri.toString()));
await vscode.commands.executeCommand("cowriting.stopCoediting");
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false);
});
// Task 3 (INV-10): a document that was never entered into coediting gets no
// native-diff surface at all. QuickDiff's `provideOriginalResource` isn't
// directly queryable from a host-E2E test, so this asserts the gate INPUTS
// structurally: `isCoediting` stays false and the SCM change count — which
// `ScmSurfaceController` only ever populates for a coediting doc — stays 0.
test("non-entered doc: no coediting, no change count (structural, INV-10)", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "untouched\n" });
await vscode.window.showTextDocument(doc);
await settle();
assert.strictEqual(api.coeditingRegistry.isCoediting(doc.uri), false);
assert.strictEqual(api.scmSurfaceController.changeCount(doc.uri.toString()), 0);
});
// Task 4 (INV-10, PUC-7): the no-hijack scenario — a non-entered doc gets no
// thread-creation surface at all; entering unlocks it; exiting hides the
// rendered thread (not the sidecar-backed data); re-entering restores it.
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
const api = await activateApi();
// Task 6 (D10): createThreadOnSelection now also fires the respond-in-
// thread turn once the doc is coediting — stub it off (not under test
// here; this suite asserts render/hide/restore, not the reply loop).
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("coediting suite: reply-loop turn stubbed off");
});
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
const ed = await vscode.window.showTextDocument(doc);
// not entered → no thread creation
ed.selection = new vscode.Selection(2, 0, 2, 4);
const before = await api.threadController.createThreadOnSelection("hi");
assert.strictEqual(before, undefined);
// entered → works
await vscode.commands.executeCommand("cowriting.coeditDocument");
const id = await api.threadController.createThreadOnSelection("hi");
assert.ok(id);
// exit → rendered thread set empty; re-enter → restored from sidecar
await vscode.commands.executeCommand("cowriting.stopCoediting");
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 0);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1);
});
// PUC-7 restore-on-enter gap: unlike ThreadController (Task 4, above),
// ProposalController.renderAll and AttributionController.loadAll were never
// wired to registry.onDidChange — so a document's FIRST enter into
// coediting with pre-existing sidecar proposals/attribution showed threads
// but not proposal decorations/CodeLens or committed author-coloring until
// a later edit happened to fire renderAll/loadAll. A same-session
// exit-then-re-enter doesn't reproduce this on its own (nothing clears the
// in-memory render caches on exit), so this test wipes those caches after
// exit — simulating "never rendered in this session yet" — then re-enters
// and asserts BOTH surfaces restore via the registry.onDidChange path
// alone, with no subsequent edit.
test("re-enter after exit restores proposal + attribution surfaces without an edit (PUC-7)", async () => {
const api = await activateApi();
// A workspace FILE (not untitled) — attribution only persists to the
// sidecar on save, and this test needs the sidecar (not just in-memory
// live state) to hold the seeded data before re-entering.
const rel = "docs/puc7-restore.md";
const abs = path.join(WS, rel);
fs.writeFileSync(abs, "# t\n\nfoo bar baz\n", "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
// Seed a pending proposal (F4) + a committed agent-attributed edit (F3) —
// the two surfaces the gap left un-restored.
const target = "bar";
const start = doc.getText().indexOf(target);
const proposalId = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + target.length,
newText: "BAR-PROPOSED",
model: "sonnet",
sessionId: "e2e-puc7",
turnId: "turn-puc7",
});
assert.ok(proposalId, "propose returns an id");
// A real text change (not a no-op replace) — "foo" precedes the "bar"
// target above, so it is unaffected by F12's optimistic-apply of the
// proposal into the buffer.
const fooStart = doc.getText().indexOf("foo");
const applied = await api.attributionController.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(fooStart), doc.positionAt(fooStart + "foo".length)),
"FOO-CLAUDE",
{ kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-puc7" } },
{ turnId: "turn-puc7" },
);
assert.strictEqual(applied, true, "seam edit applies");
await settle();
await doc.save(); // persists attribution records to the sidecar (F3 PUC-4)
const key = api.proposalController.keyFor(doc);
assert.ok(api.proposalController.getRendered(key).length >= 1, "sanity: proposal rendered pre-exit");
assert.ok(api.attributionController.getSpans(key).length >= 1, "sanity: attribution rendered pre-exit");
await vscode.commands.executeCommand("cowriting.stopCoediting");
// Wipe the controllers' in-memory render caches for this doc — nothing in
// production does this on exit (by design: exit hides, it does not wipe
// sidecar-backed state), so this reflection reproduces the "never
// rendered this session" precondition of a genuinely fresh first-enter.
(api.proposalController as unknown as { docs: Map<string, unknown> }).docs.delete(key);
(api.attributionController as unknown as { docs: Map<string, unknown> }).docs.delete(key);
assert.strictEqual(api.proposalController.getRendered(key).length, 0, "cache cleared pre-re-enter");
assert.strictEqual(api.attributionController.getSpans(key).length, 0, "cache cleared pre-re-enter");
// Re-enter — the registry.onDidChange subscribers (PUC-7 fix) must
// restore both surfaces from the sidecar with no subsequent edit.
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settleUntil(
() => api.proposalController.getRendered(key).length >= 1 && api.attributionController.getSpans(key).length >= 1,
);
assert.strictEqual(api.proposalController.getRendered(key).length, 1, "proposal restored on re-enter");
assert.ok(api.attributionController.getSpans(key).length >= 1, "attribution restored on re-enter");
});
// Finding 1 (final whole-branch review, session native-surfaces-exec): the
// preview-annotation host's INV-10 gate must never leak one document's
// baseline/spans into another document's preview. The built-in preview's
// rendered DOM isn't queryable from a host E2E test (§6.8, mirrored from
// Task 7's own previewAnnotations.test.ts), so this drives the ACTUAL
// production `previewAnnotationHost` through a real markdown-it instance
// and asserts on the rendered HTML.
test("INV-10: a non-coedited doc's preview never inherits another doc's annotations", async () => {
const api = await activateApi();
const docA = await vscode.workspace.openTextDocument({ language: "markdown", content: "alpha original\n" });
await vscode.window.showTextDocument(docA);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const start = docA.getText().indexOf("original");
const okA = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
uri: docA.uri.toString(),
start,
end: start + "original".length,
newText: "REPLACED",
model: "sonnet",
sessionId: "e2e-inv10-leak",
turnId: "turn-e2e-inv10-leak",
});
assert.strictEqual(okA, true, "seam edit applies to docA");
await settle();
// docB is opened (becomes the active editor, so it would win any
// `lastCoeditedUri` fallback if the gate were wrong) but NEVER entered
// into coediting.
const docB = await vscode.workspace.openTextDocument({ language: "markdown", content: "bravo untouched\n" });
await vscode.window.showTextDocument(docB);
await settle();
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
const html = md.render(docB.getText(), { currentDocument: docB.uri });
assert.ok(!html.includes("cw-ins-claude"), `docB must render with no annotations, got:\n${html}`);
assert.ok(!html.includes("original"), `docA's baseline text must never leak into docB's preview:\n${html}`);
assert.ok(html.includes("bravo untouched"), "docB renders its own content unannotated");
});
// Finding 1, failure mode (b): `lastCoeditedUri` is never re-validated
// against the registry, so a doc's OWN preview stayed annotated after
// `stopCoediting` — an INV-10 violation for the doc whose gate was just
// closed, not just for a different doc.
test("INV-10: stopCoediting immediately clears a doc's own preview annotations", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "gamma original\n" });
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const start = doc.getText().indexOf("original");
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + "original".length,
newText: "REPLACED",
model: "sonnet",
sessionId: "e2e-inv10-stop",
turnId: "turn-e2e-inv10-stop",
});
assert.strictEqual(ok, true, "seam edit applies");
await settle();
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
const before = md.render(doc.getText(), { currentDocument: doc.uri });
assert.ok(before.includes("cw-ins-claude"), `sanity: annotated while coediting, got:\n${before}`);
await vscode.commands.executeCommand("cowriting.stopCoediting");
await settle();
const after = md.render(doc.getText(), { currentDocument: doc.uri });
assert.ok(!after.includes("cw-ins-claude"), `expected a clean render after stopCoediting, got:\n${after}`);
});
});
+117
View File
@@ -0,0 +1,117 @@
import * as assert from "assert";
import * as vscode from "vscode";
import { activateApi, settleUntil } from "./helpers";
// Task 6 (D19/D10/D8, PUC-8): the comments-first ask + comment→reply→offer→
// proposal loop, with a stubbed reply turn (no real @cline/sdk call). Runs on
// an untitled buffer (F8 routes it to the global sidecar) so this suite has no
// workspace-folder dependency.
suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => {
test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n",
});
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
api.threadController.setTurnRunnerForTest(async () => ({
replacement: "I would tighten this sentence.",
model: "stub",
sessionId: "s1",
}));
api.editFlow.setEditTurnForTest(async () => ({
replacement: "The quick fox jumps the lazy dog.",
model: "stub",
sessionId: "s1",
}));
ed.selection = new vscode.Selection(2, 0, 2, 20);
const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
// reply-loop fires on the human message; wait for the machine message to persist
await settleUntil(() => {
const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 10000);
// Finding 2 (final whole-branch review): the machine reply flips the
// thread into an "offer" — this used to CLOBBER the "open" status token
// instead of folding in alongside it, so Resolve/Reopen disappeared from
// the menu the moment any thread got a reply. Assert both tokens survive
// together, directly via the rendered vsThread's contextValue (the exact
// string package.json's `comments/commentThread/title` `when`-clauses key
// on) — no DOM/UI query needed.
const docKey = api.proposalController.keyFor(doc);
const afterReply = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
assert.ok(afterReply, "thread is rendered after the reply lands");
assert.match(
afterReply!.contextValue,
/\bopen\b/,
`expected the "open" status token to survive the offer transition, got "${afterReply!.contextValue}"`,
);
assert.match(
afterReply!.contextValue,
/\boffer\b/,
`expected the "offer" token once the machine reply lands, got "${afterReply!.contextValue}"`,
);
const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc));
assert.ok(ids.length >= 1);
assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5
// Spending the offer flips it to "offerdone" — status token still intact,
// and the plain "offer" token must not still match (it's a DIFFERENT word,
// not just a substring: /\boffer\b/ must not match "offerdone").
const afterMakeEdit = api.threadController.getRendered(docKey).find((t) => t.id === threadId);
assert.match(afterMakeEdit!.contextValue, /\bopen\b/, "status token still intact after spending the offer");
assert.match(afterMakeEdit!.contextValue, /\bofferdone\b/, "offer token flips to offerdone once spent");
assert.doesNotMatch(afterMakeEdit!.contextValue, /\boffer\b(?!done)/, "the bare offer token is gone once spent");
});
test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" });
await vscode.window.showTextDocument(doc);
const id = await api.threadController.createThreadOnSelection("hello");
assert.strictEqual(id, undefined); // gate refuses thread creation entirely
});
// Finding 1 (code review, session native-surfaces-exec): `cowriting.edit` with NO
// selection used to route through `cowriting.editDocument` → the removed
// `askEditInstruction` webview prompt — a rejecting stub `void`'d before any
// try/catch, i.e. a completely silent dead end (no toast, no thread). It must now
// reach ThreadController.askClaude()'s top-anchored whole-document path (D19) —
// the same comments-first ask the selection route already used. There is no API to
// drive VS Code's native comment-widget submission from a host E2E test (the actual
// comment→reply→offer→proposal round trip is exercised above via the
// createThreadOnSelection seam), so this asserts the routing itself: the gesture
// reaches ThreadController.askClaude (never the old broken stub) and does not throw.
test("cowriting.edit with no selection on a coedited doc reaches the top-anchored ask path, not a silent dead end", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: "# T\n\nA document-level ask with no selection.\n",
});
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
// Defensive stubs, mirroring the suite's other test — no real turn should fire
// from this gesture alone (no comment text is ever submitted), but guard against
// a real @cline/sdk call if the routing regresses.
api.threadController.setTurnRunnerForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
api.editFlow.setEditTurnForTest(async () => ({ replacement: "stub", model: "stub", sessionId: "s1" }));
ed.selection = new vscode.Selection(0, 0, 0, 0); // no selection
let askClaudeCalls = 0;
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
api.threadController.askClaude = async () => {
askClaudeCalls++;
return origAskClaude();
};
try {
await vscode.commands.executeCommand("cowriting.edit");
} finally {
api.threadController.askClaude = origAskClaude;
}
assert.strictEqual(
askClaudeCalls,
1,
"cowriting.edit (no selection) reached ThreadController.askClaude — not the removed editDocument prompt stub",
);
});
});
+9
View File
@@ -17,6 +17,8 @@ async function open(docRel: string): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, docRel)); const uri = vscode.Uri.file(path.join(WS, docRel));
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: thread creation/rendering is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc; return doc;
} }
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
@@ -55,6 +57,13 @@ suite("F5 cross-rung round-trip (host E2E)", () => {
const DOC = "docs/crossrung.md"; const DOC = "docs/crossrung.md";
const doc = await open(DOC); const doc = await open(DOC);
const api = await getApi(); const api = await getApi();
// Task 6 (D10): createThreadOnSelection also fires the respond-in-thread
// turn now — stub it off so it can't race the forge stand-in's reply
// below (this test asserts an EXACT 2-message sequence: editor note +
// forge reply).
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("crossrung suite: reply-loop turn stubbed off");
});
const target = "portable record"; const target = "portable record";
const start = doc.getText().indexOf(target); const start = doc.getText().indexOf(target);
const editor = vscode.window.activeTextEditor!; const editor = vscode.window.activeTextEditor!;
+43 -23
View File
@@ -15,6 +15,16 @@ async function openDoc(): Promise<vscode.TextDocument> {
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
return doc; return doc;
} }
/** Open the doc AND enter coediting (INV-10) — baselines are established only
* on entry (Task 2). Idempotent across tests: re-entering an already-coedited
* doc is a no-op, so the once-captured baseline survives (order-dependent
* suite, same as before). */
async function openAndCoedit(): Promise<vscode.TextDocument> {
const doc = await openDoc();
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
return doc;
}
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
@@ -29,22 +39,29 @@ const settle = () => new Promise((r) => setTimeout(r, 300));
// later tests consume earlier state. Owns docs/diffview.md exclusively. The // later tests consume earlier state. Owns docs/diffview.md exclusively. The
// baseline works on ANY file (#19), so the last two tests use an out-of-workspace // baseline works on ANY file (#19), so the last two tests use an out-of-workspace
// file and an untitled buffer. // file and an untitled buffer.
//
// Native-surfaces migration (Task 2, spec §6.4/INV-7): a baseline is now
// established only once a document ENTERS coediting (not merely opened), and
// the fixture workspace is NOT a git repo, so every doc here resolves to
// SNAPSHOT mode. The shipped machine-landing baseline advance (#48/INV-18) is
// retired — an accepted proposal now stays a visible change-since-baseline
// until "Mark Changes as Reviewed" (D21).
suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, no LLM)", () => { suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, no LLM)", () => {
const TARGET = "A target sentence Claude will rewrite via the seam."; const TARGET = "A target sentence Claude will rewrite via the seam.";
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam."; const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => { test("entering coediting on a snapshot-mode doc captures an `entered` baseline equal to the buffer (INV-7)", async () => {
const doc = await openDoc(); const doc = await openAndCoedit();
await getApi();
const api = await getApi(); const api = await getApi();
assert.strictEqual(api.diffViewController.modeOf(docUri()), "snapshot", "no git repo → snapshot mode");
const baseline = api.diffViewController.getBaseline(docUri()); const baseline = api.diffViewController.getBaseline(docUri());
assert.ok(baseline, "baseline captured on first sight"); assert.ok(baseline, "baseline captured on coediting entry");
assert.strictEqual(baseline!.reason, "opened"); assert.strictEqual(baseline!.reason, "entered");
assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer"); assert.strictEqual(baseline!.text, doc.getText(), "baseline = entry-time buffer");
}); });
test("typing leaves the baseline unchanged while the buffer diverges", async () => { test("typing leaves the baseline unchanged while the buffer diverges", async () => {
const doc = await openDoc(); const doc = await openAndCoedit();
const api = await getApi(); const api = await getApi();
const before = api.diffViewController.getBaseline(docUri())!.text; const before = api.diffViewController.getBaseline(docUri())!.text;
const edit = new vscode.WorkspaceEdit(); const edit = new vscode.WorkspaceEdit();
@@ -55,9 +72,10 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
assert.notStrictEqual(doc.getText(), before, "buffer diverged"); assert.notStrictEqual(doc.getText(), before, "buffer diverged");
}); });
test("accepting a proposal advances the baseline past the landed text (PUC-2, INV-18)", async () => { test("accepting a proposal does NOT advance the baseline the landed text stays a change (INV-7/D21, #48 retired)", async () => {
const doc = await openDoc(); const doc = await openAndCoedit();
const api = await getApi(); const api = await getApi();
const before = api.diffViewController.getBaseline(docUri())!;
const start = doc.getText().indexOf(TARGET); const start = doc.getText().indexOf(TARGET);
assert.ok(start >= 0, "fixture contains the target"); assert.ok(start >= 0, "fixture contains the target");
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", { const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
@@ -73,14 +91,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle(); await settle();
const baseline = api.diffViewController.getBaseline(docUri())!; const baseline = api.diffViewController.getBaseline(docUri())!;
assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing"); assert.strictEqual(baseline.reason, before.reason, "baseline reason untouched by the landing");
assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)"); assert.strictEqual(baseline.text, before.text, "baseline text untouched by the landing");
assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too"); assert.ok(!baseline.text.includes(REPLACEMENT), "landed text is NOT folded into the baseline");
assert.strictEqual(baseline.text, doc.getText(), "baseline == buffer right after the landing"); assert.notStrictEqual(baseline.text, doc.getText(), "baseline != buffer the landing reads as a change");
}); });
test("an operator edit after the landing makes baseline ≠ buffer (the operator delta)", async () => { test("an operator edit after the landing keeps baseline ≠ buffer (the operator delta)", async () => {
const doc = await openDoc(); const doc = await openAndCoedit();
const api = await getApi(); const api = await getApi();
const edit = new vscode.WorkspaceEdit(); const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n"); edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n");
@@ -89,14 +107,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
assert.notStrictEqual( assert.notStrictEqual(
api.diffViewController.getBaseline(docUri())!.text, api.diffViewController.getBaseline(docUri())!.text,
doc.getText(), doc.getText(),
"operator changes show against the advanced baseline", "operator changes show against the untouched baseline",
); );
}); });
test("pin resets the baseline to now: baseline == buffer, reason pinned", async () => { test("markReviewed resets the baseline to now: baseline == buffer, reason pinned", async () => {
const doc = await openDoc(); const doc = await openAndCoedit();
const api = await getApi(); const api = await getApi();
await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); await vscode.commands.executeCommand("cowriting.markReviewed");
await settle(); await settle();
const baseline = api.diffViewController.getBaseline(docUri())!; const baseline = api.diffViewController.getBaseline(docUri())!;
assert.strictEqual(baseline.reason, "pinned"); assert.strictEqual(baseline.reason, "pinned");
@@ -104,14 +122,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
}); });
test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => { test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => {
await openDoc(); await openAndCoedit();
const api = await getApi(); const api = await getApi();
const p = api.diffViewController.baselineFilePath(docUri()); const p = api.diffViewController.baselineFilePath(docUri());
assert.ok(p, "storage-backed baseline path is available for a file: doc"); assert.ok(p, "storage-backed baseline path is available for a file: doc");
assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`); assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`);
const onDisk = JSON.parse(fs.readFileSync(p!, "utf8")); const onDisk = JSON.parse(fs.readFileSync(p!, "utf8"));
assert.strictEqual(onDisk.uri, docUri(), "baseline records the document URI"); assert.strictEqual(onDisk.uri, docUri(), "baseline records the document URI");
assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted"); assert.strictEqual(onDisk.reason, "pinned", "last epoch (markReviewed) persisted");
assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(docUri())!.text, "on-disk == in-memory"); assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(docUri())!.text, "on-disk == in-memory");
// INV-19: baseline lives under the extension's storage dir, not the repo. // INV-19: baseline lives under the extension's storage dir, not the repo.
assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree"); assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree");
@@ -127,11 +145,12 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
const outsideUri = vscode.Uri.file(outsidePath); const outsideUri = vscode.Uri.file(outsidePath);
const doc = await vscode.workspace.openTextDocument(outsideUri); const doc = await vscode.workspace.openTextDocument(outsideUri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
// Captured on open even though it is NOT under the workspace folder. // Captured on coediting entry even though it is NOT under the workspace folder.
const baseline = api.diffViewController.getBaseline(outsideUri.toString()); const baseline = api.diffViewController.getBaseline(outsideUri.toString());
assert.ok(baseline, "baseline captured for an out-of-folder file"); assert.ok(baseline, "baseline captured for an out-of-folder file");
assert.strictEqual(baseline!.reason, "opened"); assert.strictEqual(baseline!.reason, "entered");
const fp = api.diffViewController.baselineFilePath(outsideUri.toString()); const fp = api.diffViewController.baselineFilePath(outsideUri.toString());
assert.ok(fp && fs.existsSync(fp), "outside-file baseline persisted in global storage"); assert.ok(fp && fs.existsSync(fp), "outside-file baseline persisted in global storage");
assert.ok(!fp!.startsWith(WS + path.sep), "not under the workspace folder"); assert.ok(!fp!.startsWith(WS + path.sep), "not under the workspace folder");
@@ -143,6 +162,7 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress,
const api = await getApi(); const api = await getApi();
const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" }); const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" });
await vscode.window.showTextDocument(untitled); await vscode.window.showTextDocument(untitled);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
const key = untitled.uri.toString(); const key = untitled.uri.toString();
assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer"); assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer");
-302
View File
@@ -1,302 +0,0 @@
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import { renderPlain } from "../../../src/trackChangesModel";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal");
return api;
}
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
return { doc, key: uri.toString() };
}
async function propose(
doc: vscode.TextDocument,
key: string,
target: string,
newText: string,
turnId: string,
): Promise<string> {
const start = doc.getText().indexOf(target);
assert.ok(start >= 0, `fixture contains "${target}"`);
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: key,
start,
end: start + target.length,
newText,
model: "sonnet",
sessionId: "e2e-f10rev",
turnId,
});
assert.ok(id, "propose returns an id");
return id!;
}
// F10 host E2E (no LLM): the rendered preview is the single INTERACTIVE review
// surface. This suite owns docs/f10review.md (its main flow is order-dependent)
// plus its own disjoint fresh docs for the isolated cases (status-bar, toggle).
// The editor is zero-decoration; everything observable here is the data layer +
// the on-state renderReview HTML the panel posts.
suite("F10 interactive review (host E2E — preview is the single review surface, no LLM)", () => {
const DOC_REL = "docs/f10review.md";
const PROSE = "The original review paragraph that lives in this doc.";
const T1 = "A first claude target sentence here.";
const T2 = "A second claude target sentence here.";
test("open on a markdown doc → panel open, fresh baseline all-unchanged, mode is on (PUC-1)", async () => {
const { doc, key } = await freshDoc(DOC_REL, `# F10 review\n\n${PROSE}\n\n${T1}\n\n${T2}\n`);
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel yet");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default on (INV-33)");
const model = api.trackChangesPreviewController.getLastModel(key);
assert.ok(model && model.length > 0, "a model was computed");
assert.ok(model!.every((o) => o.kind === "unchanged"), "fresh baseline == buffer → every block unchanged");
void doc;
});
test("typing produces an added/changed block and a cw-ins-human span in the on-state render (PUC-2)", async () => {
const { key } = await reopen(DOC_REL);
const api = await getApi();
const doc = byKey(key)!;
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind);
assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing");
// Attribution recorded the human span (data layer), and the on-state render
// author-colors that prose as cw-ins-human (added blocks use cw-ins-{author} since Task 3/44ef0a2).
assert.ok(
api.attributionController.spansFor(doc).some((s) => s.author === "human"),
"a human span was recorded for the typed text",
);
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(html.includes("cw-ins-human"), "typed text is author-colored human in the on-state (cw-ins-human for added blocks)");
});
test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => {
const { doc, key } = await reopen(DOC_REL);
const api = await getApi();
const id = await propose(doc, key, T1, "A FIRST claude REPLACEMENT sentence.", "turn-f10-1");
await settle();
const views = api.proposalController.listProposals(doc);
assert.ok(views.some((v) => v.id === id), "listProposals returns a view with the id");
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(html.includes(`data-proposal-id="${id}"`), "the preview renders the proposal block by id");
assert.match(html, /class="cw-actions"/, "the proposal block carries ✓/✗ actions");
// #31: the proposal renders INLINE at its anchor (right after T1's block), not
// trailing the whole document — so it appears BEFORE the following T2 block.
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
const t2Idx = html.indexOf("second claude target");
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
// F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer;
// the replacement text is now in the document, proposal is still pending.
assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer");
});
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
const { doc, key } = await reopen(DOC_REL);
const api = await getApi();
const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id;
assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept applies via the seam");
await settle();
const replacement = "A FIRST claude REPLACEMENT sentence.";
assert.ok(doc.getText().includes(replacement), "replacement landed in the document");
assert.ok(!doc.getText().includes(T1), "original target gone");
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals");
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview");
// The baseline advanced on the landing (INV-18): the landed text is not marked.
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT"));
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
});
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => {
const { doc, key } = await reopen(DOC_REL);
const api = await getApi();
const before = doc.getText();
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
await settle();
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit.
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
await settle();
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state");
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
});
test("toggle annotations off → mode round-trips and the off-state render is plain (no cw- marks) (INV-33)", async () => {
// A fresh doc with a pending proposal so the on-state DOES carry a cw- mark,
// making the off-state's absence of marks meaningful (not tautological).
const { doc, key } = await freshDoc(
"docs/f10toggle.md",
"# F10 toggle\n\nA toggle target sentence to propose over.\n",
);
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const id = await propose(doc, key, "A toggle target sentence to propose over.", "A TOGGLED replacement.", "turn-tog");
await settle();
// on-state: the proposal block + its actions are present.
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "starts on");
const onHtml = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(onHtml.includes(`data-proposal-id="${id}"`), "on-state shows the proposal block");
// toggle off → mode round-trips; the off-state body is plain markdown.
api.trackChangesPreviewController.setMode(key, "off");
await settle();
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "off", "mode flipped to off");
assert.ok(api.trackChangesPreviewController.isOpen(key), "panel stays open across the toggle");
// renderHtmlFor is the on-state seam; the off-state body is renderPlain(current)
// (INV-33). Assert the actual off-state body the controller posts has no cw-
// author/proposal marks — meaningful because the on-state above DID carry one.
const offBody = renderPlain(doc.getText());
assert.ok(!/cw-proposal|cw-by-|cw-ins-|cw-del/.test(offBody), "off-state render carries no cw- marks");
// toggle back on → marks return.
api.trackChangesPreviewController.setMode(key, "on");
await settle();
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "mode flipped back on");
assert.ok(
api.trackChangesPreviewController.renderHtmlFor(key).includes(`data-proposal-id="${id}"`),
"on-state shows the proposal block again",
);
});
test("status-bar (PUC-6): a pending proposal with NO panel shows the indicator; opening the preview hides it", async () => {
// Isolated fresh doc: no preview opened, so the off-panel indicator is live.
const { doc, key } = await freshDoc("docs/f10status.md", "# F10 status\n\nA status target sentence here.\n");
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for this doc");
await propose(doc, key, "A status target sentence here.", "A STATUS replacement.", "turn-stat");
await settle();
const text = api.trackChangesPreviewController.statusText();
assert.ok(text && text.length > 0, "the off-panel indicator shows a non-empty status");
assert.match(text!, /1 Claude proposal/, "it mentions the pending count");
// open the preview for this doc → the off-panel indicator hides (undefined).
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
assert.strictEqual(api.trackChangesPreviewController.statusText(), undefined, "indicator hidden once the panel is open");
});
test("clean editor: data layers intact AND the retired in-editor surfaces are gone (INV-32)", async () => {
const { doc, key } = await freshDoc("docs/f10clean.md", "# F10 clean\n\nA clean target sentence here.\n");
const api = await getApi();
const id = await propose(doc, key, "A clean target sentence here.", "A CLAUDE clean replacement.", "turn-clean");
await settle();
assert.ok(await api.proposalController.acceptById("docs/f10clean.md", id), "accept lands the Claude edit");
await settle();
// data layer intact: a Claude (agent) attribution span exists.
assert.ok(
api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"),
"agent span recorded (attribution data layer intact)",
);
// the retired in-editor surfaces are gone from the palette (no editor decorations — INV-32).
const all = await vscode.commands.getCommands(true);
assert.ok(!all.includes("cowriting.toggleAttribution"), "cowriting.toggleAttribution is retired");
// #34: the F6 two-pane diff VIEW was removed (F10 preview is the single review
// surface) — its toggle command + ctrl+alt+d keybinding are gone entirely.
assert.ok(!all.includes("cowriting.toggleDiffView"), "cowriting.toggleDiffView command is gone (#34)");
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const dKb = (pkg.contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
(k) => k.command === "cowriting.toggleDiffView",
);
assert.ok(!dKb, "the toggleDiffView keybinding is gone from package.json (#34)");
// …but the F6 baseline data layer survives: pinDiffBaseline stays a real command.
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept");
void key;
});
test("author-colored track changes: cw-ins-human + cw-ins-claude appear; unchanged blocks are plain (Tasks 1-4)", async () => {
// Fresh doc with three blocks: one that stays unchanged, one for Claude to target, one for human typing.
const { doc, key } = await freshDoc(
"docs/f10authorcolors.md",
"# Author Colors\n\nUnchanged paragraph that stays.\n\nClaude target block.\n",
);
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Human types a new paragraph → an "added" block relative to the opened baseline.
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nHuman typed paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "human edit applied");
await settle();
// Propose a Claude change (stays PENDING — F12 optimistic-apply; NOT accepted).
// The proposal block renders with cw-ins-claude.
const target = "Claude target block.";
const targetStart = doc.getText().indexOf(target);
assert.ok(targetStart >= 0, "target text present after human edit");
await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: key,
start: targetStart,
end: targetStart + target.length,
newText: "Claude replacement block.",
model: "sonnet",
sessionId: "e2e-f10-colors",
turnId: "turn-colors-1",
});
await settle();
const html = api.trackChangesPreviewController.renderHtmlFor(key);
// The human-typed added block renders with cw-ins-human.
assert.ok(html.includes("cw-ins-human"), "human added block is colored cw-ins-human");
// The pending Claude proposal block renders with cw-ins-claude.
assert.ok(html.includes("cw-ins-claude"), "pending Claude proposal block carries cw-ins-claude");
// The unchanged block renders as cw-unchanged with no cw-ins-/cw-by- inside it
// (color = "changed since baseline"; unchanged text is never annotated).
const unchangedBlocks = [...html.matchAll(/<div class="cw-blk cw-unchanged"[^>]*>([\s\S]*?)<\/div>/g)];
assert.ok(unchangedBlocks.length > 0, "at least one unchanged block exists");
assert.ok(
unchangedBlocks.some((m) => m[1].includes("Unchanged paragraph that stays")),
"the unchanged paragraph is in a cw-unchanged block",
);
assert.ok(
unchangedBlocks.every((m) => !/cw-ins-|cw-by-/.test(m[1])),
"unchanged blocks carry no cw-ins- or cw-by- classes",
);
});
});
// ---- helpers reused across the order-dependent main-flow tests ----
function byKey(key: string): vscode.TextDocument | undefined {
return vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
}
async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const uri = vscode.Uri.file(path.join(WS, rel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
return { doc, key: uri.toString() };
}
+33 -105
View File
@@ -10,11 +10,13 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.diffViewController, "exports preview + diffView"); assert.ok(api?.editFlow && api?.diffViewController, "exports editFlow + diffView");
return api; return api;
} }
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ /** Create + open a fresh markdown doc under WS, returning the doc + its uri key.
* Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open. */
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel); const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.mkdirSync(path.dirname(abs), { recursive: true });
@@ -22,47 +24,28 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return { doc, key: uri.toString() }; return { doc, key: uri.toString() };
} }
// F11 host E2E (no LLM): the preview toolbar is the primary interaction surface. // F11/F12 host E2E (no LLM): EditFlow.runEditAndPropose is the turn→proposal(s)
// The webview posts intent messages; the host routes them through the existing // cut shared by every Ask-Claude entry point (comments-first ask, editDocument,
// F4/F6/F3 seams (INV-35). The webview DOM (real button clicks) is sealed and // editSelection). Originally exercised via the now-sunset review webview's
// manual-smoke only; here we simulate the inbound messages via `receiveMessage`. // toolbar (Task 8 deleted that webview and its message-routing seam,
suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () => { // `receiveMessage`/`pinBaseline` webview-message coverage duplicates
// SLICE-1: the Pin baseline button. // diffView.test.ts / baselineRouter.test.ts's `markReviewed` tests) — these
test("pinBaseline message pins the PREVIEWED doc → marks clear, baseline reason is pinned (PUC-5, INV-35)", async () => { // tests drive `runEditAndPropose` directly, the same programmatic seam the
const { doc, key } = await freshDoc("docs/f11pin.md", "# F11 pin\n\nA baseline paragraph that will diverge.\n"); // deleted toolbar itself called into.
const api = await getApi(); suite("F11/F12 — EditFlow document/selection edit turns (host E2E, no LLM)", () => {
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
// Diverge from the opened baseline so the preview carries a real change-mark.
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed paragraph that diverges.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
const marked = (api.trackChangesPreviewController.getLastModel(key) ?? []).some((o) => o.kind !== "unchanged");
assert.ok(marked, "the typed paragraph shows as a change before pinning");
// Simulate the webview's Pin baseline button posting its intent.
api.trackChangesPreviewController.receiveMessage(key, { type: "pinBaseline" });
await settle();
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
assert.ok(model.length > 0 && model.every((o) => o.kind === "unchanged"), "after pin, every block is unchanged");
assert.strictEqual(api.diffViewController.getBaseline(key)?.reason, "pinned", "baseline reason advanced to pinned");
});
// SLICE-1 reachability: the orphaned pin command gets a real palette `when`. // SLICE-1 reachability: the orphaned pin command gets a real palette `when`.
test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => { // Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14).
test("markReviewed is reachable from the command palette (when: editorLangId == markdown)", async () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.pinDiffBaseline", (m) => m.command === "cowriting.markReviewed",
); );
assert.ok(entry, "pinDiffBaseline has a commandPalette entry"); assert.ok(entry, "markReviewed has a commandPalette entry");
assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)"); assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
}); });
@@ -76,18 +59,16 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
"# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n", "# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n",
); );
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Stub the host edit turn (no LLM in CI): rewrite two distinct words. // Stub the host edit turn (no LLM in CI): rewrite two distinct words.
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n", replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f11-doc", sessionId: "e2e-f11-doc",
})); }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
await settle(); await settle();
assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)"); assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)");
@@ -99,8 +80,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
"The quick RED fox jumps over the lazy CAT.", "The quick RED fox jumps over the lazy CAT.",
"the block proposal carries the whole rewritten paragraph", "the block proposal carries the whole rewritten paragraph",
); );
// F12 (INV-48): EditorProposalController optimistically applies the proposed text, // F12 (INV-48): EditorProposalController optimistically applies the proposed
// so after settle the buffer has the replacement. The proposal is still pending. // text, so after settle the buffer has the replacement. The proposal is still
// pending (not finalized) until Accept.
assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer"); assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer");
void key; void key;
}); });
@@ -110,19 +92,17 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n"; const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n";
const { doc, key } = await freshDoc("docs/f11sel.md", body); const { doc, key } = await freshDoc("docs/f11sel.md", body);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const target = "The target paragraph Claude will rewrite."; const target = "The target paragraph Claude will rewrite.";
const start = doc.getText().indexOf(target); const start = doc.getText().indexOf(target);
const end = start + target.length; const end = start + target.length;
ctl.setEditTurnForTest(async (_instruction, text) => { editFlow.setEditTurnForTest(async (_instruction, text) => {
assert.strictEqual(text, target, "the turn receives exactly the selected source range"); assert.strictEqual(text, target, "the turn receives exactly the selected source range");
return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" }; return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" };
}); });
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph"); const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph");
await settle(); await settle();
assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal"); assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal");
const views = api.proposalController.listProposals(doc); const views = api.proposalController.listProposals(doc);
@@ -140,13 +120,11 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => { test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => {
const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n"); const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const target = "Leave me exactly as I am."; const target = "Leave me exactly as I am.";
const start = doc.getText().indexOf(target); const start = doc.getText().indexOf(target);
ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" })); editFlow.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change"); const ids = await editFlow.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change");
assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing"); assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing");
}); });
@@ -158,12 +136,10 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n"; const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n";
const { doc } = await freshDoc("docs/f11accept.md", original); const { doc } = await freshDoc("docs/f11accept.md", original);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
await settle(); await settle();
assert.ok(ids.length >= 1, "the rewrite produced at least one proposal"); assert.ok(ids.length >= 1, "the rewrite produced at least one proposal");
@@ -194,52 +170,4 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.ok(editEntry, "cowriting.edit has a commandPalette entry"); assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
}); });
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
test("the editor/title gateway opens the preview, and the menu entry is markdown-guarded (PUC-6)", async () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus["editor/title"] as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.showTrackChangesPreview",
);
assert.ok(entry, "showTrackChangesPreview is in editor/title");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "gateway guarded on markdown");
// the command it invokes opens the panel.
const { key } = await freshDoc("docs/f11gw.md", "# F11 gateway\n\nReachable end to end.\n");
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "gateway command opens the preview");
});
// SLICE-5: edit controls are inert on a non-authorable (read-only scheme) doc.
test("toolbar edit controls are disabled for a non-authorable document (PUC-1/7)", async () => {
const SCHEME = "cwf11ro";
const provider = new (class implements vscode.TextDocumentContentProvider {
onDidChange = undefined;
provideTextDocumentContent(): string {
return "# Read only\n\nThis markdown doc is not authorable.\n";
}
})();
const reg = vscode.workspace.registerTextDocumentContentProvider(SCHEME, provider);
try {
const uri = vscode.Uri.parse(`${SCHEME}:/readonly.md`);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.languages.setTextDocumentLanguage(doc, "markdown");
await vscode.window.showTextDocument(doc);
await settle();
const api = await getApi();
const key = uri.toString();
assert.strictEqual(doc.languageId, "markdown", "fixture is markdown");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "preview opens (reading is always allowed)");
assert.strictEqual(
api.trackChangesPreviewController.editControlsEnabled(key),
false,
"Pin + Ask-Claude controls are disabled on a non-authorable doc",
);
} finally {
reg.dispose();
}
});
}); });
+20 -21
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers"); assert.ok(api?.editFlow && api?.proposalController, "exports controllers");
return api; return api;
} }
@@ -21,6 +21,9 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: proposal rendering (F12 optimistic-apply, driven by
// onDidChangeProposals) is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return { doc, key: uri.toString() }; return { doc, key: uri.toString() };
} }
@@ -33,19 +36,18 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
test("acceptAll applies every pending proposal and reconstructs the document", async () => { test("acceptAll applies every pending proposal and reconstructs the document", async () => {
const original = "# All\n\nFirst para alpha.\n\nSecond para beta.\n\nThird para gamma.\n"; const original = "# All\n\nFirst para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
const rewrite = "# All\n\nFirst para ALPHA.\n\nSecond para BETA.\n\nThird para GAMMA.\n"; const rewrite = "# All\n\nFirst para ALPHA.\n\nSecond para BETA.\n\nThird para GAMMA.\n";
const { doc, key } = await freshDoc("docs/f12-all.md", original); const { doc } = await freshDoc("docs/f12-all.md", original);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
await settle(); await settle();
assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals"); assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals");
// Simulate the toolbar "Accept all" button posting its intent. // Route through the real "Keep all" command (Task 8, PUC-2) — the doc is the
ctl.receiveMessage(key, { type: "acceptAll" }); // active editor from freshDoc, matching the top-of-file CodeLens's own target.
await vscode.commands.executeCommand("cowriting.acceptAllProposals");
await settle(); await settle();
await settle(); await settle();
@@ -60,12 +62,10 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
const rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n"; const rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n";
const { doc } = await freshDoc("docs/f12-orphan.md", original); const { doc } = await freshDoc("docs/f12-orphan.md", original);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
await settle(); await settle();
assert.strictEqual(ids.length, 2, "two pending proposals"); assert.strictEqual(ids.length, 2, "two pending proposals");
@@ -92,20 +92,19 @@ suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
assert.strictEqual(api.proposalController.listProposals(doc).length, 1, "the orphaned proposal remains pending"); assert.strictEqual(api.proposalController.listProposals(doc).length, 1, "the orphaned proposal remains pending");
}); });
// A single pending proposal still applies through the batch path (the button is // A single pending proposal still applies through the batch path (the
// hidden < 2 pending in the webview, but the command/seam handle any count). // top-of-file "Keep all" CodeLens is hidden < 2 pending, Task 8 PUC-2, but the
// command/seam handle any count).
test("acceptAllProposals with one pending proposal applies it", async () => { test("acceptAllProposals with one pending proposal applies it", async () => {
const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n"); const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); editFlow.setEditTurnForTest(async () => ({
await settle();
ctl.setEditTurnForTest(async () => ({
replacement: "# One\n\nThe ONLY paragraph here.\n", replacement: "# One\n\nThe ONLY paragraph here.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-one", sessionId: "e2e-f12-one",
})); }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase only"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase only");
await settle(); await settle();
assert.strictEqual(ids.length, 1, "one pending proposal"); assert.strictEqual(ids.length, 1, "one pending proposal");
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc); const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
+30 -26
View File
@@ -12,10 +12,12 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers"); assert.ok(api?.diffViewController && api?.proposalController, "exports controllers");
return api; return api;
} }
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open. */
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel); const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.mkdirSync(path.dirname(abs), { recursive: true });
@@ -23,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return { doc, key: uri.toString() }; return { doc, key: uri.toString() };
} }
@@ -31,8 +34,6 @@ suite("F12 inline diff — seam landBaseline (#64, INV-48)", () => {
test("applyAgentEdit with landBaseline:false applies text but does NOT advance the baseline", async () => { test("applyAgentEdit with landBaseline:false applies text but does NOT advance the baseline", async () => {
const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n"); const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n");
const api = await getApi(); const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText(); const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText();
const start = doc.getText().indexOf("alpha"); const start = doc.getText().indexOf("alpha");
const ok = await api.attributionController.applyAgentEdit( const ok = await api.attributionController.applyAgentEdit(
@@ -103,10 +104,10 @@ suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
test("rejectAll reverts every pending proposal", async () => { test("rejectAll reverts every pending proposal", async () => {
const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n"); const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
const p = api.proposalController; const p = api.proposalController;
ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle();
for (const id of ids) await p.optimisticApply(doc, id); for (const id of ids) await p.optimisticApply(doc, id);
await settle(); await settle();
@@ -123,9 +124,9 @@ suite("F12 inline diff — INV-50 listProposals.replaced", () => {
test("listProposals reports the original as `replaced` after optimistic apply", async () => { test("listProposals reports the original as `replaced` after optimistic apply", async () => {
const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n"); const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "x"); await editFlow.runEditAndPropose(doc, { kind: "document" }, "x");
await settle(); await settle(); await settle(); await settle();
assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here."); assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here.");
}); });
@@ -135,9 +136,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => { test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => {
const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n"); const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox"); await editFlow.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
await settle(); await settle(); await settle(); await settle();
assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer"); assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer");
const v = api.proposalController.listProposals(doc)[0]; const v = api.proposalController.listProposals(doc)[0];
@@ -147,9 +148,9 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
test("editing the inserted text then finalizing keeps the human edit", async () => { test("editing the inserted text then finalizing keeps the human edit", async () => {
const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n"); const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" })); editFlow.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle(); await settle(); await settle();
// human tweaks the inserted text // human tweaks the inserted text
const at = doc.getText().indexOf("ALPHA"); const at = doc.getText().indexOf("ALPHA");
@@ -168,22 +169,25 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
}); });
suite("F12 inline diff — control parity (#64, INV-53)", () => { suite("F12 inline diff — control parity (#64, INV-53)", () => {
test("reject from the webview reverts in place; rejectAll clears every proposal", async () => { test("reject-one via revertInPlace reverts in place; rejectAll clears every proposal", async () => {
const { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n"); const { doc } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const p = api.proposalController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); const editFlow = api.editFlow;
await settle(); editFlow.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" })); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "up");
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle(); await settle(); await settle();
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB")); assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
// reject ONE via the webview intent → that block reverts, the other stays applied // reject ONE via the same seam the per-proposal "✗ Reject" CodeLens uses
ctl.receiveMessage(key, { type: "reject", proposalId: ids[0] }); // (Task 8, PUC-2) → that block reverts, the other stays applied.
const docKey = p.keyFor(doc);
assert.ok(await p.revertInPlace(docKey, ids[0]), "revert one proposal");
await settle(); await settle(); await settle(); await settle();
assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied"); assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied");
// rejectAll via the command → all gone, document restored // rejectAll via the same seam the top-of-file "✗ Reject all" CodeLens uses →
ctl.receiveMessage(key, { type: "rejectAll" }); // all gone, document restored.
const { reverted } = await p.rejectAll(doc);
assert.strictEqual(reverted, 1, "the one remaining proposal reverted");
await settle(); await settle(); await settle(); await settle();
assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored"); assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0); assert.strictEqual(api.proposalController.listProposals(doc).length, 0);
+57 -30
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller"); assert.ok(api?.threadController, "exports thread controller");
return api; return api;
} }
@@ -20,6 +20,12 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
fs.writeFileSync(abs, body, "utf8"); fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
// INV-10: cowriting.editDocument now warns instead of editing a non-entered
// doc (Task 4) — enter it here (briefly making it active) so the tests below
// exercise the routing/targeting behavior, not the gate. The caller
// re-establishes whichever doc it wants active afterward.
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
return { doc, key: uri.toString() }; return { doc, key: uri.toString() };
} }
@@ -66,9 +72,17 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document, // PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
// not whatever editor happens to be active (mirrors #41's clicked-doc resolution). // not whatever editor happens to be active (mirrors #41's clicked-doc resolution).
// Finding 1 fix (native-surfaces code review): editDocument no longer prompts via
// the removed askEditInstruction webview stub (that path threw a silent, `void`'d,
// unhandled rejection — no toast, no thread, no proposal). It now resolves/focuses
// its target document and hands off to ThreadController.askClaude() (D19), same as
// editSelection. The turn→proposal cut itself (EditFlow.runEditAndPropose against a
// {kind:"document"} target) is exercised directly, without the command, in
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts — this test's job is
// the URI-targeting/focus behavior, plus confirming the routing actually reaches
// askClaude (proving Finding 1's dead end is gone).
test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => { test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => {
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController;
// Doc A is the active editor; Doc B is the "clicked tab" we pass by URI. // Doc A is the active editor; Doc B is the "clicked tab" we pass by URI.
const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n"); const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n");
@@ -76,55 +90,68 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.window.showTextDocument(a.doc); await vscode.window.showTextDocument(a.doc);
await settle(); await settle();
// Stub the document instruction prompt (the webview can't run in CI) + the LLM turn. let askClaudeCalls = 0;
const origPrompt = ctl.askEditInstruction; // Capture the active-editor doc INSIDE the spy, before delegating to the real
ctl.askEditInstruction = async () => "rewrite it"; // askClaude — askClaude's own `workbench.action.addComment` side effect moves
ctl.setEditTurnForTest(async () => ({ // focus to the ephemeral comment-input widget (a `comment://` URI), which would
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n", // make a post-hoc `activeTextEditor` check meaningless. What we're proving here
model: "sonnet", // is editDocument's OWN resolve-and-focus step ran against the right document
sessionId: "e2e-f12-tab", // before handing off.
})); let focusedDocAtHandoff: string | undefined;
const origAskClaude = api.threadController.askClaude.bind(api.threadController);
api.threadController.askClaude = async () => {
askClaudeCalls++;
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
return origAskClaude();
};
try { try {
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri); await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle(); await settle();
} finally { } finally {
ctl.askEditInstruction = origPrompt; api.threadController.askClaude = origAskClaude;
} }
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
assert.ok(api.proposalController.listProposals(b.doc).length >= 1, "tab doc B received the document-edit proposal(s)");
assert.strictEqual( assert.strictEqual(
api.proposalController.listProposals(a.doc).length, askClaudeCalls,
0, 1,
"active doc A was NOT edited — editDocument honored the tab URI", "editDocument(uri) reached ThreadController.askClaude — not the removed prompt stub",
);
assert.strictEqual(
focusedDocAtHandoff,
b.doc.uri.toString(),
"editDocument(uri) focused the TAB doc B, not the previously-active doc A, before handing off to askClaude",
); );
// F12 (INV-48): EditorProposalController optimistically applies proposals into the
// buffer, so the proposed text is now in the tab doc B. Confirm one of the proposal
// texts is present (the tab doc was edited, not A).
assert.ok(b.doc.getText().includes("REWRITTEN"), "F12 optimistic-apply: proposed text in tab doc B");
}); });
// No URI arg (palette / keybinding) → fall back to the active editor. // No URI arg (palette / keybinding) → fall back to the active editor.
test("editDocument() with no arg targets the active editor", async () => { test("editDocument() with no arg targets the active editor", async () => {
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController;
const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n"); const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n");
await vscode.window.showTextDocument(a.doc); await vscode.window.showTextDocument(a.doc);
await settle(); await settle();
const origPrompt = ctl.askEditInstruction; let askClaudeCalls = 0;
ctl.askEditInstruction = async () => "rewrite it"; // See the sibling test above for why this is captured inside the spy rather
ctl.setEditTurnForTest(async () => ({ // than after askClaude() returns.
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n", let focusedDocAtHandoff: string | undefined;
model: "sonnet", const origAskClaude = api.threadController.askClaude.bind(api.threadController);
sessionId: "e2e-f12-noarg", api.threadController.askClaude = async () => {
})); askClaudeCalls++;
focusedDocAtHandoff = vscode.window.activeTextEditor?.document.uri.toString();
return origAskClaude();
};
try { try {
await vscode.commands.executeCommand("cowriting.editDocument"); await vscode.commands.executeCommand("cowriting.editDocument");
await settle(); await settle();
} finally { } finally {
ctl.askEditInstruction = origPrompt; api.threadController.askClaude = origAskClaude;
} }
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
assert.strictEqual(askClaudeCalls, 1, "editDocument() with no arg reached ThreadController.askClaude");
assert.strictEqual(
focusedDocAtHandoff,
a.doc.uri.toString(),
"editDocument() with no arg kept the active editor's doc active before handing off to askClaude",
);
}); });
}); });
+68 -13
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers"); assert.ok(api?.proposalController && api?.editorProposalController, "exports controllers");
return api; return api;
} }
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
fs.writeFileSync(abs, body, "utf8"); fs.writeFileSync(abs, body, "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: attribution tracking (accept's per-word attribution, INV-40) is
// gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return doc; return doc;
} }
@@ -36,13 +39,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
"# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n", "# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n",
); );
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n", replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-multi", sessionId: "e2e-f12-multi",
})); }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns");
await settle(); await settle();
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none"); assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none");
@@ -59,13 +62,13 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
"# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n", "# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n",
); );
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n", replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-fence", sessionId: "e2e-f12-fence",
})); }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "bump a to 10"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "bump a to 10");
await settle(); await settle();
assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal"); assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal");
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!; const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!;
@@ -77,15 +80,15 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => { test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => {
const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n"); const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
const key = api.proposalController.keyFor(doc); const key = api.proposalController.keyFor(doc);
ctl.setEditTurnForTest(async () => ({ editFlow.setEditTurnForTest(async () => ({
replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n", replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n",
model: "sonnet", model: "sonnet",
sessionId: "e2e-f12-attr", sessionId: "e2e-f12-attr",
})); }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "change two words"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "change two words");
await settle(); await settle();
assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal"); assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal");
@@ -115,11 +118,11 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n"; const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n";
const doc = await freshDoc("docs/f12-insert.md", original); const doc = await freshDoc("docs/f12-insert.md", original);
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
const key = api.proposalController.keyFor(doc); const key = api.proposalController.keyFor(doc);
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" })); editFlow.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
await settle(); await settle();
assert.ok(ids.length >= 1, "the insertion produced at least one proposal"); assert.ok(ids.length >= 1, "the insertion produced at least one proposal");
@@ -132,3 +135,55 @@ suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)"
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending"); assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
}); });
}); });
// Task 8 (PUC-2 lens copy): the CodeLens titles read "✓ Keep"/"✗ Reject" per
// proposal, and — once ≥2 proposals are pending — a top-of-file "Keep all
// (N)"/"Reject all" pair routes straight at the batch commands.
suite("F12 CodeLens copy (Task 8, PUC-2)", () => {
test("one pending proposal → a per-proposal ✓ Keep / ✗ Reject pair, no top-of-file pair", async () => {
const doc = await freshDoc("docs/f12-lens-one.md", "# Lens\n\nAlpha block here.\n");
const api = await getApi();
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({
replacement: "# Lens\n\nALPHA block here.\n",
model: "sonnet",
sessionId: "e2e-lens-one",
}));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase");
await settle();
assert.strictEqual(ids.length, 1, "one changed block → one proposal");
const lenses = api.editorProposalController.provideCodeLenses(doc);
const titles = lenses.map((l) => l.command?.title);
assert.deepStrictEqual(titles, ["✓ Keep", "✗ Reject"], "exactly one per-proposal pair, no top-of-file pair below threshold");
});
test("two pending proposals → a top-of-file 'Keep all (2)'/'Reject all' pair routed at the batch commands", async () => {
const doc = await freshDoc(
"docs/f12-lens-two.md",
"# Lens\n\nFirst block here.\n\nSecond block here.\n",
);
const api = await getApi();
const editFlow = api.editFlow;
editFlow.setEditTurnForTest(async () => ({
replacement: "# Lens\n\nFIRST block here.\n\nSECOND block here.\n",
model: "sonnet",
sessionId: "e2e-lens-two",
}));
const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "uppercase both");
await settle();
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals");
const lenses = api.editorProposalController.provideCodeLenses(doc);
const titles = lenses.map((l) => l.command?.title);
assert.strictEqual(titles[0], "✓ Keep all (2)", "top-of-file accept-all lens leads");
assert.strictEqual(titles[1], "✗ Reject all", "top-of-file reject-all lens follows");
assert.deepStrictEqual(titles.slice(2), ["✓ Keep", "✗ Reject", "✓ Keep", "✗ Reject"], "per-proposal pairs follow");
const acceptAll = lenses[0].command!;
const rejectAll = lenses[1].command!;
assert.strictEqual(acceptAll.command, "cowriting.acceptAllProposals", "top-of-file accept-all routes at the batch command");
assert.strictEqual(rejectAll.command, "cowriting.rejectAllProposals", "top-of-file reject-all routes at the batch command");
assert.deepStrictEqual(acceptAll.arguments, undefined, "acceptAllProposals takes no arguments (targets the active editor)");
});
});
+164
View File
@@ -0,0 +1,164 @@
import * as assert from "node:assert";
import * as vscode from "vscode";
import { activateApi, settle, settleUntil } from "./helpers";
/**
* Task 9 (spec §6.8 named E2E, §7.1 rung 2 — stub-turned): the full
* native-surfaces inner loop end to end, in ONE doc:
* PUC-7 (enter coediting, snapshot baseline)
* → PUC-8 (comment → reply → offer → pending proposal, D10/D19)
* → PUC-2 (buffer review: tweak under typing / re-anchor, keep, reject)
* → PUC-1 (pin the baseline clean).
* Both turns are stubbed (no real @cline/sdk call) — the real-SDK rung 3 is
* `scripts/smoke-native-loop.mjs`, operator-run against live credentials.
*/
suite("full native loop — PUC-7 → PUC-8 → PUC-2 → PUC-1 (§6.8, §7.1 rung 2)", () => {
test("enter, ask twice, offer, tweak-under-typing, keep one, reject one, pin — clean review", async () => {
const api = await activateApi();
const ORIG_KEEP = "The quick brown fox jumps over the lazy dog paragraph.";
const ORIG_REJECT = "A second paragraph that should stay exactly as written.";
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: `# Full loop\n\n${ORIG_KEEP}\n\n${ORIG_REJECT}\n`,
});
const ed = await vscode.window.showTextDocument(doc);
// PUC-7: enter coediting. An untitled doc has no git HEAD, so it lands in
// snapshot mode (INV-7/D13/D14 — baselineRouter.test.ts covers the fork).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(
api.diffViewController.modeOf(doc.uri.toString()),
"snapshot",
"untitled doc enters coediting in snapshot mode",
);
const docKey = api.proposalController.keyFor(doc);
const KEEP_REPLACEMENT = "The quick fox jumps the lazy dog.";
const REJECT_REPLACEMENT = "A rewritten second paragraph nobody asked to keep.";
// ---- PUC-8, first ask: the "keep" paragraph -------------------------------------
api.threadController.setTurnRunnerForTest(async () => ({
replacement: "I'd tighten this sentence.",
model: "stub",
sessionId: "full-loop-1",
}));
api.editFlow.setEditTurnForTest(async () => ({
replacement: KEEP_REPLACEMENT,
model: "stub",
sessionId: "full-loop-1",
}));
const keepStart = doc.getText().indexOf(ORIG_KEEP);
ed.selection = new vscode.Selection(doc.positionAt(keepStart), doc.positionAt(keepStart + ORIG_KEEP.length));
const keepThreadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
assert.ok(keepThreadId, "thread created on a coedited doc (INV-10 gate open)");
// The reply loop fires on the human message; wait for the machine reply to
// land and flip the thread to "offer" (D10/PUC-8).
await settleUntil(() => {
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === keepThreadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 10000);
const keepIds = await api.threadController.makeThreadEdit(keepThreadId, docKey);
assert.strictEqual(keepIds.length, 1, "one pending proposal from the ranged ask");
const keepId = keepIds[0];
// ---- PUC-8, second ask: the "reject" paragraph ----------------------------------
api.editFlow.setEditTurnForTest(async () => ({
replacement: REJECT_REPLACEMENT,
model: "stub",
sessionId: "full-loop-2",
}));
const rejectStart = doc.getText().indexOf(ORIG_REJECT);
ed.selection = new vscode.Selection(doc.positionAt(rejectStart), doc.positionAt(rejectStart + ORIG_REJECT.length));
const rejectThreadId = (await api.threadController.createThreadOnSelection("rewrite this"))!;
await settleUntil(() => {
const t = api.sidecarRouter.load(docKey)?.threads.find((x) => x.id === rejectThreadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 10000);
const rejectIds = await api.threadController.makeThreadEdit(rejectThreadId, docKey);
assert.strictEqual(rejectIds.length, 1, "one pending proposal from the second ranged ask");
const rejectId = rejectIds[0];
// ---- decorated pending proposals (F12 optimistic-apply) --------------------------
await settleUntil(
() => api.proposalController.isApplied(docKey, keepId) && api.proposalController.isApplied(docKey, rejectId),
5000,
);
let views = api.proposalController.listProposals(doc);
assert.strictEqual(views.length, 2, "two pending proposals decorated");
for (const id of [keepId, rejectId]) {
const v = views.find((x) => x.id === id)!;
assert.notStrictEqual(v.anchorStart, null, "each proposal resolves (pending, not orphaned)");
assert.ok(api.proposalController.isApplied(docKey, id), "optimistically applied into the buffer");
}
assert.ok(doc.getText().includes(KEEP_REPLACEMENT), "claude's first proposed rewrite is live in the buffer");
assert.ok(doc.getText().includes(REJECT_REPLACEMENT), "claude's second proposed rewrite is live in the buffer");
// ---- PUC-2, tweak: type INSIDE the still-pending "keep" range --------------------
// Verified against src/anchorer.ts: `resolve()` is an EXACT-substring search
// (INV-1 — no fuzzy tolerance), so an interior edit to a proposal's own
// fingerprinted text unconditionally breaks that fingerprint — this is INV-11,
// already covered by proposals.test.ts's "editing the target text makes the
// proposal stale" case. F12's finalizeInPlace ("Keep") is deliberately built to
// bypass that staleness check (see its own doc comment: "Direct finalizeInPlace
// calls ... where the user may have edited inside the applied span bypass this
// check intentionally") — the tweaked text is ALREADY the accepted result
// sitting in the buffer, so no re-resolve is needed to keep it. That is the
// real "re-anchor" story for a pending-range tweak: the proposal survives as a
// live, addressable id (not silently dropped) and Keep still lands it, even
// though its raw anchor is (correctly) stale.
const at = doc.getText().indexOf(KEEP_REPLACEMENT);
const tweak = new vscode.WorkspaceEdit();
tweak.replace(doc.uri, new vscode.Range(doc.positionAt(at + 10), doc.positionAt(at + 13)), "FOX"); // "fox" -> "FOX"
assert.ok(await vscode.workspace.applyEdit(tweak), "human tweak applied inside the pending range");
await settle();
assert.ok(doc.getText().includes("The quick FOX jumps the lazy dog."), "human tweak landed in the buffer");
views = api.proposalController.listProposals(doc);
const keepView = views.find((v) => v.id === keepId);
assert.ok(keepView, "the tweaked proposal is still a live, addressable pending proposal (not silently dropped)");
assert.strictEqual(
keepView!.anchorStart,
null,
"INV-11: the interior tweak breaks the exact-fingerprint anchor (flagged stale, never guessed)",
);
// ---- PUC-2, keep: finalize the tweaked proposal in place -------------------------
assert.ok(await api.proposalController.finalizeInPlace(docKey, keepId), "keep finalizes in place");
await settle();
assert.strictEqual(
api.proposalController.listProposals(doc).find((v) => v.id === keepId),
undefined,
"kept proposal cleared",
);
// Attribution split (F3xF12): Claude's words AND the human tweak are both
// attributed, char-honest (spansFor).
const spans = api.attributionController.spansFor(doc);
const claudeSpan = spans.find(
(s) => s.author === "claude" && doc.getText().slice(s.start, s.end).includes("jumps the lazy dog"),
);
const humanSpan = spans.find((s) => s.author === "human" && doc.getText().slice(s.start, s.end) === "FOX");
assert.ok(claudeSpan, "claude's words are attributed");
assert.ok(humanSpan, "the human tweak is attributed separately (char-honest split)");
// ---- PUC-2, reject: revert the untouched second proposal in place ----------------
assert.ok(await api.proposalController.revertInPlace(docKey, rejectId), "reject reverts in place");
await settle();
assert.strictEqual(
api.proposalController.listProposals(doc).find((v) => v.id === rejectId),
undefined,
"rejected proposal cleared",
);
// INV-5: the rejected range returns EXACTLY to its pre-proposal text.
assert.ok(doc.getText().includes(ORIG_REJECT), "rejected range restored EXACTLY to its pre-proposal text (INV-5)");
assert.ok(!doc.getText().includes(REJECT_REPLACEMENT), "claude's rejected rewrite is gone from the buffer");
// ---- PUC-1: pin the baseline (Mark Changes as Reviewed) --------------------------
await vscode.commands.executeCommand("cowriting.markReviewed");
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0, 5000);
assert.strictEqual(
api.diffViewController.getBaseline(doc.uri.toString())?.reason,
"pinned",
"baseline re-pinned to the clean, fully-reviewed buffer",
);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Shared host-E2E helpers (native-surfaces migration, Task 2). NEW suites use
* these; existing suites keep their own local copies (out of scope to
* refactor them onto this shared file — see the Task 2 brief).
*/
import * as assert from "node:assert";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import { renderReview } from "../../../src/trackChangesModel";
/** Activate the extension and return its exported API (asserts it is real). */
export async function activateApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.diffViewController, "extension exports diffViewController");
return api;
}
/**
* Task 8: the pure render probe the deleted TrackChangesPreviewController used
* to wrap (`renderHtmlFor`) — the sunset webview was a thin shell around this
* exact call (F6 baseline + F3 spans + F4 pending proposals -> `renderReview`).
* Suites that used to read `controller.renderHtmlFor(key)` now call this
* directly against the surviving controllers; no webview involved.
*/
export function renderHtmlFor(api: CowritingApi, doc: vscode.TextDocument, key: string): string {
const baseline = api.diffViewController.getBaseline(key);
const current = doc.getText();
return renderReview(
baseline?.text ?? current,
current,
api.attributionController.spansFor(doc),
api.proposalController.listProposals(doc),
{ pinned: baseline?.reason === "pinned" },
);
}
/** A short fixed settle, for state that updates synchronously-ish after a command. */
export function settle(): Promise<void> {
return new Promise((r) => setTimeout(r, 150));
}
/** Poll `predicate` every 100ms until it is true, or fail after `timeoutMs`. */
export async function settleUntil(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
assert.fail(`settleUntil: predicate did not become true within ${timeoutMs}ms`);
}
await new Promise((r) => setTimeout(r, 100));
}
}
+10 -11
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller"); assert.ok(api?.editFlow && api?.proposalController, "exports edit flow + proposal controller");
return api; return api;
} }
@@ -20,6 +20,9 @@ async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument>
fs.writeFileSync(abs, body, "utf8"); fs.writeFileSync(abs, body, "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs)); const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: proposal rendering (F12 optimistic-apply, driven by
// onDidChangeProposals) is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return doc; return doc;
} }
@@ -33,19 +36,17 @@ suite("#60 live turn progress (additive + cancel)", () => {
test("a stub that emits progress still produces the same proposals (INV-44)", async () => { test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n"); const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns // The stub honors opts.onProgress (emitting synthetic snapshots) but returns
// the same rewrite — proposals must be unaffected by progress events. // the same rewrite — proposals must be unaffected by progress events.
ctl.setEditTurnForTest(async (_i, _text, opts) => { editFlow.setEditTurnForTest(async (_i, _text, opts) => {
opts?.onProgress?.({ phase: "writing", chars: 5 }); opts?.onProgress?.({ phase: "writing", chars: 5 });
opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." }); opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." });
return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" }; return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" };
}); });
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph"); const ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
await settle(); await settle();
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events"); assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]); const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
@@ -59,14 +60,12 @@ suite("#60 live turn progress (additive + cancel)", () => {
test("an aborted turn proposes nothing (INV-47)", async () => { test("an aborted turn proposes nothing (INV-47)", async () => {
const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n"); const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController; const editFlow = api.editFlow;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// The stub throws ONLY when the aborted signal reached it — so if opts.signal // The stub throws ONLY when the aborted signal reached it — so if opts.signal
// failed to thread through runEditAndPropose, the stub would instead return a // failed to thread through runEditAndPropose, the stub would instead return a
// rewrite and create a proposal, failing this test. That proves propagation. // rewrite and create a proposal, failing this test. That proves propagation.
ctl.setEditTurnForTest(async (_i, _text, opts) => { editFlow.setEditTurnForTest(async (_i, _text, opts) => {
if (opts?.signal?.aborted) throw new Error("claude-code turn aborted"); if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" }; return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" };
}); });
@@ -75,7 +74,7 @@ suite("#60 live turn progress (additive + cancel)", () => {
ac.abort(); ac.abort();
let ids: string[] = []; let ids: string[] = [];
try { try {
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal }); ids = await editFlow.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
} catch { } catch {
ids = []; ids = [];
} }
+10
View File
@@ -37,8 +37,16 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
const uri = vscode.Uri.file(outsidePath); const uri = vscode.Uri.file(outsidePath);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: attribution tracking + thread creation are gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
const api = await getApi(); const api = await getApi();
// Task 6 (D10): createThreadOnSelection (used later in this test) also
// fires the respond-in-thread turn now — stub it off (this "no LLM" suite
// asserts the programmatic propose/accept seam, not the reply loop).
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("out-of-workspace suite: reply-loop turn stubbed off");
});
const key = uri.toString(); const key = uri.toString();
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1"); const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
@@ -80,6 +88,8 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)",
const TARGET = "The untitled draft sentence."; const TARGET = "The untitled draft sentence.";
const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" }); const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" });
await vscode.window.showTextDocument(untitled); await vscode.window.showTextDocument(untitled);
// INV-10: attribution tracking is gated on coediting (Task 4).
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
const api = await getApi(); const api = await getApi();
const key = untitled.uri.toString(); const key = untitled.uri.toString();
+79
View File
@@ -0,0 +1,79 @@
import * as assert from "node:assert";
import MarkdownIt from "markdown-it";
import * as vscode from "vscode";
import { cowritingMarkdownItPlugin } from "../../../src/previewAnnotations";
import { activateApi, settle } from "./helpers";
// Task 7 (D3/D21, PUC-3): the built-in preview's DOM isn't queryable from a
// host E2E test (§6.8), so this asserts the pure transform's INPUTS/OUTPUTS
// through the real `CowritingApi` seams instead — the production
// `previewAnnotationHost.inputsFor` (not a hand-rolled reimplementation) fed
// through the real `cowritingMarkdownItPlugin` + a fresh markdown-it instance
// (mirroring the unit suite's own `render()` helper), so this exercises the
// ACTUAL registry/diffView/attribution/proposals/config wiring end to end.
suite("PUC-3 annotate-toggle (built-in Markdown preview annotations)", () => {
test("a machine-attributed change renders cw-ins-claude; disabling the setting passes the source through unchanged", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({
language: "markdown",
content: "hello world\n",
});
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const start = doc.getText().indexOf("world");
const ok = await vscode.commands.executeCommand<boolean>("cowriting.applyAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + "world".length,
newText: "brave new world",
model: "sonnet",
sessionId: "e2e-preview-annotations",
turnId: "turn-e2e-preview-annotations",
});
assert.strictEqual(ok, true, "seam edit applies");
await settle();
// Annotations ON (default): render the CURRENT buffer through the real
// markdown-it plugin + the production host — proves the full pipeline
// (core-rule swap → full-render sentinel walk) reaches a live doc.
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, api.previewAnnotationHost);
const html = md.render(doc.getText());
assert.ok(html.includes("cw-ins-claude"), `expected cw-ins-claude in:\n${html}`);
// Toggle OFF: the config-gated `enabled` flag reaches `annotateSource` and
// the transform becomes the identity (no core-rule swap, no marks).
const config = vscode.workspace.getConfiguration("cowriting");
await config.update("annotations", false, vscode.ConfigurationTarget.Global);
try {
const inputs = api.previewAnnotationHost.inputsFor(undefined);
assert.ok(inputs, "inputsFor still resolves a coedited doc while disabled");
assert.strictEqual(inputs!.enabled, false);
const md2 = new MarkdownIt();
cowritingMarkdownItPlugin(md2, api.previewAnnotationHost);
const plainMd = new MarkdownIt();
assert.strictEqual(md2.render(doc.getText()), plainMd.render(doc.getText()));
} finally {
await config.update("annotations", true, vscode.ConfigurationTarget.Global);
}
});
test("cowriting.toggleAnnotations flips the setting", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "toggle me\n" });
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
const config = () => vscode.workspace.getConfiguration("cowriting");
const before = config().get<boolean>("annotations", true);
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
assert.strictEqual(config().get<boolean>("annotations", true), !before);
// restore
await vscode.commands.executeCommand("cowriting.toggleAnnotations");
assert.strictEqual(config().get<boolean>("annotations", true), before);
void api; // keep the activated extension alive for the duration of the test
});
});
+4
View File
@@ -18,6 +18,10 @@ async function openDoc(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: proposal rendering (and the F12 optimistic-apply it drives via
// onDidChangeProposals) is gated on coediting (Task 4) — idempotent across
// this order-dependent suite's repeated openDoc() calls.
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc; return doc;
} }
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
+82 -37
View File
@@ -10,7 +10,7 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller"); assert.ok(api?.coeditingRegistry, "exports coeditingRegistry");
return api; return api;
} }
@@ -26,14 +26,19 @@ function pkg(): any {
return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
} }
// #41 (story): "Open Cowriting Review Panel" reachable from the markdown // Task 8 (native-surfaces migration, spec §6.10): the review-webview's #41
// file/tab right-click menu. The review surface (showTrackChangesPreview) is the // right-click entries ("Open Cowriting Review Panel") are replaced by
// plugin's central affordance; this adds explorer/context + editor/title/context // `cowriting.openReviewPreview` — a minimal wrapper that resolves the clicked
// entry points and makes the command open the *clicked* document — not merely // document (or falls back to the active editor, mirroring #41's original
// the active editor — so the explorer right-click works even when the file is // behavior), enters coediting if not already (so there is a baseline to review
// not already open. // against), and hands off to VS Code's OWN "Open Preview to the Side"
suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-doc resolution)", () => { // (`markdown.showPreviewToSide`) — the built-in preview IS the review surface
test("command with a Uri previews the CLICKED doc, not the active editor", async () => { // now (Task 7's annotations render inside it). There is no controller-level
// `isOpen` seam anymore (no bespoke webview to introspect); these tests observe
// the command's OWN side effects instead — which document it focused/entered
// coediting on — the same signal #41's original suite checked via `isOpen`.
suite("Open Cowriting Review Preview (host E2E — menu wiring + clicked-doc resolution, Task 8)", () => {
test("command with a Uri targets the CLICKED doc, not the active editor", async () => {
// Two markdown docs; A is the active editor, B is the right-clicked target. // Two markdown docs; A is the active editor, B is the right-clicked target.
const aUri = writeFile("docs/menu-active.md", "# Active\n\nThe active editor's document.\n"); const aUri = writeFile("docs/menu-active.md", "# Active\n\nThe active editor's document.\n");
const bUri = writeFile("docs/menu-clicked.md", "# Clicked\n\nThe right-clicked document.\n"); const bUri = writeFile("docs/menu-clicked.md", "# Clicked\n\nThe right-clicked document.\n");
@@ -43,19 +48,20 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
await settle(); await settle();
const api = await getApi(); const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", bUri); await vscode.commands.executeCommand("cowriting.openReviewPreview", bUri);
await settle(); await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(bUri.toString()), true, "preview opened for the clicked doc"); // `markdown.showPreviewToSide` itself ends with the preview WEBVIEW focused
assert.strictEqual( // (not a text editor), so `activeTextEditor` is not a stable post-command
api.trackChangesPreviewController.isOpen(aUri.toString()), // signal — `isCoediting` (set by the command's OWN resolve-and-enter step,
false, // before it hands off to the preview) is: the clicked doc B entered, the
"the active editor's doc did NOT get a preview", // still-open-but-not-clicked doc A did not.
); assert.strictEqual(api.coeditingRegistry.isCoediting(bUri), true, "the clicked doc B entered coediting (a baseline to review against)");
assert.strictEqual(api.coeditingRegistry.isCoediting(aUri), false, "the active-but-not-clicked doc A did NOT enter coediting");
assert.ok(bDoc, "clicked doc handle held"); assert.ok(bDoc, "clicked doc handle held");
}); });
test("command with a Uri for a not-yet-open file opens it and previews (explorer case)", async () => { test("command with a Uri for a not-yet-open file opens it and focuses it (explorer case)", async () => {
// Write a file but do not openTextDocument it — mimics an Explorer right-click. // Write a file but do not openTextDocument it — mimics an Explorer right-click.
const uri = writeFile("docs/menu-unopened.md", "# Unopened\n\nNever opened before the right-click.\n"); const uri = writeFile("docs/menu-unopened.md", "# Unopened\n\nNever opened before the right-click.\n");
const key = uri.toString(); const key = uri.toString();
@@ -65,10 +71,14 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
"precondition: the file is not an open document", "precondition: the file is not an open document",
); );
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview", uri); await vscode.commands.executeCommand("cowriting.openReviewPreview", uri);
await settle(); await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "the clicked-but-unopened file got a preview"); assert.ok(
vscode.workspace.textDocuments.some((d) => d.uri.toString() === key),
"the clicked-but-unopened file was opened",
);
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "it entered coediting");
}); });
test("no-arg invocation still falls back to the active editor (palette / keybinding unchanged)", async () => { test("no-arg invocation still falls back to the active editor (palette / keybinding unchanged)", async () => {
@@ -78,39 +88,74 @@ suite("#41 review-panel right-click entry (host E2E — menu wiring + clicked-do
await settle(); await settle();
const api = await getApi(); const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await vscode.commands.executeCommand("cowriting.openReviewPreview");
await settle(); await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(uri.toString()), true, "no-arg previews the active editor"); assert.strictEqual(api.coeditingRegistry.isCoediting(uri), true, "no-arg targets the active editor");
}); });
test("explorer/context contributes the review-panel item for markdown only", () => { test("a non-markdown doc: command warns, no coediting entered", async () => {
const uri = writeFile("docs/menu-notmd.txt", "Not markdown.\n");
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
const api = await getApi();
assert.notStrictEqual(doc.languageId, "markdown", "fixture is not markdown");
await vscode.commands.executeCommand("cowriting.openReviewPreview");
await settle();
assert.strictEqual(api.coeditingRegistry.isCoediting(uri), false, "a non-markdown doc never enters coediting via this gateway");
});
test("explorer/context contributes the review-preview item for markdown only", () => {
const items = (pkg().contributes.menus["explorer/context"] ?? []) as Array<{ command: string; when?: string }>; const items = (pkg().contributes.menus["explorer/context"] ?? []) as Array<{ command: string; when?: string }>;
const entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview"); const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
assert.ok(entry, "explorer/context has a showTrackChangesPreview entry"); assert.ok(entry, "explorer/context has an openReviewPreview entry");
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown resources"); assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown resources");
}); });
test("editor/title/context (tab right-click) contributes the review-panel item for markdown only", () => { test("editor/title/context (tab right-click) contributes the review-preview item for markdown only", () => {
const items = (pkg().contributes.menus["editor/title/context"] ?? []) as Array<{ command: string; when?: string }>; const items = (pkg().contributes.menus["editor/title/context"] ?? []) as Array<{ command: string; when?: string }>;
const entry = items.find((m) => m.command === "cowriting.showTrackChangesPreview"); const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
assert.ok(entry, "editor/title/context has a showTrackChangesPreview entry"); assert.ok(entry, "editor/title/context has an openReviewPreview entry");
assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown tabs"); assert.match(entry!.when ?? "", /resourceLangId == markdown/, "gated on markdown tabs");
}); });
test("the command title reads 'Open Cowriting Review Panel'", () => { test("editor/title (toolbar icon) contributes the review-preview item for markdown only", () => {
const cmd = (pkg().contributes.commands as Array<{ command: string; title: string }>).find( const items = (pkg().contributes.menus["editor/title"] ?? []) as Array<{ command: string; when?: string }>;
(c) => c.command === "cowriting.showTrackChangesPreview", const entry = items.find((m) => m.command === "cowriting.openReviewPreview");
); assert.ok(entry, "editor/title has an openReviewPreview entry");
assert.ok(cmd, "command is contributed"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "gated on markdown");
assert.strictEqual(cmd!.title, "Open Cowriting Review Panel", "menus render this title");
}); });
test("the ctrl+alt+r keybinding still targets the command (unchanged)", () => { test("the command title reads 'Open Cowriting Review Preview'", () => {
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find( const cmd = (pkg().contributes.commands as Array<{ command: string; title: string }>).find(
(c) => c.command === "cowriting.openReviewPreview",
);
assert.ok(cmd, "command is contributed");
assert.strictEqual(cmd!.title, "Open Cowriting Review Preview", "menus render this title");
});
test("the old cowriting.showTrackChangesPreview command/menus/keybinding are fully gone (Task 8 exit criterion)", () => {
const p = pkg();
const commands = p.contributes.commands as Array<{ command: string }>;
assert.ok(!commands.some((c) => c.command === "cowriting.showTrackChangesPreview"), "command contribution removed");
for (const menuId of ["commandPalette", "editor/title", "editor/title/context", "explorer/context"]) {
const items = (p.contributes.menus[menuId] ?? []) as Array<{ command: string }>;
assert.ok(!items.some((m) => m.command === "cowriting.showTrackChangesPreview"), `${menuId} no longer references it`);
}
const kb = (p.contributes.keybindings as Array<{ command: string }>).find(
(k) => k.command === "cowriting.showTrackChangesPreview", (k) => k.command === "cowriting.showTrackChangesPreview",
); );
assert.ok(kb, "keybinding still present"); assert.ok(!kb, "keybinding removed");
assert.strictEqual(kb!.key, "ctrl+alt+r", "key unchanged"); });
test("ctrl+alt+r now targets cowriting.reviewChanges (the native diff), not the retired preview command", () => {
const kb = (pkg().contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find(
(k) => k.key === "ctrl+alt+r",
);
assert.ok(kb, "ctrl+alt+r keybinding still present");
assert.strictEqual(kb!.command, "cowriting.reviewChanges", "the chord now opens the native diff (Step 1 re-point)");
}); });
}); });
+11 -9
View File
@@ -3,6 +3,7 @@ import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as vscode from "vscode"; import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension"; import type { CowritingApi } from "../../../src/extension";
import { renderHtmlFor } from "./helpers";
const WS = process.env.E2E_WORKSPACE!; const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400)); const settle = () => new Promise((r) => setTimeout(r, 400));
@@ -10,10 +11,12 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller"); assert.ok(api?.diffViewController && api?.attributionController, "exports diffView + attribution");
return api; return api;
} }
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open. */
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel); const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.mkdirSync(path.dirname(abs), { recursive: true });
@@ -21,6 +24,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return { doc, key: uri.toString() }; return { doc, key: uri.toString() };
} }
@@ -32,21 +36,19 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
test("pin clears authorship coloring; a later edit brings annotations back", async () => { test("pin clears authorship coloring; a later edit brings annotations back", async () => {
const { doc, key } = await freshDoc("docs/s48pin.md", "# S48\n\nAn original baseline paragraph.\n"); const { doc, key } = await freshDoc("docs/s48pin.md", "# S48\n\nAn original baseline paragraph.\n");
const api = await getApi(); const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Type a paragraph → a human attribution span + author coloring in the on-state. // Type a paragraph → a human attribution span + author coloring in the on-state.
const edit = new vscode.WorkspaceEdit(); const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n"); edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied"); assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle(); await settle();
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "typed text is author-colored cw-ins-human before the pin"); assert.match(renderHtmlFor(api, doc, key), /cw-ins-human/, "typed text is author-colored cw-ins-human before the pin");
// Pin the baseline → zero diff → the panel must be fully clean. // Pin the baseline (the real command — replaces the F11-webview's pinBaseline
ctl.receiveMessage(key, { type: "pinBaseline" }); // toolbar intent, Task 8) → zero diff → the render must be fully clean.
await vscode.commands.executeCommand("cowriting.markReviewed");
await settle(); await settle();
const pinned = ctl.renderHtmlFor(key); const pinned = renderHtmlFor(api, doc, key);
assert.ok(!pinned.includes("cw-ins-human"), "no human insertion marks after pin"); assert.ok(!pinned.includes("cw-ins-human"), "no human insertion marks after pin");
assert.ok(!pinned.includes("cw-ins-claude"), "no Claude insertion marks after pin"); assert.ok(!pinned.includes("cw-ins-claude"), "no Claude insertion marks after pin");
assert.ok(!pinned.includes("cw-ins-") && !pinned.includes("cw-del-"), "no insertion or deletion marks after pin"); assert.ok(!pinned.includes("cw-ins-") && !pinned.includes("cw-del-"), "no insertion or deletion marks after pin");
@@ -58,6 +60,6 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n"); edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n");
assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied"); assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied");
await settle(); await settle();
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "cw-ins-human authorship coloring returns once the doc diverges from the pin"); assert.match(renderHtmlFor(api, doc, key), /cw-ins-human/, "cw-ins-human authorship coloring returns once the doc diverges from the pin");
}); });
}); });
+16
View File
@@ -18,6 +18,9 @@ async function openSample(): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const uri = vscode.Uri.file(path.join(WS, DOC_REL));
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
// INV-10: thread creation/rendering is gated on coediting (Task 4) — idempotent
// across this order-dependent suite's repeated openSample() calls.
await vscode.commands.executeCommand("cowriting.coeditDocument");
return doc; return doc;
} }
@@ -38,6 +41,17 @@ async function getApi(): Promise<CowritingApi> {
return api; return api;
} }
// Task 6 (D10): every human comment on a coedited doc now also fires the
// respond-in-thread turn (createThreadOnSelection/reply). This suite predates
// that loop and asserts exact message sequences unrelated to it — stub the
// turn to reject so the loop runs harmlessly (no real @cline/sdk call, and no
// extra machine message lands in the sidecar this suite's assertions read).
function stubReplyLoop(api: CowritingApi): void {
api.threadController.setTurnRunnerForTest(async () => {
throw new Error("F2 suite: reply-loop turn stubbed off (not under test here)");
});
}
// Reaches the controller's internal docs map for reply/resolve handlers (which // Reaches the controller's internal docs map for reply/resolve handlers (which
// expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and // expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and
// assert Comments-controller state" fallback. // assert Comments-controller state" fallback.
@@ -64,6 +78,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
test("create on selection persists a thread sidecar", async () => { test("create on selection persists a thread sidecar", async () => {
const doc = await openSample(); const doc = await openSample();
const api = await getApi(); const api = await getApi();
stubReplyLoop(api);
const start = doc.getText().indexOf("quick brown fox"); const start = doc.getText().indexOf("quick brown fox");
const editor = vscode.window.activeTextEditor!; const editor = vscode.window.activeTextEditor!;
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length)); editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length));
@@ -86,6 +101,7 @@ suite("F2 region-anchored threads (host E2E)", () => {
test("reply appends and resolve flips status, both persisted", async () => { test("reply appends and resolve flips status, both persisted", async () => {
const api = await getApi(); const api = await getApi();
stubReplyLoop(api);
const art0 = readSidecar(); const art0 = readSidecar();
const threadId = art0.threads[0].id; const threadId = art0.threads[0].id;
-135
View File
@@ -1,135 +0,0 @@
import * as assert from "assert";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/preview.md";
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();
async function openDoc(rel = DOC_REL): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, rel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
return doc;
}
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "extension exports trackChangesPreviewController");
return api;
}
const settle = () => new Promise((r) => setTimeout(r, 400));
const kinds = (api: CowritingApi) =>
(api.trackChangesPreviewController.getLastModel(docUri()) ?? []).map((o) => o.kind);
// Order-dependent (F2F6 pattern): later tests consume earlier state.
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
const TARGET = "A target sentence Claude will rewrite via the seam.";
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
const doc = await openDoc();
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
const model = api.trackChangesPreviewController.getLastModel(docUri());
assert.ok(model && model.length > 0, "a model was computed");
assert.ok(
model!.every((o) => o.kind === "unchanged"),
"baseline == buffer at open → every block unchanged",
);
void doc;
});
test("typing produces a changed/added block (PUC-2)", async () => {
const doc = await openDoc();
const api = await getApi();
const edit = new vscode.WorkspaceEdit();
const end = doc.positionAt(doc.getText().length);
edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
assert.ok(
kinds(api).some((k) => k === "added" || k === "changed"),
"an added/changed block appears after typing",
);
});
test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => {
const doc = await openDoc();
const api = await getApi();
const start = doc.getText().indexOf(TARGET);
assert.ok(start >= 0, "fixture contains the target sentence");
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + TARGET.length,
newText: REPLACEMENT,
model: "sonnet",
sessionId: "e2e-f7",
turnId: "turn-f7-1",
});
assert.ok(id, "propose returns an id");
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
// The baseline advanced to include REPLACEMENT, so the buffer == baseline for
// that block → it is NOT marked. Confirm the replacement is not flagged as a change.
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
const replacementMarked = model.some(
(o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"),
);
assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)");
});
test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => {
const doc = await openDoc();
const api = await getApi();
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
await settle();
assert.ok(
kinds(api).every((k) => k === "unchanged"),
"after pin, every block is unchanged (baseline == buffer)",
);
void doc;
});
test("a non-markdown doc → command warns and opens no panel (PUC-6, markdown-only)", async () => {
const txt = await openDoc("docs/notes.txt");
const api = await getApi();
const key = txt.uri.toString();
assert.notStrictEqual(txt.languageId, "markdown", "fixture is not markdown");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for non-markdown");
});
test("a changed flowchart augments the emitted mermaid source (#22, INV-29)", async () => {
const doc = await openDoc();
const api = await getApi();
// Show the preview and pin the baseline so the fixture's `a --> b` flowchart
// is the "before"; then add an edge `a --> c` and confirm the intra-diagram diff.
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
await settle();
const anchor = doc.getText().indexOf("a --> b");
assert.ok(anchor >= 0, "fixture contains the flowchart edge");
const insertAt = doc.positionAt(anchor + "a --> b".length);
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, insertAt, "\n a --> c");
assert.ok(await vscode.workspace.applyEdit(edit), "flowchart edit applied");
await settle();
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
const mermaidOp = model.find((o) => o.kind === "changed" && o.block.type === "mermaid");
assert.ok(mermaidOp, "the flowchart block is a changed mermaid op");
const html = api.trackChangesPreviewController.renderHtmlFor(docUri());
assert.match(html, /classDef cwAdded/, "augmented with cwAdded classDef");
assert.match(html, /class\s+c\s+cwAdded/, "node c colored added");
assert.match(html, /cw-mermaid-legend/, "legend emitted");
});
});
+7 -2
View File
@@ -4,6 +4,7 @@ import * as path from "path";
import * as vscode from "vscode"; import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension"; import type { CowritingApi } from "../../../src/extension";
import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable"; import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable";
import { renderHtmlFor } from "./helpers";
const WS = process.env.E2E_WORKSPACE!; const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400)); const settle = () => new Promise((r) => setTimeout(r, 400));
@@ -11,10 +12,13 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> { async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi; const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.attributionController && api?.trackChangesPreviewController, "exports attribution + preview"); assert.ok(api?.attributionController && api?.proposalController, "exports attribution + proposal");
return api; return api;
} }
/** Also enters coediting (INV-10/Task 2): the baseline is established only on
* entry, not merely on open — needed for the preview's added/changed marks to
* reflect a real diff rather than a vacuous baseline==current fallback. */
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel); const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.mkdirSync(path.dirname(abs), { recursive: true });
@@ -22,6 +26,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo
const uri = vscode.Uri.file(abs); const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri); const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc); await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle(); await settle();
return { doc, key: uri.toString() }; return { doc, key: uri.toString() };
} }
@@ -79,7 +84,7 @@ suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)"
// And the on-state render must not color 'bravo' as human-authored. // And the on-state render must not color 'bravo' as human-authored.
// Added blocks use cw-ins-human (not cw-by-human) since Task 3/44ef0a2. // Added blocks use cw-ins-human (not cw-by-human) since Task 3/44ef0a2.
const html = api.trackChangesPreviewController.renderHtmlFor(key); const html = renderHtmlFor(api, doc, key);
const bravoColoredHuman = /cw-ins-human[^<]*bravo|<[^>]+class="[^"]*cw-ins-human[^"]*"[^>]*>[^<]*bravo/.test(html); const bravoColoredHuman = /cw-ins-human[^<]*bravo|<[^>]+class="[^"]*cw-ins-human[^"]*"[^>]*>[^<]*bravo/.test(html);
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-ins-human in the preview"); assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-ins-human in the preview");
}); });
+198
View File
@@ -0,0 +1,198 @@
import MarkdownIt from "markdown-it";
import { describe, expect, it } from "vitest";
import { cowritingMarkdownItPlugin } from "../src/previewAnnotations";
function render(src: string, inputs: any): string {
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, { inputsFor: () => inputs });
return md.render(src);
}
const base = { baselineReason: "entered", proposals: [], enabled: true };
describe("preview annotations (PUC-3/D21)", () => {
it("renders clean when disabled", () => {
const html = render("hello brave world\n", { ...base, enabled: false, baselineText: "hello world\n", spans: [] });
expect(html).not.toContain("cw-");
});
it("colors machine spans blue and human insertions green vs baseline", () => {
const html = render("hello brave new world\n", {
...base,
baselineText: "hello world\n",
spans: [{ start: 6, end: 12, author: "claude" }, { start: 12, end: 16, author: "human" }],
});
expect(html).toContain('class="cw-ins-claude"');
expect(html).toContain('class="cw-ins-human"');
});
it("strikes deletions vs baseline", () => {
const html = render("hello world\n", { ...base, baselineText: "hello cruel world\n", spans: [] });
expect(html).toContain("cw-del");
expect(html).toContain("cruel");
});
it("pinned baseline renders clean even with spans (pin→clean, INV-33/#48)", () => {
const html = render("hello brave world\n", {
...base, baselineReason: "pinned", baselineText: "hello brave world\n",
spans: [{ start: 6, end: 12, author: "claude" }],
});
expect(html).not.toContain("cw-ins");
});
it("survives intra-emphasis boundaries (#33 discipline)", () => {
const html = render("a *bold claim* here\n", {
...base, baselineText: "a here\n", spans: [{ start: 2, end: 14, author: "claude" }],
});
expect(html).toContain("cw-ins-claude");
expect(html).not.toMatch(/[-]/); // no PUA sentinel leaks
});
// Task 8 (coverage moved from the deleted f10Review.test.ts PUC-3/9): a
// pending F4 proposal is optimistically applied in the buffer (F12, INV-48),
// so the preview renders its applied text ins-claude and reinserts the
// pre-apply original as a deletion — proposals are agent-authored by
// definition, so this is unconditional (no `spans` entry needed).
it("a pending proposal renders its applied text cw-ins-claude and the original as a deletion", () => {
const src = "hello CLAUDE world\n";
const html = render(src, {
...base,
baselineText: "hello world\n",
spans: [],
proposals: [
{ id: "p1", anchorStart: 6, anchorEnd: 12, replaced: "world", replacement: "CLAUDE" },
],
});
expect(html).toContain('class="cw-ins-claude"');
expect(html).toContain("CLAUDE");
expect(html).toContain("cw-del");
expect(html).toContain("world");
});
// Finding 4 (final whole-branch review, session native-surfaces-exec): the
// applied-proposal insertion mark used to hardcode tag: "claude", so a
// human-authored proposal (ProposalView.author = "human") still rendered
// cw-ins-claude. It must follow `p.author` (defaulting to "claude" only
// when the field is absent, per ProposalView's own documented default).
it("a human-authored pending proposal renders its applied text cw-ins-human, not cw-ins-claude", () => {
const src = "hello HUMAN world\n";
const html = render(src, {
...base,
baselineText: "hello world\n",
spans: [],
proposals: [
{ id: "p1", anchorStart: 6, anchorEnd: 11, replaced: "world", replacement: "HUMAN", author: "human" },
],
});
expect(html).toContain('class="cw-ins-human"');
expect(html).not.toContain('class="cw-ins-claude"');
expect(html).toContain("HUMAN");
});
// Task 8 (INV-32/33 coverage moved from f10Review.test.ts): a block with no
// spans and no baseline divergence carries no cw- marks at all, even when
// annotations are enabled — "changed since baseline" is the only trigger.
it("an unchanged block (baseline == src, no spans) renders with no cw- marks", () => {
const html = render("hello unchanged world\n", {
...base, baselineText: "hello unchanged world\n", spans: [],
});
expect(html).not.toContain("cw-");
});
});
// Task 7 §2.6 / cross-task review follow-up (plan-mandated, INV-29..31): the
// built-in preview's mermaid fence rendering reuses the F7.1 (#22) pure
// re-emit, same as the sunset webview did — a changed mermaid diagram gets
// intra-diagram change styling, not just the plain current diagram. Ports the
// substance of the E2E assertions Task 8 deleted with the webview
// (`git show 2170a0d^:test/e2e/suite/trackChangesPreview.test.ts`, "a changed
// flowchart augments the emitted mermaid source (#22, INV-29)") as pure unit
// tests here instead — no DOM/webview needed to verify SOURCE augmentation.
describe("intra-diagram mermaid diff in the built-in preview (plan T7 §2.6, INV-29)", () => {
const flowchartBefore = "```mermaid\nflowchart LR\n a --> b\n```\n";
const flowchartAfter = "```mermaid\nflowchart LR\n a --> b\n a --> c\n```\n";
it("a changed flowchart fence is re-emitted with cwAdded styling + a legend", () => {
const html = render(flowchartAfter, { ...base, baselineText: flowchartBefore, spans: [] });
expect(html).toMatch(/classDef cwAdded/);
expect(html).toMatch(/class\s+c\s+cwAdded/);
expect(html).toContain("cw-mermaid-legend");
expect(html).toContain('class="mermaid"');
});
it("an unchanged mermaid fence passes through byte-identical (no diff markup)", () => {
const html = render(flowchartBefore, { ...base, baselineText: flowchartBefore, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
expect(html).toContain("flowchart LR");
expect(html).toContain("a --&gt; b");
});
it("annotations disabled: a changed mermaid fence renders the plain current diagram", () => {
const html = render(flowchartAfter, { ...base, enabled: false, baselineText: flowchartBefore, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
expect(html).toContain("a --&gt; c");
});
it("pinned baseline with zero landed diff: mermaid fence renders the plain current diagram (INV-33/#48 parity)", () => {
const html = render(flowchartAfter, {
...base, baselineReason: "pinned", baselineText: flowchartAfter, spans: [],
});
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
});
it("no baseline (no coediting context established): mermaid fence renders the plain current diagram", () => {
const html = render(flowchartAfter, { ...base, baselineText: undefined, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
});
it("a non-mermaid changed fence is untouched by the mermaid queue (fence-kind gate)", () => {
const before = "```js\nconst a = 1;\n```\n";
const after = "```js\nconst a = 2;\n```\n";
const html = render(after, { ...base, baselineText: before, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
expect(html).toContain('class="language-js"');
});
// Code-review hardening (705de31 follow-up): the queue used to be consumed
// POSITIONALLY per mermaid fence encountered, so a fence the queue doesn't
// know about (e.g. a pending proposal's optimistic apply, F12/INV-48,
// inserting a whole new mermaid fence ABOVE a changed one) shifted every
// later fence's override by one, substituting the wrong diagram's augmented
// source into the wrong fence instead of the documented verbatim fallback.
// The fix matches queue entries to fences by BODY: a fence with no matching
// entry renders verbatim without consuming anything, so a later matching
// fence still finds its own entry.
it("misalignment: an extra proposal-inserted mermaid fence above a changed one renders verbatim, and the changed fence still gets its own augmentation", () => {
const extraFence = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n";
const src = extraFence + "\n" + flowchartAfter;
const html = render(src, {
...base,
baselineText: flowchartBefore,
spans: [],
proposals: [{ id: "p1", anchorStart: 0, anchorEnd: extraFence.length, replaced: "", replacement: extraFence }],
});
const fences = html.split('<pre class="mermaid"').slice(1); // one chunk per fence, document order
expect(fences).toHaveLength(2);
// the extra fence has no matching queue entry: verbatim, no wrong-diagram substitution
expect(fences[0]).toContain("sequenceDiagram");
expect(fences[0]).not.toContain("cwAdded");
// the changed fence still finds its own augmentation despite the extra fence ahead of it in document order
expect(fences[1]).toContain("classDef cwAdded");
expect(fences[1]).toMatch(/class\s+c\s+cwAdded/);
expect((html.match(/cw-mermaid-legend/g) ?? []).length).toBe(1); // legend only on the augmented fence
});
// Body-match must not collapse two changed fences that happen to render the
// SAME current diagram into a single queue entry — each occurrence gets its
// own independent augmentation (consume-first-match, not a body->entry map).
it("two identical-body changed mermaid fences each get their own augmentation", () => {
const baselineText = flowchartBefore + "\n" + flowchartBefore;
const src = flowchartAfter + "\n" + flowchartAfter;
const html = render(src, { ...base, baselineText, spans: [] });
const fences = html.split('<pre class="mermaid"').slice(1);
expect(fences).toHaveLength(2);
for (const f of fences) {
expect(f).toContain("classDef cwAdded");
expect(f).toMatch(/class\s+c\s+cwAdded/);
}
expect((html.match(/cw-mermaid-legend/g) ?? []).length).toBe(2);
});
});
+19 -3
View File
@@ -1,6 +1,6 @@
import { describe, it, test, expect } from "vitest"; import { describe, it, test, expect } from "vitest";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, type AuthorSpan } from "../src/trackChangesModel"; import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, countLineHunks, type AuthorSpan } from "../src/trackChangesModel";
const md = new MarkdownIt({ html: true, linkify: false, breaks: false }); const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
describe("splitBlocks", () => { describe("splitBlocks", () => {
@@ -440,9 +440,9 @@ describe("renderReview", () => {
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action) expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
expect(html).toContain("Person"); expect(html).toContain("Person");
}); });
test("renderReview: zero diff (e.g. machine-landing accept) renders unchanged blocks plain (Task 2 behavior)", () => { test("renderReview: zero diff (baseline == current) renders unchanged blocks plain (Task 2 behavior)", () => {
// Task 2: unchanged blocks always render plain — color means "changed since baseline". // Task 2: unchanged blocks always render plain — color means "changed since baseline".
// After accepting a Claude edit the text is the new baseline; no diff → no coloring. // When baseline == current (e.g. right after a fresh snapshot) there's no diff → no coloring.
const doc = "Human wrote this.\n\nClaude wrote that."; const doc = "Human wrote this.\n\nClaude wrote that.";
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }]; const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
const html = renderReview(doc, doc, spans, []); // no pinned flag const html = renderReview(doc, doc, spans, []); // no pinned flag
@@ -825,3 +825,19 @@ describe("F11 data-src emission (INV-36)", () => {
expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"'); expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"');
}); });
}); });
describe("countLineHunks", () => {
it("0 for identical text", () => {
expect(countLineHunks("a\nb\n", "a\nb\n")).toBe(0);
});
it("1 for one contiguous change", () => {
expect(countLineHunks("a\nb\nc\n", "a\nX\nc\n")).toBe(1);
});
it("2 for two separated changes", () => {
expect(countLineHunks("a\nb\nc\nd\ne\n", "a\nX\nc\nd\nY\n")).toBe(2);
});
it("counts pure insertions and deletions", () => {
expect(countLineHunks("a\n", "a\nb\n")).toBe(1);
expect(countLineHunks("a\nb\n", "a\n")).toBe(1);
});
});