Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
68 KiB
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
HEADwhere 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
commentingRangeProviderwhenever the coediting set changes; focus the comment input viaworkbench.action.addComment; the thread-contextValue menuwhenkey iscommentThread; key HEAD re-reads offstate.HEAD.commit; nested repos may needopenRepository(). - 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/sdkstaysexternalinesbuild.mjsand is loaded via dynamicimport(); spawned withCLAUDE_CODE_AUTO_CONNECT_IDE=0+CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1(#59).- Sunset discipline (§6.10):
trackChangesPreview.tsandeditInstructionInput.tsare 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 controllersnpm 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):
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), commandscowriting.coeditDocument,cowriting.stopCoediting. -
Step 1: Write the failing unit test
// 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
/**
* 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:
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:
{ "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"]:
{ "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
// 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).
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 theonDidApplyAgentEdit → advancewire),src/proposalController.ts(finalize no longer signals a baseline advance) - Test:
test/e2e/suite/baselineRouter.test.ts; updatetest/e2e/suite/diffView.test.tsexpectations
Interfaces:
-
Produces:
// 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; } -
DiffViewControllergains/changes (consumed by Tasks 3–4 and the preview):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):
/**
* 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):
BaselineReasoninsrc/baselineStore.tsbecomes"entered" | "pinned" | "head". Onstore.load, migrate legacy reasons:"opened"/"machine-landing"→"entered"(onenormalizeReasonhelper inbaselineStore.ts).- Constructor becomes
constructor(store: BaselineStore | null, git: GitBaselineAdapter, registry: CoeditingRegistry). Remove theonDidOpenTextDocument → ensureBaselineself-wiring and the open-time loop; instead: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); } - New
establish(replacesensureBaseline) resolves the mode: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 }); } pin()guards on mode (D14):if (this.modes.get(key) !== "snapshot") { warn "git-tracked — commit to advance the baseline"; return; }. The command idcowriting.pinDiffBaselineis renamedcowriting.markReviewed(title "Mark Changes as Reviewed"); updatepackage.jsoncommands/menus and every test referencingpinDiffBaseline.- Delete
advance()and the#48-era"machine-landing"branch; insrc/extension.tsdelete the wireattributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document))(lines ~154–156). Insrc/proposalController.ts,finalizeInPlace's call toattribution.signalLanded(...)must no longer trigger any baseline change — keepsignalLanded(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). getBaseline/baselineFilePathkeep their signatures (preview + F12 rely on them). Thereason === "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
// 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).
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 incoediting.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:
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):
// 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
/**
* 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):
{ "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
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 aregistry: CoeditingRegistryconstructor 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:
// 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: theonDidChangeTextDocumenttracking listener andloadAllearly-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:provideCodeLensesreturns[]andrenderEditorclears decorations when not coediting; re-render onregistry.onDidChange. -
extension.ts:renderIfOpenchecks the registry; thecowriting.edit/editSelection/editDocumentcommands 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:
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
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):// 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 299–335 (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, itsEditTarget/EditTurntypes, and thecowriting.editDocumentcommand registration out oftrackChangesPreview.tsintosrc/editFlow.ts; the preview controller receives theEditFlowinstance and delegates (its webviewaskClaudemessages calleditFlow.runEditAndPropose).extension.tsconstructsEditFlowbefore the preview controller, passes it to both the preview controller and (Task 6) the thread controller, and addseditFlow: EditFlowtoCowritingApi(the Task 6/9 E2E suites driveeditFlow.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).
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:
// 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)
// 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:
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)
// 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):
"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.tscowriting.editSelection(line ~254): replacetrackChangesPreviewController.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). Keepcowriting.editrouting (selection/document both end ataskClaude). -
Delete
src/editInstructionInput.tsand the preview controller'saskEditInstructiondefault (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 onlytrackChangesPreview.tsinternals slated for Task 8. -
Step 5: E2E — the loop with a stubbed turn (PUC-8)
// 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
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(exportextendMarkdownItfromactivate's return),package.json(markdown.markdownItPlugins: true,markdown.previewStyles,cowriting.annotationssetting, Toggle command),esbuild.mjsonly 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,landedTextOffromtrackChangesModel.ts(all existing pure exports);AttributionController.spansFor;DiffViewController.getBaseline;ProposalController.listProposals; registry. -
Produces:
// 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)
// 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):
annotateSourcecomputes, againstbaselineText(skip everything whenenabled === falseorbaselineReason === "pinned"with zero diff — the #48 rule): (a) insertion marks fromspansclipped to changed regions (wordEditHunks(current, baseline)gives changed ranges; a span portion inside a changed range gets anins-<author>sentinel pair viainjectSentinels); (b) deletion marks: for each hunk, the baseline-side dropped words are re-inserted into the source at the hunk start wrapped indelsentinels; (c) pending proposals already sit in the buffer text — mark them via theiranchorStart/anchorEndasins-claude+ theiroriginal's dropped words asdel.cowritingMarkdownItPlugininstalls (a) a core rule beforenormalizethat swapsstate.srcforannotateSource(state.src, inputs)whenhost.inputsFor(state.env)yields inputs, and (b) atextrenderer rule that runs the existingsentinelsToSpanstoken-aware walker (#33/#47-hardened) over the escaped token content so sentinel pairs become<span class="cw-…">even across emphasis/tag boundaries.- The host hook in
extension.ts:(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.currentDocumentis observed, not contractual — thelastActiveCoedited()fallback keeps single-doc correctness; record this in the module docstring.) - Toggle: setting
cowriting.annotations(boolean, default true) + commandcowriting.toggleAnnotations(icon$(eye), title-barnavigation@3, gatedcowriting.isCoediting) that flips the setting and runsvscode.commands.executeCommand("markdown.preview.refresh"). media/preview-annotations.css(declared undermarkdown.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 onbody.vscode-dark.- Mermaid (Q4 check, recorded): the built-in preview does not render mermaid itself. Contribute
markdown.previewScriptswith the already-bundled mermaid (reusing the F7.1 pure re-emit: the markdown-it plugin re-emits changed-diagram sources throughmermaidDiff.tsbefore the previewScript renders them). Ifmarkdown.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
"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
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.showPreviewToSidepreceded bycowriting.coeditDocumentif not entered (one small wrapper commandcowriting.openReviewPreview, title "Open Cowriting Review Preview"). Remove thecowriting.editDocumentpalette 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.tscovers accept/reject routing + INV-32/33 rendering: the routing asserts move tocommentLoop.test.ts/f12Accept.test.ts(buffer surface), the render asserts totest/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.tsdrops its construction and theaskEditInstructionstub;EditFlow(Task 5) already owns everything live.git grep -n "trackChangesPreview\|TrackChangesPreviewController"must return nothing. - Step 4: Lens copy (PUC-2 wording). In
editorProposalController.tsprovideCodeLenses: per-proposal titles become"✓ Keep"/"✗ Reject"(commands unchanged:finalizeInPlace/revertInPlacevia the menu handlers); add a top-of-file pair when ≥2 applied proposals:"✓ Keep all (N)"→cowriting.acceptAllProposals,"✗ Reject all"→cowriting.rejectAllProposals. Updatef12*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
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(scriptsmoke: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 viaspansFor: 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.mjsmirrorsscripts/smoke-live-turn.mjsbut through the comment loop: launches the EDH, enters a sandbox doc, posts a real comment, waits for the real@cline/sdkreply + 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.
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 2–3 · 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.