Files
vscode-cowriting-plugin-con…/plans/2026-07-01-native-surfaces-migration.md
T
2026-07-02 05:32:10 -07:00

1253 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Native Surfaces Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Evolve the shipped cowriting extension in place onto VS Code's native review surfaces — opt-in coediting, git-HEAD/snapshot baseline with QuickDiff + native diff, comments-first asking with a machine reply/offer loop, and annotations in the built-in Markdown preview — sunsetting the two bespoke webviews.
**Architecture:** Per `specs/coauthoring-native-surfaces.md` **v0.2.1** (graduated; D17 evolve-in-place, §6.10). The 21 pure cores (attribution, anchoring, diff/hunk engine, sidecar, turn progress) are retained untouched; thin controllers are rewired: a `CoeditingRegistry` gates every surface (INV-10), the baseline follows the file's home (git `HEAD` / snapshot, INV-7), a `QuickDiffProvider` + `cowriting-baseline:` content provider drive gutter bars and the native diff, the Comments API becomes the ask surface (D19/D10), and a `markdown-it` contribution annotates the built-in preview (D3/D21). Each bespoke surface sunsets only after its native replacement is green (§7.1 rung 4 order).
**Tech Stack:** TypeScript, VS Code stable APIs only (SCM/QuickDiff, `vscode.diff`, Comments, CodeLens, decorations, `markdown.markdownItPlugins`), built-in Git extension API v1, `@cline/sdk` (ESM, dynamic import, never bundled), esbuild, vitest (unit), `@vscode/test-electron` (host E2E — **no Playwright**, §6.8).
## Global Constraints
- **INV-3 stable-API boundary:** only the surfaces named above; any new custom rendering needs a recorded justification.
- **INV-5:** nothing settles until Keep; Reject restores the retained original **exactly**.
- **INV-6:** new pure logic goes in vscode-free modules with vitest suites; controllers stay thin.
- **INV-7:** baseline = git `HEAD` where the file is tracked (advances on commit only), else a snapshot (set on enter, re-pinned by "Mark Changes as Reviewed"); baseline is never committed. **The shipped machine-landing baseline advance (INV-18) is retired** — kept machine text stays a visible change until commit/pin.
- **INV-10:** every surface gates on the CoeditingRegistry; non-entered documents show nothing.
- **Spike-verified seams (spec §6.4, v0.2.1):** re-assign `commentingRangeProvider` whenever the coediting set changes; focus the comment input via `workbench.action.addComment`; the thread-contextValue menu `when` key is **`commentThread`**; key HEAD re-reads off `state.HEAD.commit`; nested repos may need `openRepository()`.
- **Command copy (spec §5):** "✦ Coedit this Document with Claude", "Stop editing with Claude", "Review Changes", "Ask Claude", "Toggle Annotations", "Mark Changes as Reviewed" (snapshot mode only, D14). Status bar: `✦ Coediting · N changes`.
- **`@cline/sdk`** stays `external` in `esbuild.mjs` and is loaded via dynamic `import()`; spawned with `CLAUDE_CODE_AUTO_CONNECT_IDE=0` + `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1` (#59).
- **Sunset discipline (§6.10):** `trackChangesPreview.ts` and `editInstructionInput.ts` are deleted only in their sunset task, after the native replacement's tests are green.
- Verification loop for every task: `npm run typecheck && npm run test`, and where the task touches controllers `npm run pretest:e2e && npm run test:e2e`.
- Commits cite the spec section and carry `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
## File Structure (end state)
```
src/
coeditingRegistry.ts NEW — the INV-10 opt-in gate (thin, persisted set + context keys)
gitBaseline.ts NEW — built-in Git-extension adapter (HEAD text, commit watch)
scmSurface.ts NEW — SourceControl "Cowriting": QuickDiff, cowriting-baseline: provider,
Review Changes (native diff), status bar, Mark Changes as Reviewed
editFlow.ts NEW — runEditAndPropose + editDocument/editSelection turn plumbing
(extracted from trackChangesPreview.ts before its sunset)
previewAnnotations.ts NEW — pure markdown-it plugin: sentinel-injected authorship + change marks
diffViewController.ts MOD — baseline modes (head|snapshot), reasons (entered|pinned|head),
enter-time capture, machine-landing advance retired
threadController.ts MOD — registry gating + provider re-assign; Ask Claude; comment→turn→
reply+offer loop (D19/D10/D8)
editorProposalController.ts MOD — registry gating; ✓ Keep / ✗ Reject + Keep all (N) lens copy
attributionController.ts MOD — registry gating (typing tracked only while coediting)
proposalController.ts MOD — registry gating; finalize no longer advances the baseline
extension.ts MOD — construction order, gating wires, extendMarkdownIt export
trackChangesPreview.ts DEL (Task 8)
editInstructionInput.ts DEL (Task 6)
media/preview-annotations.css NEW — preview stylesheet (markdown.previewStyles)
test/ — new vitest suites per new pure module
test/e2e/suite/ — coediting.test.ts, baselineRouter.test.ts, commentLoop.test.ts,
previewAnnotations.test.ts (+ existing suites updated)
```
---
### Task 1: CoeditingRegistry — the INV-10 opt-in gate
**Files:**
- Create: `src/coeditingRegistry.ts`
- Test: `test/coeditingRegistry.test.ts` (pure set logic), `test/e2e/suite/coediting.test.ts` (context keys + enter/exit; extended in later tasks)
- Modify: `src/extension.ts` (construct + export), `package.json` (commands + menus)
**Interfaces:**
- Consumes: `vscode.Memento` (`context.workspaceState`), `setContext`.
- Produces (later tasks rely on these exact names):
```typescript
export class CoeditingRegistry implements vscode.Disposable {
constructor(state: vscode.Memento);
enter(uri: vscode.Uri): void;
exit(uri: vscode.Uri): void;
isCoediting(uri: vscode.Uri): boolean;
list(): string[]; // uri strings
readonly onDidChange: vscode.Event<{ uri: string; coediting: boolean }>;
/** Re-derives cowriting.isCoediting / cowriting.baselineMode for the active editor. */
syncContext(editor: vscode.TextEditor | undefined): void;
dispose(): void;
}
```
Context keys: `cowriting.isCoediting` (boolean), commands `cowriting.coeditDocument`, `cowriting.stopCoediting`.
- [ ] **Step 1: Write the failing unit test**
```typescript
// test/coeditingRegistry.test.ts
import { describe, expect, it, vi } from "vitest";
// The pure set logic lives in a vscode-free helper the class wraps, so unit-test the class
// with a Memento stub (vitest runs vscode-free; the module imports vscode only for types +
// EventEmitter — mock it).
vi.mock("vscode", () => ({
EventEmitter: class {
private handlers: Array<(e: unknown) => void> = [];
event = (h: (e: unknown) => void) => { this.handlers.push(h); return { dispose() {} }; };
fire(e: unknown) { for (const h of this.handlers) h(e); }
dispose() {}
},
Uri: { parse: (s: string) => ({ toString: () => s }) },
commands: { executeCommand: vi.fn() },
window: { activeTextEditor: undefined },
}));
import { CoeditingRegistry } from "../src/coeditingRegistry";
function memento(): { store: Map<string, unknown> } & { get: any; update: any } {
const store = new Map<string, unknown>();
return {
store,
get: (k: string, dflt?: unknown) => (store.has(k) ? store.get(k) : dflt),
update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); },
} as any;
}
describe("CoeditingRegistry", () => {
it("enter/exit flips membership and fires onDidChange", () => {
const reg = new CoeditingRegistry(memento() as any);
const events: Array<{ uri: string; coediting: boolean }> = [];
reg.onDidChange((e) => events.push(e as any));
const uri = { toString: () => "file:///a.md" } as any;
expect(reg.isCoediting(uri)).toBe(false);
reg.enter(uri);
expect(reg.isCoediting(uri)).toBe(true);
reg.exit(uri);
expect(reg.isCoediting(uri)).toBe(false);
expect(events).toEqual([
{ uri: "file:///a.md", coediting: true },
{ uri: "file:///a.md", coediting: false },
]);
});
it("persists the set across construction (reload survival)", () => {
const m = memento();
const reg1 = new CoeditingRegistry(m as any);
reg1.enter({ toString: () => "file:///a.md" } as any);
const reg2 = new CoeditingRegistry(m as any);
expect(reg2.isCoediting({ toString: () => "file:///a.md" } as any)).toBe(true);
expect(reg2.list()).toEqual(["file:///a.md"]);
});
it("enter is idempotent (no duplicate events)", () => {
const reg = new CoeditingRegistry(memento() as any);
const events: unknown[] = [];
reg.onDidChange((e) => events.push(e));
const uri = { toString: () => "file:///a.md" } as any;
reg.enter(uri);
reg.enter(uri);
expect(events).toHaveLength(1);
});
});
```
- [ ] **Step 2: Run it to verify it fails**
Run: `npx vitest run test/coeditingRegistry.test.ts`
Expected: FAIL — `Cannot find module '../src/coeditingRegistry'`.
- [ ] **Step 3: Implement `src/coeditingRegistry.ts`**
```typescript
/**
* CoeditingRegistry — the INV-10 activation gate (spec §6.4). Holds the set of
* documents the writer has explicitly entered into coediting; every surface
* (SCM/QuickDiff, comments, preview annotations, edit commands, decorations)
* checks membership before attaching. Persisted in workspaceState so a reload
* restores the set (the durable sidecar carries the artifacts; this carries
* only the mode).
*/
import * as vscode from "vscode";
const STATE_KEY = "cowriting.coeditingSet";
export class CoeditingRegistry implements vscode.Disposable {
private readonly set: Set<string>;
private readonly emitter = new vscode.EventEmitter<{ uri: string; coediting: boolean }>();
readonly onDidChange = this.emitter.event;
/** Set by the SCM surface (Task 3) so syncContext can expose the baseline mode. */
baselineModeOf: ((uri: vscode.Uri) => "head" | "snapshot" | undefined) | undefined;
constructor(private readonly state: vscode.Memento) {
this.set = new Set(state.get<string[]>(STATE_KEY, []));
}
enter(uri: vscode.Uri): void {
const key = uri.toString();
if (this.set.has(key)) return;
this.set.add(key);
void this.state.update(STATE_KEY, [...this.set]);
this.emitter.fire({ uri: key, coediting: true });
}
exit(uri: vscode.Uri): void {
const key = uri.toString();
if (!this.set.delete(key)) return;
void this.state.update(STATE_KEY, [...this.set]);
this.emitter.fire({ uri: key, coediting: false });
}
isCoediting(uri: vscode.Uri): boolean {
return this.set.has(uri.toString());
}
list(): string[] {
return [...this.set];
}
syncContext(editor: vscode.TextEditor | undefined): void {
const coediting = !!editor && this.isCoediting(editor.document.uri);
void vscode.commands.executeCommand("setContext", "cowriting.isCoediting", coediting);
const mode = coediting && editor ? this.baselineModeOf?.(editor.document.uri) : undefined;
void vscode.commands.executeCommand("setContext", "cowriting.baselineMode", mode ?? "");
}
dispose(): void {
this.emitter.dispose();
}
}
```
- [ ] **Step 4: Run the unit test — PASS.** `npx vitest run test/coeditingRegistry.test.ts`
- [ ] **Step 5: Wire into `extension.ts`**
In `src/extension.ts`, immediately after the `LiveProgressUi` block (line ~41), construct the registry and the enter/stop commands; add `coeditingRegistry: CoeditingRegistry` to `CowritingApi` and to the returned object:
```typescript
import { CoeditingRegistry } from "./coeditingRegistry";
// …
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);
```
- [ ] **Step 6: package.json — commands + context/title menus**
Add to `contributes.commands`:
```json
{ "command": "cowriting.coeditDocument", "title": "✦ Coedit this Document with Claude", "category": "Cowriting" },
{ "command": "cowriting.stopCoediting", "title": "Stop editing with Claude", "category": "Cowriting" }
```
Add to `contributes.menus["editor/context"]`:
```json
{ "command": "cowriting.coeditDocument", "when": "editorLangId == markdown && !cowriting.isCoediting", "group": "1_cowriting@1" },
{ "command": "cowriting.stopCoediting", "when": "editorLangId == markdown && cowriting.isCoediting", "group": "1_cowriting@9" }
```
- [ ] **Step 7: Host E2E — enter/exit round-trip**
```typescript
// test/e2e/suite/coediting.test.ts
import * as assert from "node:assert";
import * as vscode from "vscode";
async function api() {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
return (await ext.activate()) as import("../../../src/extension").CowritingApi;
}
suite("coediting registry (PUC-7)", () => {
test("enter → isCoediting true; exit → false; persists in list()", async () => {
const { coeditingRegistry } = await api();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# t\n\nbody\n" });
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), true);
assert.ok(coeditingRegistry.list().includes(doc.uri.toString()));
await vscode.commands.executeCommand("cowriting.stopCoediting");
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false);
});
});
```
(Use the extension id from package.json `publisher.name`; check `runTest.js` fixtures for the exact id used by existing suites and mirror it.)
- [ ] **Step 8: Verify + commit**
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
Expected: all green (new suites included).
```bash
git add src/coeditingRegistry.ts test/coeditingRegistry.test.ts test/e2e/suite/coediting.test.ts src/extension.ts package.json
git commit -m "feat: CoeditingRegistry opt-in gate (INV-10, spec §6.4) — enter/stop commands + context keys"
```
---
### Task 2: Baseline router — git HEAD + snapshot (INV-7), machine-landing advance retired
**Files:**
- Create: `src/gitBaseline.ts`
- Modify: `src/diffViewController.ts`, `src/baselineStore.ts` (reason vocabulary), `src/extension.ts` (remove the `onDidApplyAgentEdit → advance` wire), `src/proposalController.ts` (finalize no longer signals a baseline advance)
- Test: `test/e2e/suite/baselineRouter.test.ts`; update `test/e2e/suite/diffView.test.ts` expectations
**Interfaces:**
- Produces:
```typescript
// src/gitBaseline.ts
export interface HeadBaseline { text: string; commit: string }
export class GitBaselineAdapter implements vscode.Disposable {
/** null until the git extension is available; resolves lazily. */
async headFor(uri: vscode.Uri): Promise<HeadBaseline | null>; // null = untracked/no repo/no HEAD blob
/** Fires when ANY watched repo's HEAD commit changes (debounced, keyed off state.HEAD.commit). */
readonly onDidChangeHead: vscode.Event<void>;
dispose(): void;
}
```
- `DiffViewController` gains/changes (consumed by Tasks 34 and the preview):
```typescript
type BaselineReason = "entered" | "pinned" | "head"; // replaces opened|machine-landing|pinned
modeOf(uriString: string): "head" | "snapshot" | undefined;
async establish(document: vscode.TextDocument): Promise<void>; // called by registry.enter wiring
pin(document: vscode.TextDocument): void; // snapshot mode only (D14)
// ensureBaseline(open-time capture) and advance(machine-landing) are REMOVED.
```
- [ ] **Step 1: Write `src/gitBaseline.ts`**
The Git extension API is typed inline (no dependency on git.d.ts). Key seams are spike-proven (spec §6.4 v0.2.1):
```typescript
/**
* 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().
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
interface GitRepository {
rootUri: vscode.Uri;
state: { HEAD?: { commit?: string }; onDidChange: vscode.Event<void> };
show(ref: string, path: string): Promise<string>;
}
interface GitAPI {
repositories: GitRepository[];
getRepository(uri: vscode.Uri): GitRepository | null;
openRepository(root: vscode.Uri): Promise<GitRepository | null>;
onDidOpenRepository: vscode.Event<GitRepository>;
}
export interface HeadBaseline { text: string; commit: string }
export class GitBaselineAdapter implements vscode.Disposable {
private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable
private readonly hooked = new WeakSet<GitRepository>();
private readonly lastCommit = new Map<string, string>(); // repo root → HEAD commit
private readonly emitter = new vscode.EventEmitter<void>();
readonly onDidChangeHead = this.emitter.event;
private readonly disposables: vscode.Disposable[] = [this.emitter];
private async gitApi(): Promise<GitAPI | null> {
if (this.api !== undefined) return this.api;
try {
const ext = vscode.extensions.getExtension("vscode.git");
if (!ext) return (this.api = null);
const exports = ext.isActive ? ext.exports : await ext.activate();
this.api = exports.getAPI(1) as GitAPI;
this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r)));
for (const r of this.api.repositories) this.hook(r);
} catch {
this.api = null;
}
return this.api;
}
/** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */
async headFor(uri: vscode.Uri): Promise<HeadBaseline | null> {
if (uri.scheme !== "file") return null;
const git = await this.gitApi();
if (!git) return null;
let repo = git.getRepository(uri);
if (!repo) repo = await this.discoverNested(git, uri);
if (!repo) return null;
this.hook(repo);
try {
const text = await repo.show("HEAD", uri.fsPath);
return { text, commit: repo.state.HEAD?.commit ?? "" };
} catch {
return null; // untracked, ignored, or no commit yet → snapshot mode
}
}
/** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */
private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise<GitRepository | null> {
let dir = path.dirname(uri.fsPath);
for (let i = 0; i < 32; i++) {
if (fs.existsSync(path.join(dir, ".git"))) {
await git.openRepository(vscode.Uri.file(dir));
return git.getRepository(uri);
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */
private hook(repo: GitRepository): void {
if (this.hooked.has(repo)) return;
this.hooked.add(repo);
this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? "");
this.disposables.push(
repo.state.onDidChange(() => {
const key = repo.rootUri.toString();
const commit = repo.state.HEAD?.commit ?? "";
if (this.lastCommit.get(key) !== commit) {
this.lastCommit.set(key, commit);
this.emitter.fire();
}
}),
);
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}
```
- [ ] **Step 2: Rework `src/diffViewController.ts`**
Changes (keep the class name and file — evolve in place):
1. `BaselineReason` in `src/baselineStore.ts` becomes `"entered" | "pinned" | "head"`. On `store.load`, migrate legacy reasons: `"opened"`/`"machine-landing"` → `"entered"` (one `normalizeReason` helper in `baselineStore.ts`).
2. Constructor becomes `constructor(store: BaselineStore | null, git: GitBaselineAdapter, registry: CoeditingRegistry)`. Remove the `onDidOpenTextDocument → ensureBaseline` self-wiring and the open-time loop; instead:
```typescript
this.disposables.push(
registry.onDidChange(({ uri, coediting }) => {
if (!coediting) return; // exit keeps the stored baseline (PUC-7)
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) void this.establish(doc);
}),
git.onDidChangeHead(() => void this.refreshHeadBaselines()),
);
for (const key of registry.list()) {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (doc) void this.establish(doc);
}
```
3. New `establish` (replaces `ensureBaseline`) resolves the mode:
```typescript
private readonly modes = new Map<string, "head" | "snapshot">();
async establish(document: vscode.TextDocument): Promise<void> {
if (!this.isDiffable(document)) return;
const key = this.uriKey(document);
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)) {
const stored = this.tryLoad(key); // existing load path, reasons normalized
if (stored) { this.baselines.set(key, stored); this.fire(key); return; }
}
this.capture(document, "entered");
}
modeOf(uriString: string): "head" | "snapshot" | undefined {
return this.modes.get(uriString);
}
private async refreshHeadBaselines(): Promise<void> {
for (const [key, mode] of this.modes) {
if (mode !== "head") continue;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (!doc) continue;
const head = await this.git.headFor(doc.uri);
if (head && head.text !== this.baselines.get(key)?.text) {
this.setBaseline(key, head.text, "head"); // commit advanced the baseline (D13)
}
}
}
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.onDidChangeBaselineEmitter.fire({ uri: key });
}
```
4. `pin()` guards on mode (D14): `if (this.modes.get(key) !== "snapshot") { warn "git-tracked — commit to advance the baseline"; return; }`. The command id `cowriting.pinDiffBaseline` is **renamed** `cowriting.markReviewed` (title "Mark Changes as Reviewed"); update `package.json` commands/menus and every test referencing `pinDiffBaseline`.
5. **Delete `advance()`** and the `#48`-era `"machine-landing"` branch; in `src/extension.ts` delete the wire `attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document))` (lines ~154156). In `src/proposalController.ts`, `finalizeInPlace`'s call to `attribution.signalLanded(...)` must no longer trigger any baseline change — keep `signalLanded` (it still marks landing for attribution) but confirm nothing else subscribes for baseline purposes. Kept text now remains a change-since-baseline (spec INV-7 note, D21).
6. `getBaseline`/`baselineFilePath` keep their signatures (preview + F12 rely on them). The `reason === "pinned"` → clean rendering behavior (#48/INV-33) is preserved by keeping the `"pinned"` reason.
- [ ] **Step 3: Update unit expectations**
`test/` suites referencing `"opened"`/`"machine-landing"` reasons or `pinDiffBaseline` get updated to the new vocabulary (`git grep -l 'machine-landing\|pinDiffBaseline\|"opened"' test/ src/`). The pin→clean tests keep passing (reason `"pinned"` unchanged).
- [ ] **Step 4: Host E2E — both baseline modes**
```typescript
// test/e2e/suite/baselineRouter.test.ts
import * as assert from "node:assert";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as vscode from "vscode";
suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
test("snapshot mode: enter captures; markReviewed re-pins clean", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "one\n" });
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot");
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n");
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n"));
await vscode.commands.executeCommand("cowriting.markReviewed");
const b = api.diffViewController.getBaseline(doc.uri.toString());
assert.strictEqual(b?.text, "one\ntwo\n");
assert.strictEqual(b?.reason, "pinned");
});
test("head mode: baseline = HEAD; commit advances it", async function () {
this.timeout(30000);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-git-"));
const git = (...a: string[]) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" });
git("init", "-q");
fs.writeFileSync(path.join(dir, "doc.md"), "committed\n");
git("add", "doc.md");
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1");
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(dir, "doc.md")));
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settleUntil(() => api.diffViewController.modeOf(doc.uri.toString()) === "head", 10000);
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "committed\n");
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "uncommitted\n"));
await doc.save();
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-aqm", "c2");
await settleUntil(
() => api.diffViewController.getBaseline(doc.uri.toString())?.text === "committed\nuncommitted\n",
15000,
);
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head");
});
});
// activateApi()/settle()/settleUntil() go in a shared test/e2e/suite/helpers.ts:
// activateApi = activate + return CowritingApi (mirror the pattern in the existing suites)
// settle = new Promise(r => setTimeout(r, 150))
// settleUntil = poll a predicate every 100ms until true or timeout (assert on timeout)
```
- [ ] **Step 5: Verify + commit**
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
Expected: green, including both new baselineRouter tests (the head-mode one exercises the real vscode.git extension in the test host).
```bash
git add src/gitBaseline.ts src/diffViewController.ts src/baselineStore.ts src/proposalController.ts src/extension.ts package.json test/
git commit -m "feat: baseline router — git HEAD + snapshot per INV-7/D13/D14; retire machine-landing advance (spec §6.4)"
```
---
### Task 3: Native diff surface — cowriting-baseline:, QuickDiff, Review Changes, status bar
**Files:**
- Create: `src/scmSurface.ts`
- Test: extend `test/e2e/suite/baselineRouter.test.ts` + new asserts in `coediting.test.ts`
- Modify: `src/extension.ts`, `package.json` (Review Changes / Mark Reviewed title-bar menus, status-bar-less commands)
**Interfaces:**
- Consumes: `DiffViewController.getBaseline/modeOf/onDidChangeBaseline`, `CoeditingRegistry.isCoediting/onDidChange`.
- Produces:
```typescript
export const BASELINE_SCHEME = "cowriting-baseline";
export function baselineUriFor(docUri: vscode.Uri): vscode.Uri; // query = encodeURIComponent(docUri)
export class ScmSurfaceController implements vscode.Disposable {
constructor(registry: CoeditingRegistry, diffView: DiffViewController);
changeCount(uriString: string): number; // test seam
// commands registered: cowriting.reviewChanges
}
```
- [ ] **Step 1: Unit test the change counter (pure)**
The status-bar count is a line-hunk count between baseline and live text. Add a pure `countLineHunks(oldText, newText): number` to `src/trackChangesModel.ts` (it already owns the diff algebra — implement via the existing `diffBlocks`/`diff` machinery or a line-LCS; test first):
```typescript
// append to test/trackChangesModel.test.ts
import { countLineHunks } from "../src/trackChangesModel";
describe("countLineHunks", () => {
it("0 for identical text", () => {
expect(countLineHunks("a\nb\n", "a\nb\n")).toBe(0);
});
it("1 for one contiguous change", () => {
expect(countLineHunks("a\nb\nc\n", "a\nX\nc\n")).toBe(1);
});
it("2 for two separated changes", () => {
expect(countLineHunks("a\nb\nc\nd\ne\n", "a\nX\nc\nd\nY\n")).toBe(2);
});
it("counts pure insertions and deletions", () => {
expect(countLineHunks("a\n", "a\nb\n")).toBe(1);
expect(countLineHunks("a\nb\n", "a\n")).toBe(1);
});
});
```
Implement with the same LCS walk `diffBlocks` uses, at line grain (a run of non-equal lines = one hunk).
- [ ] **Step 2: Implement `src/scmSurface.ts`**
```typescript
/**
* ScmSurfaceController — the native diff surface (spec §6.4/§5, INV-13).
* Owns: the "Cowriting" SourceControl + QuickDiffProvider (gutter bars), the
* cowriting-baseline: TextDocumentContentProvider, the "Review Changes" native
* diff command, and the "✦ Coediting · N changes" status-bar item. Gated on
* the CoeditingRegistry (INV-10): non-entered docs resolve no original.
*/
import * as path from "node:path";
import * as vscode from "vscode";
import type { CoeditingRegistry } from "./coeditingRegistry";
import type { DiffViewController } from "./diffViewController";
import { countLineHunks } from "./trackChangesModel";
export const BASELINE_SCHEME = "cowriting-baseline";
export function baselineUriFor(docUri: vscode.Uri): vscode.Uri {
const name = path.basename(docUri.path) || "untitled.md";
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: `/${name}`, query: encodeURIComponent(docUri.toString()) });
}
export class ScmSurfaceController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly statusItem: vscode.StatusBarItem;
private readonly counts = new Map<string, number>();
private readonly baselineEmitter = new vscode.EventEmitter<vscode.Uri>();
private readonly recountTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly registry: CoeditingRegistry,
private readonly diffView: DiffViewController,
) {
this.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
this.statusItem.command = "cowriting.reviewChanges";
this.statusItem.tooltip = "Cowriting — click to review changes (baseline ⟷ live)";
this.disposables.push(this.statusItem, this.baselineEmitter);
this.disposables.push(
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, {
onDidChange: this.baselineEmitter.event,
provideTextDocumentContent: (uri) =>
this.diffView.getBaseline(decodeURIComponent(uri.query))?.text ?? "",
}),
);
const sc = vscode.scm.createSourceControl("cowriting", "✦ Cowriting");
sc.quickDiffProvider = {
provideOriginalResource: (uri: vscode.Uri) =>
this.registry.isCoediting(uri) && this.diffView.getBaseline(uri.toString())
? baselineUriFor(uri)
: null,
};
this.disposables.push(sc);
this.disposables.push(
vscode.commands.registerCommand("cowriting.reviewChanges", () => this.openReview()),
this.diffView.onDidChangeBaseline(({ uri }) => {
this.baselineEmitter.fire(baselineUriFor(vscode.Uri.parse(uri)));
this.scheduleRecount(uri);
}),
this.registry.onDidChange(({ uri, coediting }) => {
if (coediting) this.scheduleRecount(uri);
this.refreshStatus();
}),
vscode.workspace.onDidChangeTextDocument((e) => {
if (this.registry.isCoediting(e.document.uri)) this.scheduleRecount(e.document.uri.toString());
}),
vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()),
);
this.refreshStatus();
}
changeCount(uriString: string): number {
return this.counts.get(uriString) ?? 0;
}
private async openReview(): Promise<void> {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
void vscode.window.showWarningMessage("Cowriting: not coediting this document.");
return;
}
this.baselineEmitter.fire(baselineUriFor(ed.document.uri));
await vscode.commands.executeCommand(
"vscode.diff",
baselineUriFor(ed.document.uri),
ed.document.uri,
`${path.basename(ed.document.uri.path)} — Coediting (baseline ⟷ live)`,
{ preview: true },
);
}
private scheduleRecount(uriString: string): void {
const prev = this.recountTimers.get(uriString);
if (prev !== undefined) clearTimeout(prev);
this.recountTimers.set(uriString, setTimeout(() => {
this.recountTimers.delete(uriString);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
const baseline = this.diffView.getBaseline(uriString);
if (!doc || !baseline) return;
this.counts.set(uriString, countLineHunks(baseline.text, doc.getText()));
this.refreshStatus();
}, 150));
}
private refreshStatus(): void {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
this.statusItem.hide();
return;
}
const n = this.counts.get(ed.document.uri.toString()) ?? 0;
this.statusItem.text = `$(sparkle) Coediting · ${n} change${n === 1 ? "" : "s"}`;
this.statusItem.show();
}
dispose(): void {
for (const t of this.recountTimers.values()) clearTimeout(t);
for (const d of this.disposables) d.dispose();
}
}
```
- [ ] **Step 3: Wire + menus**
`extension.ts`: construct after `diffViewController`; add to `CowritingApi` as `scmSurfaceController`. Also wire `registry.baselineModeOf = (uri) => diffViewController.modeOf(uri.toString())` (Task 1 hook) and call `registry.syncContext(...)` inside `diffView.onDidChangeBaseline` so the `cowriting.baselineMode` context key tracks mode changes.
`package.json` — `editor/title` (replacing the `showTrackChangesPreview` entry in Task 8; for now add alongside):
```json
{ "command": "cowriting.reviewChanges", "when": "resourceLangId == markdown && cowriting.isCoediting", "group": "navigation@1" },
{ "command": "cowriting.markReviewed", "when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot", "group": "navigation@4" }
```
with icons on the command declarations: `"icon": "$(git-compare)"` (reviewChanges), `"icon": "$(check-all)"` (markReviewed).
- [ ] **Step 4: E2E asserts**
Extend `baselineRouter.test.ts`: after entering the snapshot doc and editing, `settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 1)`; after `cowriting.markReviewed`, count returns to 0. Add to `coediting.test.ts`: a **non-entered** markdown doc gets `provideOriginalResource` → null (assert via `api.scmSurfaceController.changeCount` staying 0 and, structurally, `registry.isCoediting === false` — QuickDiff itself isn't queryable from tests; assert the gate inputs).
- [ ] **Step 5: Verify + commit**
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
```bash
git add src/scmSurface.ts src/trackChangesModel.ts src/extension.ts package.json test/
git commit -m "feat: native diff surface — QuickDiff + cowriting-baseline: provider + Review Changes + status bar (spec §6.4, INV-13)"
```
---
### Task 4: Gate every existing surface on the registry (INV-10)
**Files:**
- Modify: `src/threadController.ts`, `src/attributionController.ts`, `src/proposalController.ts`, `src/editorProposalController.ts`, `src/extension.ts`, `package.json`
- Test: extend `test/e2e/suite/coediting.test.ts`
**Interfaces:**
- Consumes: `CoeditingRegistry.isCoediting/onDidChange` (each controller gains a `registry: CoeditingRegistry` constructor param, appended last).
- Produces: no new API; behavior — non-entered docs get no commenting ranges, no attribution tracking, no proposal rendering, no CodeLens, no decorations.
- [ ] **Step 1: ThreadController gating + the spike's re-assign refresh**
In `threadController.ts`:
```typescript
// constructor gains: private readonly registry: CoeditingRegistry
// keep ONE provider object; re-assign it on every registry change (spec §6.4 v0.2.1:
// re-assignment 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)];
},
};
// in constructor:
this.controller.commentingRangeProvider = this.rangeProvider;
this.disposables.push(
this.registry.onDidChange(() => {
this.controller.commentingRangeProvider = this.rangeProvider; // force re-query
}),
);
```
Also gate `createThreadOnSelection` and `renderAll` early-return on `!this.registry.isCoediting(document.uri)` — and on `registry.onDidChange`, dispose rendered threads for exited docs / `renderAll` for entered ones (threads persist in the sidecar; exit hides, re-enter restores — PUC-7).
- [ ] **Step 2: Attribution/Proposal/CodeLens gating**
Same pattern, minimal touch:
- `attributionController.ts`: the `onDidChangeTextDocument` tracking listener and `loadAll` early-return when `!registry.isCoediting(doc.uri)`.
- `proposalController.ts`: `renderAll` + `onDidChangeProposals`-driven rendering gated; `propose()` itself stays ungated (the seam is only reachable from gated surfaces + the E2E harness).
- `editorProposalController.ts`: `provideCodeLenses` returns `[]` and `renderEditor` clears decorations when not coediting; re-render on `registry.onDidChange`.
- `extension.ts`: `renderIfOpen` checks the registry; the `cowriting.edit`/`editSelection`/`editDocument` commands warn `"Run ✦ Coedit this Document with Claude first."` when the target doc isn't entered.
- [ ] **Step 3: package.json when-clauses**
Every cowriting menu/keybinding entry that assumed always-on markdown gains `&& cowriting.isCoediting`: `cowriting.edit` (editor/context, editor/title/context, keybinding), `cowriting.createThread`, `cowriting.acceptAllProposals` / `cowriting.rejectAllProposals` palette gates. `cowriting.coeditDocument` keeps `!cowriting.isCoediting`.
- [ ] **Step 4: E2E — the no-hijack scenario (PUC-7)**
Extend `coediting.test.ts`:
```typescript
test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" });
const ed = await vscode.window.showTextDocument(doc);
// not entered → no thread creation
ed.selection = new vscode.Selection(2, 0, 2, 4);
const before = await api.threadController.createThreadOnSelection("hi");
assert.strictEqual(before, undefined);
// entered → works
await vscode.commands.executeCommand("cowriting.coeditDocument");
const id = await api.threadController.createThreadOnSelection("hi");
assert.ok(id);
// exit → rendered thread set empty; re-enter → restored from sidecar
await vscode.commands.executeCommand("cowriting.stopCoediting");
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 0);
await vscode.commands.executeCommand("cowriting.coeditDocument");
await settle();
assert.strictEqual(api.threadController.getRendered(api.proposalController.keyFor(doc)).length, 1);
});
```
Existing E2E suites (`authorship`, `f10Review`, `f12*`, `diffView`, `undoMarks`) assumed always-on: add `await vscode.commands.executeCommand("cowriting.coeditDocument")` to their setup helpers (do it once in the shared helper that opens the doc).
- [ ] **Step 5: Verify + commit**
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
```bash
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
**Files:**
- Create: `src/editFlow.ts`
- Modify: `src/trackChangesPreview.ts` (delegate to editFlow), `src/extension.ts` (acceptAll/rejectAll route to ProposalController directly)
- Test: existing `f10Review`/`f12*` suites keep passing (behavior-preserving refactor)
**Interfaces:**
- Produces (Task 6 consumes `runEditAndPropose`; Task 8 deletes the webview knowing nothing else imports it):
```typescript
// src/editFlow.ts
export type EditTarget = { kind: "document" } | { kind: "range"; start: number; end: number };
export class EditFlow {
constructor(proposals: ProposalController, attribution: AttributionController, liveProgressUi: LiveProgressUi);
/** Moved verbatim from trackChangesPreview.ts lines 299335 (one proposal per changed
* block for document edits — INV-39/40; block-union single proposal for range edits). */
runEditAndPropose(document: vscode.TextDocument, target: EditTarget, instruction: string,
opts?: RunEditTurnOptions): Promise<string[]>;
setEditTurnForTest(fn: EditTurn): void; // preserved test seam
}
```
- [ ] **Step 1: Move, don't rewrite.** Cut `runEditAndPropose`, its `EditTarget`/`EditTurn` types, and the `cowriting.editDocument` command registration out of `trackChangesPreview.ts` into `src/editFlow.ts`; the preview controller receives the `EditFlow` instance and delegates (its webview `askClaude` messages call `editFlow.runEditAndPropose`). `extension.ts` constructs `EditFlow` before the preview controller, passes it to both the preview controller and (Task 6) the thread controller, and **adds `editFlow: EditFlow` to `CowritingApi`** (the Task 6/9 E2E suites drive `editFlow.setEditTurnForTest`).
- [ ] **Step 2: Re-route accept/reject-all.** `cowriting.acceptAllProposals` → `proposalController.acceptAllProposals(doc)`; `cowriting.rejectAllProposals` → `proposalController.rejectAll(doc)` (drop the preview-controller indirection; keep the same toasts by moving the report strings).
- [ ] **Step 3: Verify + commit.** `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e` — all existing suites green (pure refactor).
```bash
git add src/editFlow.ts src/trackChangesPreview.ts src/extension.ts
git commit -m "refactor: extract EditFlow (runEditAndPropose + editDocument) from the review webview ahead of its sunset (spec §6.10)"
```
---
### Task 6: Comments-first ask + comment-response loop (D19/D10/D8); sunset the input webview
**Files:**
- Modify: `src/threadController.ts` (the loop), `src/extension.ts` (Ask Claude command; editSelection prompt path), `package.json` (Ask Claude, Make-this-edit menus)
- Delete: `src/editInstructionInput.ts` (+ its media client if separate; `git grep editInstructionInput media/` first)
- Test: `test/e2e/suite/commentLoop.test.ts`
**Interfaces:**
- Consumes: `EditFlow.runEditAndPropose`, `LiveProgressUi.begin`, `runEditTurn` (dynamic import), registry.
- Produces:
```typescript
// ThreadController additions
askClaude(): Promise<void>; // command cowriting.askClaude
/** Test seam: run the offer for a thread without the UI button. */
makeThreadEdit(threadId: string, docPath: string): Promise<string[]>;
setTurnRunnerForTest(fn: (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>): void;
```
- [ ] **Step 1: Ask Claude = focused comment box (spec §6.4 v0.2.1)**
```typescript
// threadController.ts
async askClaude(): Promise<void> {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
return;
}
if (ed.selection.isEmpty) {
// whole-document ask → top-anchored thread (D19)
ed.selection = new vscode.Selection(0, 0, 0, 0);
ed.revealRange(new vscode.Range(0, 0, 0, 0));
}
// Spike finding: workbench.action.addComment is the only path that opens the
// comment widget WITH its input focused (no thread.focus() API exists).
this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh
await vscode.commands.executeCommand("workbench.action.addComment");
}
```
Register `cowriting.askClaude` in the constructor's command block. Command declaration: `{ "command": "cowriting.askClaude", "title": "Ask Claude", "category": "Cowriting", "icon": "$(sparkle)" }`; `editor/title` menu entry `when: "resourceLangId == markdown && cowriting.isCoediting"`, `group: "navigation@2"`.
- [ ] **Step 2: Every comment on a coedited doc runs a turn (D10)**
The existing `cowriting.reply` command (and thread creation via the `+` gutter → the contributed reply button) is the ingress. Extend `reply(r)`: after persisting the human message, if `this.registry.isCoediting(r.thread.uri)` and the author isn't the machine, fire the response loop:
```typescript
private async respondInThread(vsThread: vscode.CommentThread, state: DocState, threadId: string, ask: string): Promise<void> {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
if (!doc) return;
const contextText = this.threadContextText(doc, vsThread); // passage for a ranged thread, full doc for top-anchored
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);
vsThread.contextValue = "offer"; // when-key: commentThread == offer
this.offers.set(vsThread, { ask, threadId });
},
);
} catch (err) {
void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`);
}
}
```
`REPLY_PROMPT(ask)` (module const): `"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`. `threadContextText`: `doc.getText(vsThread.range)` when the thread has a non-empty range, else `doc.getText()`.
- [ ] **Step 3: The offer → pending proposals (INV-5)**
```typescript
// command cowriting.makeThreadEdit — menu arg is CommentReply or CommentThread
private async makeEditFromThread(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise<void> {
const vsThread = "thread" in arg ? arg.thread : arg;
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); // {kind:"range", start,end} for ranged threads, {kind:"document"} for top-anchored
const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask);
vsThread.contextValue = "offer-done";
if (ids.length) void vscode.window.showInformationMessage(
`Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`);
}
```
`ThreadController` constructor gains `editFlow: EditFlow` and `liveProgressUi: LiveProgressUi` params (extension.ts passes them). `offers` is a `WeakMap<vscode.CommentThread, { ask: string; threadId: string }>`.
`package.json` menus (note the **`commentThread`** when-key — spec §6.4 v0.2.1):
```json
"comments/commentThread/title": [
{ "command": "cowriting.makeThreadEdit", "when": "commentController == cowriting.threads && commentThread == offer", "group": "inline@1" },
…existing resolve/reopen entries…
],
"comments/commentThread/context": [
{ "command": "cowriting.reply", "when": "commentController == cowriting.threads", "group": "inline@1" },
{ "command": "cowriting.makeThreadEdit", "when": "commentController == cowriting.threads && commentThread == offer", "group": "inline@2" }
]
```
with `{ "command": "cowriting.makeThreadEdit", "title": "✦ Make this edit", "icon": "$(sparkle)" }` in commands.
- [ ] **Step 4: Replace the input webview at its two call sites**
- `extension.ts` `cowriting.editSelection` (line ~254): replace `trackChangesPreviewController.askEditInstruction(...)` with creating a thread on the selection + focusing its input — the ask *is* the comment now (D19). Concretely: `await threadController.askClaude(); return;` — the loop (Step 2/3) takes over; delete the rest of the inline turn plumbing from that command (it now lives in the loop + EditFlow). Keep `cowriting.edit` routing (selection/document both end at `askClaude`).
- Delete `src/editInstructionInput.ts` and the preview controller's `askEditInstruction` default (point it at a rejected promise with a clear message; it dies fully in Task 8). `git grep -n "editInstructionInput\|promptEditInstruction\|askEditInstruction" src/ test/` must return only `trackChangesPreview.ts` internals slated for Task 8.
- [ ] **Step 5: E2E — the loop with a stubbed turn (PUC-8)**
```typescript
// test/e2e/suite/commentLoop.test.ts
suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => {
test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n" });
const ed = await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("cowriting.coeditDocument");
api.threadController.setTurnRunnerForTest(async () => ({ replacement: "I would tighten this sentence.", model: "stub", sessionId: "s1" }));
api.editFlow.setEditTurnForTest(async () => ({ replacement: "The quick fox jumps the lazy dog.", model: "stub", sessionId: "s1" }));
ed.selection = new vscode.Selection(2, 0, 2, 20);
const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!;
// reply-loop fires on the human message; wait for the machine message to persist
await settleUntil(() => {
const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId);
return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent";
}, 10000);
const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc));
assert.ok(ids.length >= 1);
assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5
});
test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" });
await vscode.window.showTextDocument(doc);
const id = await api.threadController.createThreadOnSelection("hello");
assert.strictEqual(id, undefined); // gate refuses thread creation entirely
});
});
```
(Wire `createThreadOnSelection` to fire the same respond loop as `reply` when the doc is coedited — the first message of a new thread is also "a comment on a coedited doc", D10. `makeThreadEdit(threadId, docPath)` test-seam overload resolves the `vsThread` from `state.vsThreads`.)
- [ ] **Step 6: Verify + commit**
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
```bash
git add src/threadController.ts src/extension.ts package.json test/ && git rm src/editInstructionInput.ts
git commit -m "feat: comments-first ask + comment→reply→offer→proposal loop (D19/D10/D8, PUC-8); sunset the input webview (spec §6.10)"
```
---
### Task 7: Annotations in the built-in Markdown preview (D3/D21, PUC-3)
**Files:**
- Create: `src/previewAnnotations.ts`, `media/preview-annotations.css`
- Modify: `src/extension.ts` (export `extendMarkdownIt` from `activate`'s return), `package.json` (`markdown.markdownItPlugins: true`, `markdown.previewStyles`, `cowriting.annotations` setting, Toggle command), `esbuild.mjs` only if the css needs copying (previewStyles paths are repo-relative — no build step needed)
- Test: `test/previewAnnotations.test.ts` (pure, vitest + markdown-it), `test/e2e/suite/previewAnnotations.test.ts`
**Interfaces:**
- Consumes: `injectSentinels`, `sentinelsToSpans`, `wordEditHunks`, `landedTextOf` from `trackChangesModel.ts` (all existing pure exports); `AttributionController.spansFor`; `DiffViewController.getBaseline`; `ProposalController.listProposals`; registry.
- Produces:
```typescript
// src/previewAnnotations.ts (pure module + one thin host hook)
export interface AnnotationInputs {
baselineText: string | undefined;
baselineReason: "entered" | "pinned" | "head" | undefined;
spans: AuthorSpan[];
proposals: ProposalView[];
enabled: boolean;
}
/** Pure: markdown source → sentinel-annotated source (authorship + change + proposal marks). */
export function annotateSource(src: string, inputs: AnnotationInputs): string;
/** markdown-it plugin factory; host supplies inputs per render. */
export function cowritingMarkdownItPlugin(md: any, host: { inputsFor(env: unknown): AnnotationInputs | undefined }): any;
```
- [ ] **Step 1: Unit tests first (token-in → HTML-out, vscode-free)**
```typescript
// test/previewAnnotations.test.ts
import MarkdownIt from "markdown-it";
import { describe, expect, it } from "vitest";
import { cowritingMarkdownItPlugin } from "../src/previewAnnotations";
function render(src: string, inputs: any): string {
const md = new MarkdownIt();
cowritingMarkdownItPlugin(md, { inputsFor: () => inputs });
return md.render(src);
}
const base = { baselineReason: "entered", proposals: [], enabled: true };
describe("preview annotations (PUC-3/D21)", () => {
it("renders clean when disabled", () => {
const html = render("hello brave world\n", { ...base, enabled: false, baselineText: "hello world\n", spans: [] });
expect(html).not.toContain("cw-");
});
it("colors machine spans blue and human insertions green vs baseline", () => {
const html = render("hello brave new world\n", {
...base,
baselineText: "hello world\n",
spans: [{ start: 6, end: 12, author: "claude" }, { start: 12, end: 16, author: "human" }],
});
expect(html).toContain('class="cw-ins-claude"');
expect(html).toContain('class="cw-ins-human"');
});
it("strikes deletions vs baseline", () => {
const html = render("hello world\n", { ...base, baselineText: "hello cruel world\n", spans: [] });
expect(html).toContain("cw-del");
expect(html).toContain("cruel");
});
it("pinned baseline renders clean even with spans (pin→clean, INV-33/#48)", () => {
const html = render("hello brave world\n", {
...base, baselineReason: "pinned", baselineText: "hello brave world\n",
spans: [{ start: 6, end: 12, author: "claude" }],
});
expect(html).not.toContain("cw-ins");
});
it("survives intra-emphasis boundaries (#33 discipline)", () => {
const html = render("a *bold claim* here\n", {
...base, baselineText: "a here\n", spans: [{ start: 2, end: 14, author: "claude" }],
});
expect(html).toContain("cw-ins-claude");
expect(html).not.toMatch(/[-]/); // no PUA sentinel leaks
});
});
```
- [ ] **Step 2: Implement `previewAnnotations.ts`**
Mechanism (reuses the shipped sentinel discipline instead of inventing a new one):
1. `annotateSource` computes, against `baselineText` (skip everything when `enabled === false` or `baselineReason === "pinned"` with zero diff — the #48 rule): (a) **insertion marks** from `spans` clipped to changed regions (`wordEditHunks(current, baseline)` gives changed ranges; a span portion inside a changed range gets an `ins-<author>` sentinel pair via `injectSentinels`); (b) **deletion marks**: for each hunk, the baseline-side dropped words are re-inserted into the source at the hunk start wrapped in `del` sentinels; (c) **pending proposals** already sit in the buffer text — mark them via their `anchorStart/anchorEnd` as `ins-claude` + their `original`'s dropped words as `del`.
2. `cowritingMarkdownItPlugin` installs (a) a core rule **before `normalize`** that swaps `state.src` for `annotateSource(state.src, inputs)` when `host.inputsFor(state.env)` yields inputs, and (b) a `text` renderer rule that runs the existing `sentinelsToSpans` token-aware walker (#33/#47-hardened) over the escaped token content so sentinel pairs become `<span class="cw-…">` even across emphasis/tag boundaries.
3. The host hook in `extension.ts`:
```typescript
const annotationHost = {
inputsFor(env: unknown): AnnotationInputs | undefined {
const envUri = (env as { currentDocument?: { toString(): string } })?.currentDocument?.toString();
const key = envUri && coeditingRegistry.list().includes(envUri) ? envUri : lastActiveCoedited();
if (!key) return undefined;
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
if (!doc) return undefined;
return {
baselineText: diffViewController.getBaseline(key)?.text,
baselineReason: diffViewController.getBaseline(key)?.reason,
spans: attributionController.spansFor(doc),
proposals: proposalController.listProposals(doc),
enabled: vscode.workspace.getConfiguration("cowriting").get<boolean>("annotations", true),
};
},
};
// activate() return gains:
return { …api, extendMarkdownIt: (md: any) => cowritingMarkdownItPlugin(md, annotationHost) };
```
(`env.currentDocument` is observed, not contractual — the `lastActiveCoedited()` fallback keeps single-doc correctness; record this in the module docstring.)
4. Toggle: setting `cowriting.annotations` (boolean, default true) + command `cowriting.toggleAnnotations` (icon `$(eye)`, title-bar `navigation@3`, gated `cowriting.isCoediting`) that flips the setting and runs `vscode.commands.executeCommand("markdown.preview.refresh")`.
5. `media/preview-annotations.css` (declared under `markdown.previewStyles`): the F10 class vocabulary — `.cw-ins-claude` (blue bg + underline), `.cw-ins-human` (green bg + underline), `.cw-del` (struck, red), light/dark variants keyed on `body.vscode-dark`.
6. **Mermaid (Q4 check, recorded):** the built-in preview does not render mermaid itself. Contribute `markdown.previewScripts` with the already-bundled mermaid (reusing the F7.1 pure re-emit: the markdown-it plugin re-emits changed-diagram sources through `mermaidDiff.ts` before the previewScript renders them). If `markdown.previewScripts` + bundled mermaid proves unworkable in the E2E step, **stop and record the Q4 finding in the spec before proceeding** — do not build a webview fallback silently (INV-3).
- [ ] **Step 3: package.json**
```json
"markdown.markdownItPlugins": true,
"markdown.previewStyles": ["./media/preview-annotations.css"],
"markdown.previewScripts": ["./media/preview-mermaid.js"],
"configuration": { "properties": { "cowriting.annotations": { "type": "boolean", "default": true, "description": "Show authorship + change annotations in the Markdown preview for coedited documents." }, …existing… } }
```
- [ ] **Step 4: E2E (structural asserts, §6.8 discipline)**
The preview DOM isn't queryable from the host — assert the pure transform inputs/outputs instead: open + enter a doc, make a machine-attributed change (via the `cowriting.applyAgentEdit` harness seam), assert `annotateSource(doc.getText(), inputsFromApi(...))` contains `cw-ins-claude`; flip `cowriting.annotations` to false via `workspace.getConfiguration().update` and assert the transform returns the source unchanged. (Named scenario PUC-3 annotate-toggle.)
- [ ] **Step 5: Verify + commit**
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
```bash
git add src/previewAnnotations.ts media/preview-annotations.css src/extension.ts package.json test/
git commit -m "feat: authorship + change annotations in the built-in Markdown preview (D3/D21, PUC-3); cowriting.annotations toggle"
```
---
### Task 8: Sunset the review-panel webview; surface cleanup
**Files:**
- Delete: `src/trackChangesPreview.ts`, its webview client assets (`git grep -l "preview.js\|acquireVsCodeApi" media/ src/` to enumerate), `test/e2e/suite/f10Review.test.ts` (webview-bound asserts; fold its INV coverage into the surviving suites first — see Step 2)
- Modify: `src/extension.ts`, `package.json`, `src/editorProposalController.ts` (lens copy), `README.md`
**Interfaces:** none new; removals — `TrackChangesPreviewController` leaves `CowritingApi`.
- [ ] **Step 1: Re-point the entry surfaces.** `cowriting.showTrackChangesPreview` (command, ⌘⌥R keybinding, title/context/explorer menus) is replaced by: keybinding ⌘⌥R → `cowriting.reviewChanges`; the "Open Review Panel" context entries (#41) → `markdown.showPreviewToSide` preceded by `cowriting.coeditDocument` if not entered (one small wrapper command `cowriting.openReviewPreview`, title "Open Cowriting Review Preview"). Remove the `cowriting.editDocument` palette entry (the ask is comments-first now); keep the command registered for the E2E harness.
- [ ] **Step 2: Preserve INV coverage before deleting tests.** `f10Review.test.ts` covers accept/reject routing + INV-32/33 rendering: the routing asserts move to `commentLoop.test.ts`/`f12Accept.test.ts` (buffer surface), the render asserts to `test/previewAnnotations.test.ts` (pure). Do this move in the same commit as the deletion so coverage never drops.
- [ ] **Step 3: Delete** `src/trackChangesPreview.ts` + client assets; `extension.ts` drops its construction and the `askEditInstruction` stub; `EditFlow` (Task 5) already owns everything live. `git grep -n "trackChangesPreview\|TrackChangesPreviewController"` must return nothing.
- [ ] **Step 4: Lens copy (PUC-2 wording).** In `editorProposalController.ts` `provideCodeLenses`: per-proposal titles become `"✓ Keep"` / `"✗ Reject"` (commands unchanged: `finalizeInPlace`/`revertInPlace` via the menu handlers); add a top-of-file pair when ≥2 applied proposals: `"✓ Keep all (N)"` → `cowriting.acceptAllProposals`, `"✗ Reject all"` → `cowriting.rejectAllProposals`. Update `f12*` E2E title asserts.
- [ ] **Step 5: README (DOC-2).** Rewrite the "how it works" section as the native-surface map (spec §5 table): enter → title bar/status bar → comments-first ask → pending in buffer → native diff → built-in preview. One screenshot-free walkthrough.
- [ ] **Step 6: Verify + commit.**
Run: `npm run typecheck && npm run test && npm run pretest:e2e && npm run test:e2e`
```bash
git add -A
git commit -m "feat!: sunset the review-panel webview — built-in preview + native diff are the review surfaces (D3/D17, spec §6.10); Keep/Reject lens copy (PUC-2)"
```
---
### Task 9: Full-loop E2E + live smoke (rung 3 folded in)
**Files:**
- 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 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.**
```bash
git add test/e2e/suite/fullLoop.test.ts scripts/smoke-native-loop.mjs package.json
git commit -m "test: full native-loop E2E (PUC-1/2/7/8) + real-SDK smoke (spec §6.8, §7.1 rung 3)"
```
---
## Execution order & green-ness invariant
Tasks run 1→9 strictly; every task leaves `typecheck + unit + host E2E` green. The two sunsets (Tasks 6, 8) each land only after their native replacement's tests pass in the same task (spec §6.10). No §9 web pipeline applies (VS Code extension — spec §7.2); "done" = acceptance (§1.9) + suites green + the Task 9 live smoke.
## Deferred (recorded, not silently dropped)
- **D15 — preview-initiated comments** (select in the rendered preview → native thread on the source, 💬 deep-link marker): the spike validated only the editor-selection and whole-document halves of E4; the preview-selection→source-map half was explicitly **not** spiked (spec §7.1 v0.2.1 note), and the built-in preview's script sandbox makes its feasibility a real question. It ships in a follow-up increment behind its own feasibility check; this plan's ask surfaces are the title-bar/editor-selection/whole-document paths. Reviewer may veto and pull it in.
- **PUC-4/6 thread re-anchor/displaced coverage** rides the existing F2 suites unchanged (the anchoring cores don't move in this migration).
## Spec-coverage self-check (§ → task)
- INV-7/D13/D14 baseline router → Tasks 23 · INV-10/PUC-7 gate → Tasks 1, 4 · INV-13 discovery → Task 3 · D19/D10/D8/PUC-8 comment loop → Task 6 · D3/D21/PUC-3 preview → Task 7 · D18/D20/PUC-2 buffer review → shipped (F12) + copy/gating in Tasks 4, 8 · INV-5/INV-12 → existing proposal seam, asserted in Task 9 · INV-8 onBehalfOf → Task 6 reply provenance · PUC-5 progress → shipped (#60), reused in Task 6 · §6.10 sunsets → Tasks 6, 8 · §6.8 named E2E → Tasks 2, 4, 6, 7, 9 · Q4 mermaid check → Task 7 Step 2.6 · DOC-2 README → Task 8.