From 935fcc35ee311f46e3b2421d37188f37d13e2f63 Mon Sep 17 00:00:00 2001 From: benstull <1+benstull@noreply.localhost> Date: Thu, 2 Jul 2026 23:09:37 +0000 Subject: [PATCH] =?UTF-8?q?Native=20surfaces=20migration=20=E2=80=94=20evo?= =?UTF-8?q?lve=20the=20extension=20onto=20VS=20Code's=20native=20review=20?= =?UTF-8?q?surfaces=20(9-task=20plan,=20spec=20v0.2.1)=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 97 +++- .../2026-07-01-native-surfaces-migration.md | 6 +- esbuild.mjs | 23 +- media/preview-annotations.css | 55 ++ media/preview-mermaid.ts | 57 ++ media/preview.css | 118 ---- media/preview.ts | 157 ----- package.json | 146 ++++- scripts/smoke-native-loop.mjs | 67 +++ src/attributionController.ts | 46 +- src/baselineStore.ts | 19 +- src/coeditingRegistry.ts | 57 ++ src/diffViewController.ts | 169 ++++-- src/editFlow.ts | 102 ++++ src/editInstructionInput.ts | 147 ----- src/editorProposalController.ts | 43 +- src/extension.ts | 410 ++++++++----- src/gitBaseline.ts | 131 +++++ src/previewAnnotations.ts | 300 ++++++++++ src/proposalController.ts | 44 +- src/scmSurface.ts | 120 ++++ src/threadController.ts | 309 +++++++++- src/trackChangesModel.ts | 130 +++-- src/trackChangesPreview.ts | 537 ------------------ test/baselineStore.test.ts | 42 +- test/coeditingRegistry.test.ts | 169 ++++++ test/e2e/smoke/nativeLoop.ts | 77 +++ .../suite-no-workspace/noWorkspace.test.ts | 15 +- test/e2e/suite/attribution.test.ts | 3 + test/e2e/suite/authorship.test.ts | 32 +- test/e2e/suite/baselineRouter.test.ts | 87 +++ test/e2e/suite/coediting.test.ts | 223 ++++++++ test/e2e/suite/commentLoop.test.ts | 117 ++++ test/e2e/suite/crossrung.test.ts | 9 + test/e2e/suite/diffView.test.ts | 66 ++- test/e2e/suite/f10Review.test.ts | 302 ---------- test/e2e/suite/f11Toolbar.test.ts | 138 ++--- test/e2e/suite/f12Accept.test.ts | 41 +- test/e2e/suite/f12InlineDiff.test.ts | 56 +- test/e2e/suite/f12Reach.test.ts | 87 ++- test/e2e/suite/f12Review.test.ts | 81 ++- test/e2e/suite/fullLoop.test.ts | 164 ++++++ test/e2e/suite/helpers.ts | 52 ++ test/e2e/suite/liveProgress.test.ts | 21 +- test/e2e/suite/outOfWorkspace.test.ts | 10 + test/e2e/suite/previewAnnotations.test.ts | 79 +++ test/e2e/suite/proposals.test.ts | 4 + test/e2e/suite/reviewPanelMenu.test.ts | 119 ++-- test/e2e/suite/s48PinClean.test.ts | 20 +- test/e2e/suite/threads.test.ts | 16 + test/e2e/suite/trackChangesPreview.test.ts | 135 ----- test/e2e/suite/undoMarks.test.ts | 9 +- test/previewAnnotations.test.ts | 198 +++++++ test/trackChangesModel.test.ts | 22 +- 54 files changed, 3689 insertions(+), 1995 deletions(-) create mode 100644 media/preview-annotations.css create mode 100644 media/preview-mermaid.ts delete mode 100644 media/preview.css delete mode 100644 media/preview.ts create mode 100644 scripts/smoke-native-loop.mjs create mode 100644 src/coeditingRegistry.ts create mode 100644 src/editFlow.ts delete mode 100644 src/editInstructionInput.ts create mode 100644 src/gitBaseline.ts create mode 100644 src/previewAnnotations.ts create mode 100644 src/scmSurface.ts delete mode 100644 src/trackChangesPreview.ts create mode 100644 test/coeditingRegistry.test.ts create mode 100644 test/e2e/smoke/nativeLoop.ts create mode 100644 test/e2e/suite/baselineRouter.test.ts create mode 100644 test/e2e/suite/coediting.test.ts create mode 100644 test/e2e/suite/commentLoop.test.ts delete mode 100644 test/e2e/suite/f10Review.test.ts create mode 100644 test/e2e/suite/fullLoop.test.ts create mode 100644 test/e2e/suite/helpers.ts create mode 100644 test/e2e/suite/previewAnnotations.test.ts delete mode 100644 test/e2e/suite/trackChangesPreview.test.ts create mode 100644 test/previewAnnotations.test.ts diff --git a/README.md b/README.md index 1a4b979..4cfc315 100644 --- a/README.md +++ b/README.md @@ -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 human/Claude attribution (Feature #6), F4 propose/accept diff flow (Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view -toggle (Feature #17), F10 interactive review — **write left / review -right** (Feature #29), and F11 — the **preview toolbar as the primary -interaction surface** (Feature #43). +data layer (Feature #17), and the **native-surfaces migration** (the +"Coediting Markdown with a Machine" Solution Design) — the current UI, replacing +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 @@ -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`. -## 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 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`. 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 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`. 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 | 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`. 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 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`. 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. Beside the existing **Annotations** switch it gains two controls: diff --git a/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md b/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md index df150d9..f289ba4 100644 --- a/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md +++ b/docs/superpowers/plans/2026-07-01-native-surfaces-migration.md @@ -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)" ``` ----### Task 5: Extract the edit flow from the webview controller +--- + +### Task 5: Extract the edit flow from the webview controller **Files:** - 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` - 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 3: Verify + commit.** diff --git a/esbuild.mjs b/esbuild.mjs index 916f33a..ca9a273 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -31,13 +31,14 @@ const liveTurnOptions = { }; /** @type {import('esbuild').BuildOptions} */ -const previewOptions = { - entryPoints: ["media/preview.ts"], - outfile: "out/media/preview.js", +const previewMermaidOptions = { + entryPoints: ["media/preview-mermaid.ts"], + outfile: "out/media/preview-mermaid.js", bundle: true, - // The webview is a browser context; mermaid is bundled IN (and ONLY in) this - // asset so it never bloats the extension-host bundle (the @cline/sdk size - // discipline). No externals — everything is shipped to the sealed webview. + // Task 7 (Q4): contributed via markdown.previewScripts into the BUILT-IN + // preview's webview (a browser context) — mermaid is bundled IN (and ONLY + // in) this asset so it never bloats the extension-host bundle (the + // @cline/sdk size discipline). platform: "browser", format: "iife", target: "es2020", @@ -48,12 +49,14 @@ const previewOptions = { if (watch) { const ctx = await context(options); const ctxLive = await context(liveTurnOptions); - const ctxPreview = await context(previewOptions); - await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]); + const ctxPreviewMermaid = await context(previewMermaidOptions); + await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreviewMermaid.watch()]); console.log("esbuild: watching…"); } else { await build(options); await build(liveTurnOptions); - await build(previewOptions); - console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js"); + await build(previewMermaidOptions); + console.log( + "esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview-mermaid.js", + ); } diff --git a/media/preview-annotations.css b/media/preview-annotations.css new file mode 100644 index 0000000..f990d84 --- /dev/null +++ b/media/preview-annotations.css @@ -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; } diff --git a/media/preview-mermaid.ts b/media/preview-mermaid.ts new file mode 100644 index 0000000..320d209 --- /dev/null +++ b/media/preview-mermaid.ts @@ -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 + * `
SRC
` (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 { + const nodes = Array.from(document.querySelectorAll("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(); diff --git a/media/preview.css b/media/preview.css deleted file mode 100644 index f706ff1..0000000 --- a/media/preview.css +++ /dev/null @@ -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; } diff --git a/media/preview.ts b/media/preview.ts deleted file mode 100644 index f4769e2..0000000 --- a/media/preview.ts +++ /dev/null @@ -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(".cw-actions button"); - if (!btn) return; - const id = btn.closest(".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 { - const nodes = Array.from(body.querySelectorAll("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) => { - 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 = - `+${msg.summary?.added ?? 0} ` + - `−${msg.summary?.removed ?? 0} ` + - `${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}`; - } - void renderMermaid(); -}); diff --git a/package.json b/package.json index 913b6fa..b5be3a2 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,13 @@ "onStartupFinished" ], "contributes": { + "markdown.markdownItPlugins": true, + "markdown.previewStyles": [ + "./media/preview-annotations.css" + ], + "markdown.previewScripts": [ + "./out/media/preview-mermaid.js" + ], "configuration": { "title": "Cowriting", "properties": { @@ -25,6 +32,11 @@ "type": "boolean", "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." + }, + "cowriting.annotations": { + "type": "boolean", + "default": true, + "description": "Show authorship + change annotations in the Markdown preview for coedited documents." } } }, @@ -85,13 +97,20 @@ "category": "Cowriting" }, { - "command": "cowriting.pinDiffBaseline", - "title": "Cowriting: Pin Review Baseline to Now", - "category": "Cowriting" + "command": "cowriting.markReviewed", + "title": "Mark Changes as Reviewed", + "category": "Cowriting", + "icon": "$(check-all)" }, { - "command": "cowriting.showTrackChangesPreview", - "title": "Open Cowriting Review Panel", + "command": "cowriting.reviewChanges", + "title": "Review Changes", + "category": "Cowriting", + "icon": "$(git-compare)" + }, + { + "command": "cowriting.openReviewPreview", + "title": "Open Cowriting Review Preview", "category": "Cowriting" }, { @@ -118,6 +137,34 @@ "command": "cowriting.proposalRejectMenu", "title": "Reject Claude Proposal", "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": { @@ -139,8 +186,12 @@ "when": "false" }, { - "command": "cowriting.pinDiffBaseline", - "when": "editorLangId == markdown" + "command": "cowriting.reviewChanges", + "when": "editorLangId == markdown && cowriting.isCoediting" + }, + { + "command": "cowriting.markReviewed", + "when": "editorLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot" }, { "command": "cowriting.edit", @@ -154,13 +205,25 @@ "command": "cowriting.editDocument", "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", - "when": "editorLangId == markdown" + "when": "editorLangId == markdown && cowriting.isCoediting" }, { "command": "cowriting.rejectAllProposals", - "when": "editorLangId == markdown" + "when": "editorLangId == markdown && cowriting.isCoediting" }, { "command": "cowriting.proposalAcceptMenu", @@ -173,7 +236,27 @@ ], "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", "group": "navigation@9" } @@ -181,18 +264,18 @@ "editor/title/context": [ { "command": "cowriting.edit", - "when": "resourceLangId == markdown", + "when": "resourceLangId == markdown && cowriting.isCoediting", "group": "1_cowriting@1" }, { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.openReviewPreview", "when": "resourceLangId == markdown", "group": "1_cowriting@3" } ], "explorer/context": [ { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.openReviewPreview", "when": "resourceLangId == markdown", "group": "navigation@9" } @@ -200,47 +283,67 @@ "editor/context": [ { "command": "cowriting.edit", - "when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)", + "when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting", "group": "1_cowriting@1" }, { "command": "cowriting.createThread", - "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", + "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled) && cowriting.isCoediting", "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": [ { "command": "cowriting.reply", - "group": "inline", + "group": "inline@1", "when": "commentController == cowriting.threads" + }, + { + "command": "cowriting.makeThreadEdit", + "group": "inline@2", + "when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/" } ], "comments/commentThread/title": [ + { + "command": "cowriting.makeThreadEdit", + "group": "inline@1", + "when": "commentController == cowriting.threads && commentThread =~ /\\boffer\\b/" + }, { "command": "cowriting.resolveThread", "group": "inline", - "when": "commentController == cowriting.threads && commentThread =~ /^open$/" + "when": "commentController == cowriting.threads && commentThread =~ /\\bopen\\b/" }, { "command": "cowriting.reopenThread", "group": "inline", - "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" + "when": "commentController == cowriting.threads && commentThread =~ /\\bresolved\\b/" } ] }, "keybindings": [ { - "command": "cowriting.showTrackChangesPreview", + "command": "cowriting.reviewChanges", "key": "ctrl+alt+r", "mac": "cmd+alt+r", - "when": "editorLangId == markdown" + "when": "editorLangId == markdown && cowriting.isCoediting" }, { "command": "cowriting.edit", "key": "ctrl+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", "test:e2e": "node ./out/test/e2e/runTest.js", "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" }, "dependencies": { diff --git a/scripts/smoke-native-loop.mjs b/scripts/smoke-native-loop.mjs new file mode 100644 index 0000000..6eb2be5 --- /dev/null +++ b/scripts/smoke-native-loop.mjs @@ -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(); diff --git a/src/attributionController.ts b/src/attributionController.ts index c3e7066..5b28923 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -21,6 +21,7 @@ import { minimizeReplace, PendingEditRegistry } from "./pendingEdits"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; import type { AuthorSpan } from "./trackChangesModel"; +import type { CoeditingRegistry } from "./coeditingRegistry"; /** Test-facing snapshot of live attribution state for a document. */ 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 * (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 - * advance the baseline; no call-site wiring, no future driver can forget it. + * ingress, so this is the sole signal. Native-surfaces migration (INV-7/ + * 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 }>(); 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 rootDir: string | undefined, private readonly guard: VersionGuard, + private readonly registry: CoeditingRegistry, ) { this.disposables.push(this.statusItem, this.output, this.applyEmitter); this.disposables.push( vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), 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). */ loadAll(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; + if (!this.registry.isCoediting(document.uri)) return; const docPath = this.keyOf(document); const s = this.state(docPath); const artifact = this.store.load(docPath); @@ -142,6 +170,7 @@ export class AttributionController implements vscode.Disposable { private onDidChange(e: vscode.TextDocumentChangeEvent): void { if (!this.isTracked(e.document) || e.contentChanges.length === 0) return; + if (!this.registry.isCoediting(e.document.uri)) return; const docPath = this.keyOf(e.document); if (!e.document.isDirty && this.matchesDisk(e.document)) { // 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) { - // F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48) - // suppresses this for optimistic apply: the proposed text is in the buffer - // but the change stays PENDING (baseline at pre-proposal) until accept. + // F6 (INV-18, retired as a baseline advance — spec §6.4/INV-7): fire the + // machine-landing signal. F12 (INV-48) suppresses this for optimistic + // apply: the proposed text is in the buffer but the change stays PENDING + // until accept. this.applyEmitter.fire({ document }); } 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 * 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 - * F6 baseline so the now-accepted change stops reading as pending. + * optimistic apply (`landBaseline:false`) and accept marks the landing so the + * 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 { if (this.isTracked(document)) this.applyEmitter.fire({ document }); diff --git a/src/baselineStore.ts b/src/baselineStore.ts index 4980aff..b2c1a79 100644 --- a/src/baselineStore.ts +++ b/src/baselineStore.ts @@ -7,11 +7,19 @@ * 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 * 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 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 { /** the document URI (the identity the key is derived from) — for round-trip/debug. */ @@ -22,6 +30,12 @@ export interface Baseline { 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 { /** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */ constructor(private readonly storageDir: string) {} @@ -34,7 +48,8 @@ export class BaselineStore { load(key: string): Baseline | null { const p = this.baselinePath(key); 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). */ diff --git a/src/coeditingRegistry.ts b/src/coeditingRegistry.ts new file mode 100644 index 0000000..9177478 --- /dev/null +++ b/src/coeditingRegistry.ts @@ -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; + 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(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(); + } +} diff --git a/src/diffViewController.ts b/src/diffViewController.ts index ef649a2..2a7f2fd 100644 --- a/src/diffViewController.ts +++ b/src/diffViewController.ts @@ -1,9 +1,16 @@ /** - * DiffViewController — F6 baseline data layer (spec §6.4/§6.7). Owns the baseline - * lifecycle (initialize at first sight / advance at every machine landing / pin - * on demand) and serves it to F7/F10 via `getBaseline` + the additive - * `onDidChangeBaseline` event. A pure data layer: never mutates the document, - * sidecar, or attribution state (INV-19). + * DiffViewController — the baseline router (native-surfaces migration, spec + * §6.4, INV-7). Resolves each coedited document's review baseline to one of two + * modes: **head** (git-tracked — the baseline is the `HEAD` blob, re-read on + * every commit, never persisted) or **snapshot** (untracked/no-repo — captured + * 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:` * 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 * `.threads/` sidecar, only a stable doc identity + a storage home. So it has * its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from - * F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in - * VS Code's per-extension GLOBAL storage keyed by a hash of the document URI; - * untitled buffers have no durable identity, so their baseline is in-memory - * only (lost on reload) — the same degrade the storage-unavailable path uses. + * F2's workspace `isTracked`. Persistable docs (`file:`) keep their SNAPSHOT + * baseline in VS Code's per-extension GLOBAL storage keyed by a hash of the + * document URI (INV-7: head-mode baselines are storage-free — never + * 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 * as vscode from "vscode"; import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore"; +import type { GitBaselineAdapter } from "./gitBaseline"; +import type { CoeditingRegistry } from "./coeditingRegistry"; export class DiffViewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; /** Source of truth for the baseline, keyed by `document.uri.toString()`. */ private readonly baselines = new Map(); - /** F7 (additive): fires on every baseline capture (open / advance / pin) so - * the track-changes preview refreshes without polling. Carries the real - * document URI. */ + /** Which mode each established document resolved to (INV-7). */ + private readonly modes = new Map(); + /** 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 }>(); readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event; 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.onDidChangeBaselineEmitter, - vscode.commands.registerCommand("cowriting.pinDiffBaseline", () => + vscode.commands.registerCommand("cowriting.markReviewed", () => this.pinCommand(vscode.window.activeTextEditor), ), - // F6 captures a baseline for any diffable doc the moment it is first seen, - // independent of the workspace gate (so "opened" is the open-time text). - vscode.workspace.onDidOpenTextDocument((d) => this.ensureBaseline(d)), + // INV-10: a baseline is established only once its document ENTERS + // coediting; exiting keeps the stored baseline as-is (PUC-7). + 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 -------------------------------------------------------- @@ -51,7 +78,7 @@ export class DiffViewController implements vscode.Disposable { private isDiffable(document: vscode.TextDocument): boolean { 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 { return document.uri.scheme === "file"; } @@ -64,36 +91,66 @@ export class DiffViewController implements vscode.Disposable { 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 { if (!this.isDiffable(document)) return; const key = this.uriKey(document); - if (this.baselines.has(key)) return; + const head = await this.git.headFor(document.uri); + if (head) { + this.modes.set(key, "head"); + this.setBaseline(key, head.text, "head"); + return; + } + this.modes.set(key, "snapshot"); if (this.store && this.isPersistable(document)) { - try { - const stored = this.store.load(this.storageKey(key)); - if (stored) { - this.baselines.set(key, stored); - return; - } - } catch { - this.warnStorageOnce(); + 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. */ - advance(document: vscode.TextDocument): void { - if (!this.isDiffable(document)) return; - this.capture(document, "machine-landing"); + /** The mode a document's baseline resolved to, or undefined before `establish`. */ + modeOf(uriString: string): "head" | "snapshot" | undefined { + return this.modes.get(uriString); } - /** 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 { + 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 { 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"); } @@ -103,12 +160,38 @@ export class DiffViewController implements vscode.Disposable { const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason }; this.baselines.set(key, baseline); if (this.store && this.isPersistable(document)) { - try { - this.store.save(this.storageKey(key), baseline); - } catch { - this.warnStorageOnce(); - } + 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 { + 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 { + this.warnStorageOnce(); + } + } + + private fire(key: string): void { this.onDidChangeBaselineEmitter.fire({ uri: key }); } @@ -122,7 +205,7 @@ export class DiffViewController implements vscode.Disposable { private pinCommand(editor: vscode.TextEditor | undefined): void { 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; } this.pin(editor.document); diff --git a/src/editFlow.ts b/src/editFlow.ts new file mode 100644 index 0000000..5b83273 --- /dev/null +++ b/src/editFlow.ts @@ -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; +/** 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 { + 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(); + } +} diff --git a/src/editInstructionInput.ts b/src/editInstructionInput.ts deleted file mode 100644 index 12cb29c..0000000 --- a/src/editInstructionInput.ts +++ /dev/null @@ -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 { - // 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 ` - - - - - ${title} - - - -

${title}

- -
- ⌘↵ / Ctrl+↵ to send · Esc to cancel - -
- - -`; -} diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts index 691af60..379f4d4 100644 --- a/src/editorProposalController.ts +++ b/src/editorProposalController.ts @@ -17,6 +17,7 @@ import type { DiffViewController } from "./diffViewController"; import type { AttributionController } from "./attributionController"; import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel"; import { isAuthorable } from "./workspacePath"; +import type { CoeditingRegistry } from "./coeditingRegistry"; export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider { private readonly disposables: vscode.Disposable[] = []; @@ -51,13 +52,14 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL private readonly proposals: ProposalController, private readonly diffView: DiffViewController, private readonly attribution: AttributionController, + private readonly registry: CoeditingRegistry, ) { this.disposables.push( this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter, vscode.languages.registerCodeLensProvider({ language: "markdown" }, this), this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)), 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 }) => { const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri); if (ed) this.renderEditor(ed); @@ -67,6 +69,13 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL const ed = vscode.window.activeTextEditor; 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 vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", 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 { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); 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); for (const v of this.proposals.listProposals(doc)) { 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], []); }; 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 ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] }; 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[] { if (document.languageId !== "markdown") return []; + if (!this.registry.isCoediting(document.uri)) return []; 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[] = []; - for (const v of this.proposals.listProposals(document)) { - if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue; - const pos = document.positionAt(v.anchorStart); + if (applied.length >= 2) { + const top = new vscode.Range(0, 0, 0, 0); + 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); lenses.push( - new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }), - new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", 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] }), ); } return lenses; diff --git a/src/extension.ts b/src/extension.ts index 3a81496..91916c3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,10 +10,15 @@ import { BaselineStore } from "./baselineStore"; import { GlobalSidecarStore } from "./globalSidecarStore"; import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; -import { TrackChangesPreviewController } from "./trackChangesPreview"; +import { GitBaselineAdapter } from "./gitBaseline"; +import { EditFlow } from "./editFlow"; import { LiveProgressUi } from "./liveProgressUi"; 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)"; @@ -23,10 +28,19 @@ export interface CowritingApi { proposalController: ProposalController; versionGuard: VersionGuard; diffViewController: DiffViewController; - trackChangesPreviewController: TrackChangesPreviewController; + editFlow: EditFlow; sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; 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 { @@ -59,19 +73,66 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); - // --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT --- - // The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is - // the single review surface); only the baseline store 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. Baseline lives in VS - // Code's per-extension GLOBAL storage (always present), never the repo - // (INV-19). The machine-landing advance is wired below. The controller - // self-wires baseline capture on open. + // --- PUC-7 (native-surfaces migration): the INV-10 opt-in gate --- + // Every later native surface (SCM/QuickDiff, comments, preview annotations, + // edit commands, decorations) checks isCoediting before attaching. Persisted + // in workspaceState so a reload restores the set. + const coeditingRegistry = new CoeditingRegistry(context.workspaceState); + context.subscriptions.push(coeditingRegistry); + context.subscriptions.push( + 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 baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null; - const diffViewController = new DiffViewController(baselineStore); + const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry); 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 // 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 @@ -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 // warning per doc across the three co-owning controllers. const versionGuard = new VersionGuard(sidecarRouter); - const threadController = new ThreadController(sidecarRouter, root, versionGuard); - context.subscriptions.push(threadController); // --- F3: live attribution (Feature #6) --- - const attributionController = new AttributionController(sidecarRouter, root, versionGuard); + const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry); context.subscriptions.push(attributionController); - // --- F4: propose/accept (Feature #12) — constructed before the preview so F10 - // can route ✓/✗ through it --- - const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); + // --- F4: propose/accept (Feature #12) --- + const proposalController = new ProposalController( + sidecarRouter, + attributionController, + root, + versionGuard, + coeditingRegistry, + ); context.subscriptions.push(proposalController); - // --- F7/F10: the review preview is the single interactive review surface --- - // Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, - // INV-20). Constructed AFTER attribution (reads F3 spans) and proposals (routes - // F4 accept/reject from the webview ✓/✗). - const trackChangesPreviewController = new TrackChangesPreviewController( - diffViewController, - context.extensionUri, - attributionController, - proposalController, - liveProgressUi, - ); - context.subscriptions.push(trackChangesPreviewController); + // --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the + // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped + // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut + // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the thread controller, + // which consumes it too (Task 6: the comment-loop's "make this edit" offer + // calls runEditAndPropose). --- + const editFlow = new EditFlow(proposalController); + context.subscriptions.push(editFlow); + + // --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→ + // offer→proposal loop. Consumes editFlow (offer → pending proposals) and + // 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, // 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); // #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 - // batched F4 seam + reports applied-vs-skipped. + // (also reachable from the top-of-file "Keep all (N)" CodeLens, Task 8 PUC-2). + // Reuses the batched F4 seam + reports applied-vs-skipped, routing directly to + // ProposalController (native-surfaces migration: no preview-controller + // indirection). context.subscriptions.push( vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { 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."); 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( vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => { 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."); 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 --- - // The seam's single machine-landing signal advances the F6 baseline (INV-18); - // the seam can now fire on out-of-folder files too, so wire it unconditionally. - context.subscriptions.push( - attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)), - ); + // NOTE (native-surfaces migration, INV-7/INV-18 retirement): the shipped + // machine-landing baseline advance (#48) — wiring + // attributionController.onDidApplyAgentEdit → diffViewController.advance — + // was removed here. A landed Claude edit now stays a visible change-since- + // 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 // 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 - // claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4, - // INV-10); the applyAgentEdit seam (INV-9) now fires only on accept. + // Task 6 (D19): the comments-first ask replaces the old inline turn + // plumbing here — the ask now IS a comment. threadController.askClaude() + // 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( vscode.commands.registerCommand("cowriting.editSelection", async () => { - const editor = vscode.window.activeTextEditor; - // 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). - const reason = selectionRejection({ - hasEditor: !!editor, - selectionEmpty: editor?.selection.isEmpty ?? true, - scheme: editor?.document.uri.scheme ?? "", - }); - if (reason) { - void vscode.window.showWarningMessage(reason); + await threadController.askClaude(); + }), + ); + + // Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument` + // used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompted via + // `askEditInstruction` — a rejecting stub after Task 6 sunset the input webview + // (spec §6.10; both `askClaude`/`askEditInstruction` were deleted from EditFlow in + // 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 + // reject fired before EditFlow.askClaude's own try/catch and was `void`'d, so no + // 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; } - if (!editor) return; // unreachable once reason is null, but narrows the type - const document = editor.document; - 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}`); + if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) { + await vscode.window.showTextDocument(doc); } + await threadController.askClaude(); }), ); // The single user-facing "Ask Claude to Edit" gesture (one command, one // keybinding, one menu entry). It routes to the selection flow (editSelection) // or the whole-document flow (editDocument) by selection/context — see - // routeEdit. The two underlying commands stay registered for the host E2E - // harness and the internal seams, but are hidden from the palette. + // routeEdit. Both underlying commands now end at ThreadController.askClaude() + // (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( vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => { 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. + // 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) => { - 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); attributionController.loadAll(doc); proposalController.renderAll(doc); @@ -352,16 +418,78 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef vscode.workspace.textDocuments.forEach(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("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("annotations", true); + await config.update("annotations", !current, vscode.ConfigurationTarget.Global); + await vscode.commands.executeCommand("markdown.preview.refresh"); + }), + ); + return { threadController, attributionController, proposalController, versionGuard, diffViewController, - trackChangesPreviewController, + editFlow, sidecarRouter, liveProgressUi, editorProposalController, + coeditingRegistry, + scmSurfaceController, + previewAnnotationHost, + extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost), }; } diff --git a/src/gitBaseline.ts b/src/gitBaseline.ts new file mode 100644 index 0000000..3997b3b --- /dev/null +++ b/src/gitBaseline.ts @@ -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 }; + show(ref: string, path: string): Promise; + status(): Promise; +} +interface GitAPI { + repositories: GitRepository[]; + getRepository(uri: vscode.Uri): GitRepository | null; + openRepository(root: vscode.Uri): Promise; + onDidOpenRepository: vscode.Event; +} + +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(); + private readonly lastCommit = new Map(); // repo root → HEAD commit + private readonly emitter = new vscode.EventEmitter(); + readonly onDidChangeHead = this.emitter.event; + private readonly disposables: vscode.Disposable[] = [this.emitter]; + + private async gitApi(): Promise { + 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 { + 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 { + 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 | 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(); + } +} diff --git a/src/previewAnnotations.ts b/src/previewAnnotations.ts new file mode 100644 index 0000000..35ccfb2 --- /dev/null +++ b/src/previewAnnotations.ts @@ -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 ``. + * + * 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- (`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 `
`
+ * (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 `
${escapeMermaidSource(entry?.source ?? code)}
${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, "&") + .replace(//g, ">") + .replace(/\n+$/, ""); +} diff --git a/src/proposalController.ts b/src/proposalController.ts index af90a30..30372e7 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -19,6 +19,7 @@ import type { AttributionController } from "./attributionController"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; import { wordEditHunks, type ProposalView } from "./trackChangesModel"; +import type { CoeditingRegistry } from "./coeditingRegistry"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedProposal { @@ -56,11 +57,36 @@ export class ProposalController implements vscode.Disposable { private readonly attribution: AttributionController, private readonly rootDir: string | undefined, private readonly guard: VersionGuard, + private readonly registry: CoeditingRegistry, ) { this.disposables.push(this.statusItem); this.disposables.push(this.onDidChangeProposalsEmitter); this.disposables.push( 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 - * already in the buffer, so this only advances the F6 baseline (machine-landing, - * via `attribution.signalLanded`) and clears the proposal. No re-application. + * already in the buffer, so this only marks the attribution landing (via + * `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 { const hit = this.byId(docPath, proposalId); @@ -454,7 +485,13 @@ export class ProposalController implements vscode.Disposable { // ---- 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 { if (!this.isTracked(document)) return; const docPath = this.keyOf(document); @@ -462,6 +499,7 @@ export class ProposalController implements vscode.Disposable { state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); state.live.clear(); state.unresolved.clear(); + if (!this.registry.isCoediting(document.uri)) return; const text = document.getText(); for (const proposal of state.artifact.proposals) { const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; diff --git a/src/scmSurface.ts b/src/scmSurface.ts new file mode 100644 index 0000000..9409fee --- /dev/null +++ b/src/scmSurface.ts @@ -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(); + private readonly baselineEmitter = new vscode.EventEmitter(); + private readonly recountTimers = new Map>(); + + 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 { + 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(); + } +} diff --git a/src/threadController.ts b/src/threadController.ts index d5b7445..ef85faf 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -15,6 +15,10 @@ import { gitUserEmail } from "./identity"; import { addThread, appendMessage, setStatus } from "./threadModel"; import type { VersionGuard } from "./versionGuard"; 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. */ export interface RenderedThread { @@ -22,8 +26,22 @@ export interface RenderedThread { status: "open" | "resolved"; orphaned: boolean; 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; + interface DocState { docPath: string; uri: vscode.Uri; @@ -34,25 +52,45 @@ interface DocState { live: Map; /** thread id -> orphaned flag for the current render. */ orphaned: Map; + /** 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; } export class ThreadController implements vscode.Disposable { private readonly controller: vscode.CommentController; private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath + /** thread -> the pending machine "offer" (D8): the ask it answered, ready to become an edit. */ + private readonly offers = new WeakMap(); + /** 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( private readonly store: SidecarRouter, private readonly rootDir: string | undefined, 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.commentingRangeProvider = { - provideCommentingRanges: (document) => { - if (!isAuthorable(document.uri.scheme)) return []; - return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; - }, - }; + this.controller.commentingRangeProvider = this.rangeProvider; this.disposables.push(this.controller); this.disposables.push( @@ -64,8 +102,30 @@ export class ThreadController implements vscode.Disposable { vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) => 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.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(), live: new Map(), orphaned: new Map(), + offerToken: new Map(), }; this.docs.set(docPath, 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 { + 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 ------------------------------------------------- async createThreadOnSelection(firstBody = "New thread"): Promise { const editor = vscode.window.activeTextEditor; 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; const document = editor.document; const state = this.ensureState(document); @@ -119,22 +206,175 @@ export class ThreadController implements vscode.Disposable { end: document.offsetAt(editor.selection.end), }; 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.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; } // ---- PUC-2: reply / resolve ----------------------------------------------------- reply(r: vscode.CommentReply): void { - const threadId = this.threadIdOf(r.thread); - const state = this.stateOfThread(r.thread); - if (!threadId || !state) return; + const vsThread = r.thread; + if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) 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; - 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.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 { + 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 { + const vsThread = "thread" in arg ? arg.thread : arg; + await this.runMakeEdit(vsThread); + } + + private async runMakeEdit(vsThread: vscode.CommentThread): Promise { + 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 { + 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 { @@ -145,13 +385,14 @@ export class ThreadController implements vscode.Disposable { setStatus(state.artifact, threadId, status); this.persist(state); 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 ----------------------------------------- /** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */ renderAll(document: vscode.TextDocument): void { + if (!this.registry.isCoediting(document.uri)) return; const docPath = this.keyOf(document); const state = this.ensureState(document); // fresh artifact from disk (reload / external change) @@ -160,6 +401,7 @@ export class ThreadController implements vscode.Disposable { state.vsThreads.clear(); state.live.clear(); state.orphaned.clear(); + state.offerToken.clear(); const text = document.getText(); for (const thread of state.artifact.threads) { const fp = state.artifact.anchors[thread.anchorId]?.fingerprint; @@ -225,7 +467,7 @@ export class ThreadController implements vscode.Disposable { threadId: string, offsets: OffsetRange, orphaned: boolean, - ): void { + ): vscode.CommentThread { const thread = state.artifact.threads.find((t) => t.id === threadId)!; const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); 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)), ); 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.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; state.vsThreads.set(threadId, vsThread); state.live.set(threadId, offsets); 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 { @@ -269,6 +533,18 @@ export class ThreadController implements vscode.Disposable { 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 -------------------------------------------------------- getRendered(docPath: string): RenderedThread[] { @@ -283,6 +559,7 @@ export class ThreadController implements vscode.Disposable { status: t.status, orphaned: !!state.orphaned.get(id), range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 }, + contextValue: typeof vsThread.contextValue === "string" ? vsThread.contextValue : "", }); } return out; diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index c349298..9b9ab25 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -193,6 +193,30 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[] 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). */ export interface EditHunk { /** 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); }; -/** 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 open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/); if (!open || open[3]?.toLowerCase() !== "mermaid") return null; @@ -455,8 +484,12 @@ function mermaidFenceBody(raw: string): string | null { 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 = '
' + 'added' + 'changed' + @@ -548,11 +581,14 @@ export interface RenderOptions { /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */ render?: (src: string) => string; /** - * #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes - * since that pin, the on-render is fully clean — no authorship coloring — so a - * pin reads as "this is my clean starting point". Distinct from a baseline - * advanced by a machine-landing (accept), which keeps its authorship coloring - * (F10 INV-33). Only consulted by `renderReview`. + * #48: the baseline was just PINNED (`reason === "pinned"` — "Mark Changes as + * Reviewed" in snapshot mode). With zero changes since that pin, the + * on-render is fully clean — no authorship coloring — so a pin reads as + * "this is my clean starting point". Distinct from an ordinary accepted + * 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; } @@ -631,15 +667,33 @@ export interface AuthorSpan { 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 -// them through as plain text). Paired open/close per author. -const SENT = { +// them through as plain text). Paired open/close per tag. +const SENT: Record = { claude: { open: "", close: "" }, human: { open: "", close: "" }, -} as const; + del: { open: "", close: "" }, +}; +const SENT_TAGS = Object.keys(SENT) as SentinelTag[]; 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 @@ -685,8 +739,8 @@ function blockContentStart(raw: string): number { return 0; } -/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */ -function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string { +/** Inject paired sentinels into a block's raw text for the (tagged) spans clipped to it. */ +export function injectSentinels(raw: string, blockStart: number, spans: TaggedSpan[]): string { const inserts: { at: number; marker: string }[] = []; // Open sentinels must not precede block-level markdown markers (heading/list/ // 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 hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart)); if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing - inserts.push({ at: lo, marker: SENT[s.author].open }); - inserts.push({ at: hi, marker: SENT[s.author].close }); + inserts.push({ at: lo, marker: SENT[s.tag].open }); + inserts.push({ at: hi, marker: SENT[s.tag].close }); } // 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 @@ -708,34 +762,34 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): return out; } -const SENTINEL_OF: Record = { - [SENT.claude.open]: { author: "claude", open: true }, - [SENT.claude.close]: { author: "claude", open: false }, - [SENT.human.open]: { author: "human", open: true }, - [SENT.human.close]: { author: "human", open: false }, -}; -const ALL_SENTINELS = new RegExp( - `[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`, - "g", -); +const SENTINEL_OF: Record = {}; +for (const tag of SENT_TAGS) { + SENTINEL_OF[SENT[tag].open] = { tag, open: true }; + SENTINEL_OF[SENT[tag].close] = { tag, open: false }; +} +const ALL_SENTINELS = new RegExp(`[${SENT_TAGS.map((t) => SENT[t].open + SENT[t].close).join("")}]`, "g"); /** - * #33: token-aware replacement of the rendered author sentinels with `cw-ins-*` - * spans. A naive global string-replace (the old approach) could emit a span that + * #33: token-aware replacement of the rendered sentinels with `cw-*` spans. A + * naive global string-replace (the old approach) could emit a span that * CROSSES an element boundary — `bold` — when a * 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 `` and + * emits the span only around TEXT runs, CLOSING it before any `` and * REOPENING it after, so a span is always well-nested within the inline elements * (one `` 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[] = []; - 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 is currently open in `out` + const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`); const openSpan = () => { if (current && !spanOpen) { - out.push(``); + out.push(``); spanOpen = true; } }; @@ -749,7 +803,7 @@ function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string { const ch = html[i]; const sentinel = SENTINEL_OF[ch]; 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 { closeSpan(); current = null; @@ -786,7 +840,11 @@ export function colorByAuthor( kind: "by" | "ins" = "by", ): string { 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); } diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts deleted file mode 100644 index d7b0ffc..0000000 --- a/src/trackChangesPreview.ts +++ /dev/null @@ -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; -/** 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(); - private readonly lastModel = new Map(); - private readonly debounces = new Map(); - /** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */ - private readonly mode = new Map(); - /** 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 = 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 { - 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 { - 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 { - // 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 { - 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