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
`
+ * (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