Files
vscode-cowriting-plugin-con…/plans/2026-07-01-native-surfaces-migration.md

68 KiB
Raw Permalink Blame History

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):

    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

// 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 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:

    // 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):

    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):

  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:
    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:
    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
// 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 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:

    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.jsoneditor/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 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:

// 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:

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 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.acceptAllProposalsproposalController.acceptAllProposals(doc); cowriting.rejectAllProposalsproposalController.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.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)

// 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 (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:

    // 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):

  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:
    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
"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.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

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 the documented anchoring contract: the exact-substring anchor orphans — INV-1/INV-11 — but the proposal is NOT lost: it stays listed and Keep still lands it, because finalizeInPlace looks up by id and bypasses resolution by design) → Keep one (assert attribution split via spansFor: claude words + human tweak) → Reject one (assert buffer text equals pre-proposal text for that range exactly — INV-5; note: the reject candidate must be un-tweaked — reject-after-interior-edit is a known pre-existing F12 INV-5 gap, tracked as a follow-up issue) → cowriting.markReviewed → change count 0. (Corrected during execution: the original text asserted "re-anchor: proposal still resolvable" after an interior tweak, which contradicts the shipped exact-substring anchorer contract.)

  • Step 2: Live smoke (rung 3 — the real SDK, manual gate): scripts/smoke-native-loop.mjs mirrors scripts/smoke-live-turn.mjs but through the comment loop: launches the EDH, enters a sandbox doc, posts a real comment, waits for the real @cline/sdk reply + offer, accepts, prints the created proposal ids. npm run smoke:native. This is operator-run (real tokens); the plan's definition of done for rung 3 is one green run reported in the session transcript.

  • Step 3: Verify + commit.

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.