Files
Ben Stull 6f0b903596 feat(f6): BaselineStore — vscode-free per-doc baseline persistence (SLICE-1)
F6 §6.2/§6.3, INV-19. One JSON per docPath under VS Code workspace storage,
never the repo. Mirrors CoauthorStore; unit-tested round-trip/paths/overwrite.

Also lands the F6 implementation plan
(docs/superpowers/plans/2026-06-11-f6-diff-view-toggle.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:11:42 -07:00

42 KiB
Raw Permalink Blame History

F6 Diff-View Toggle 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: Add a one-gesture diff-view toggle that flips a tracked document into a vscode.diff against a coauthoring baseline (live document on the right), where the baseline auto-advances at every machine landing and can be pinned on demand.

Architecture: One vscode-free BaselineStore (the CoauthorStore pattern — Node fs, one JSON per docPath under VS Code workspace storage, never the repo — INV-19) plus one vscode-layer DiffViewController (content provider for a cowriting-baseline: scheme, baseline lifecycle, toggle via the tab-groups API). The machine-landing signal is one additive onDidApplyAgentEdit event on AttributionController — the seam is the single ingress (INV-9), so it is the single signal. Everything else is reused unchanged.

Tech Stack: TypeScript, VS Code extension API (vscode.diff, TextDocumentContentProvider, window.tabGroups), vitest (unit), @vscode/test-electron + mocha (host E2E). No LLM anywhere in F6 or its tests.

Source spec: vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md (F6, Feature #17). Section refs (§6.x, INV-18/19, PUC-x) point into it.


File Structure

  • Create src/baselineStore.ts — vscode-free BaselineStore: load/save/baselinePath for one Baseline JSON per docPath under a storage dir. Mirrors src/store.ts.
  • Create test/baselineStore.test.ts — vitest round-trip / path / overwrite / missing-null unit tests. Mirrors test/store.test.ts.
  • Modify src/attributionController.ts — add the additive onDidApplyAgentEdit event emitter; fire it after a real (non-no-op) seam apply succeeds. Seam signature/return unchanged.
  • Create src/diffViewController.ts — vscode-layer controller: registers the cowriting-baseline: content provider (+ onDidChange refresh), owns baseline lifecycle (ensureBaseline/advance/pin), toggle via tab groups, registers the two commands, exposes the test-facing surface, degrades to in-memory on storage failure.
  • Modify src/extension.ts — construct BaselineStore from context.storageUri + DiffViewController; wire ensureBaseline into renderIfOpen; subscribe advance to attributionController.onDidApplyAgentEdit; add diffViewController to CowritingApi; add the two commands to the no-folder stub list.
  • Modify package.json — contribute the two commands + the ctrl+alt+d keybinding.
  • Create test/e2e/fixtures/workspace/docs/diffview.md — the F6 suite's owned fixture (disjoint from sample/attrib/proposal/crossrung).
  • Create test/e2e/suite/diffView.test.ts — host E2E per §6.8.
  • Modify test/e2e/suite-no-workspace/noWorkspace.test.ts — add the two new commands to the stub-registration assertion.
  • Create docs/MANUAL-SMOKE-F6.md — live smoke per §6.8.
  • Modify README.md — one-line F6 pointer in the develop/feature section.

Slice → Task map

  • SLICE-1 (BaselineStore + unit): Task 1.
  • SLICE-2 (seam event + controller lifecycle): Tasks 2, 3.
  • SLICE-3 (toggle UX + package.json + API + wiring): Tasks 4, 5, 6.
  • SLICE-4 (host E2E + smoke + README): Tasks 7, 8, 9.

Task 1: BaselineStore (vscode-free) + unit tests — SLICE-1

Files:

  • Create: src/baselineStore.ts

  • Test: test/baselineStore.test.ts

  • Step 1: Write the failing test

Create test/baselineStore.test.ts:

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { BaselineStore, type Baseline } from "../src/baselineStore";

let dir: string;

beforeEach(() => {
  dir = fs.mkdtempSync(path.join(os.tmpdir(), "baseline-store-"));
});
afterEach(() => {
  fs.rmSync(dir, { recursive: true, force: true });
});

function sample(docPath = "notes/chapter-1.md"): Baseline {
  return { docPath, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" };
}

describe("BaselineStore", () => {
  it("returns null for a document with no baseline", () => {
    expect(new BaselineStore(dir).load("notes/chapter-1.md")).toBeNull();
  });

  it("computes the baseline path as baselines/<docPath>.json", () => {
    const store = new BaselineStore(dir);
    expect(store.baselinePath("notes/chapter-1.md")).toBe(
      path.join(dir, "baselines", "notes", "chapter-1.md.json"),
    );
  });

  it("save then load round-trips the baseline (including nested docPaths)", () => {
    const store = new BaselineStore(dir);
    const b = sample();
    store.save(b.docPath, b);
    expect(store.load(b.docPath)).toEqual(b);
  });

  it("overwrites in place: the newest epoch wins, no history kept", () => {
    const store = new BaselineStore(dir);
    store.save("d.md", { docPath: "d.md", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" });
    store.save("d.md", { docPath: "d.md", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
    expect(store.load("d.md")).toEqual({ docPath: "d.md", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
  });

  it("writes pretty JSON with a trailing newline", () => {
    const store = new BaselineStore(dir);
    const b = sample("d.md");
    store.save("d.md", b);
    const raw = fs.readFileSync(store.baselinePath("d.md"), "utf8");
    expect(raw).toBe(JSON.stringify(b, null, 2) + "\n");
  });
});
  • Step 2: Run the test to verify it fails

Run: npx vitest run test/baselineStore.test.ts Expected: FAIL — cannot resolve ../src/baselineStore.

  • Step 3: Write the minimal implementation

Create src/baselineStore.ts:

/**
 * BaselineStore — load/save one diff-view baseline JSON per document (F6 §6.3).
 * The CoauthorStore shape (src/store.ts): vscode-free (Node fs only, unit-
 * testable), one file per docPath. INV-19: the storage dir is VS Code's
 * per-workspace extension storage, NEVER the repo — so the baseline can never
 * be committed, merged, or read by another rung, and the sidecar / cross-rung
 * contract (INV-14..17) are untouched by construction.
 */
import * as fs from "node:fs";
import * as path from "node:path";

export type BaselineReason = "opened" | "machine-landing" | "pinned";

export interface Baseline {
  docPath: string;
  /** full document text captured at the epoch (from the buffer, not disk — §6.3). */
  text: string;
  capturedAt: string;
  reason: BaselineReason;
}

export class BaselineStore {
  /** @param storageDir absolute VS Code workspace-storage dir (context.storageUri.fsPath). */
  constructor(private readonly storageDir: string) {}

  /** `<storageDir>/baselines/<repo-relative-docPath>.json` (§6.3). */
  baselinePath(docPath: string): string {
    return path.join(this.storageDir, "baselines", `${docPath}.json`);
  }

  load(docPath: string): Baseline | null {
    const p = this.baselinePath(docPath);
    if (!fs.existsSync(p)) return null;
    return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
  }

  /** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */
  save(docPath: string, baseline: Baseline): void {
    const p = this.baselinePath(docPath);
    fs.mkdirSync(path.dirname(p), { recursive: true });
    fs.writeFileSync(p, JSON.stringify(baseline, null, 2) + "\n", "utf8");
  }
}
  • Step 4: Run the test to verify it passes

Run: npx vitest run test/baselineStore.test.ts Expected: PASS (5 tests).

  • Step 5: Typecheck

Run: npm run typecheck Expected: no errors.

  • Step 6: Commit
git add src/baselineStore.ts test/baselineStore.test.ts
git commit -m "feat(f6): BaselineStore — vscode-free per-doc baseline persistence (SLICE-1)

F6 §6.2/§6.3, INV-19. One JSON per docPath under VS Code workspace storage,
never the repo. Mirrors CoauthorStore; unit-tested round-trip/paths/overwrite."

Task 2: Additive onDidApplyAgentEdit seam event — SLICE-2

Files:

  • Modify: src/attributionController.ts (fields near :56-64; fire near :278)

The seam (applyAgentEdit) is the single machine-edit ingress (INV-9). Adding one event on it makes it the single machine-landing signal — no call-site wiring, future seam drivers can't forget it (§6.7). This task has no behavioral test of its own; it is exercised by the SLICE-4 host E2E (accept advances the baseline). Verify with typecheck + build + the existing suites staying green.

  • Step 1: Add the emitter field and public event

In src/attributionController.ts, in the AttributionController class field block (currently ending around line 64 with private visible = true;), add:

  /**
   * F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real
   * (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit
   * ingress, so this is the sole signal — DiffViewController subscribes to
   * advance the baseline; no call-site wiring, no future driver can forget it.
   */
  private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>();
  readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
  • Step 2: Register the emitter for disposal

In the constructor, the first this.disposables.push(...) currently reads:

    this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output);

Change it to also dispose the emitter:

    this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output, this.applyEmitter);
  • Step 3: Fire the event after a successful real apply

In applyAgentEdit, the tail currently reads (around line 266-278):

    const ok = await vscode.workspace.applyEdit(we);
    const removed = this.pending.unregister(pendingEdit);
    if (ok && removed) {
      // ... existing WARN-on-mismatch block unchanged ...
      this.output.appendLine(
        "WARN: seam edit applied but its change event never matched the registration " +
        "(host minimized differently?) — the edit may be mis-attributed (INV-9).",
      );
    }
    return ok;

Insert the fire just before return ok; (the no-op replacement already returned at minStart === minEnd above, so reaching here means a real change):

    if (ok) {
      // F6 (INV-18): a real machine landing — signal the baseline to advance so
      // this text never shows as a change in the diff view. Fire regardless of
      // attribution-match bookkeeping above; the landing happened either way.
      this.applyEmitter.fire({ document });
    }
    return ok;

Note the existing if (ok && removed) WARN block stays exactly as is — only the new if (ok) { ... fire ... } is added above return ok;.

  • Step 4: Typecheck

Run: npm run typecheck Expected: no errors.

  • Step 5: Existing unit + E2E suites stay green

Run: npx vitest run Expected: PASS (unchanged — the emitter has no unit consumer yet).

Run: npm run test:e2e Expected: PASS (the seam's signature and return contract are unchanged; the added fire has no listener yet).

  • Step 6: Commit
git add src/attributionController.ts
git commit -m "feat(f6): onDidApplyAgentEdit — the single machine-landing signal (SLICE-2)

F6 §6.2/§6.4, INV-9/INV-18. Additive EventEmitter on the seam owner, fired
after a real (non-no-op) applyEdit succeeds. Seam signature/return unchanged."

Task 3: DiffViewController — content provider + baseline lifecycle — SLICE-2

Files:

  • Create: src/diffViewController.ts

This task creates the controller with the content provider, the ensureBaseline/advance/pin lifecycle, the storage-unavailable fallback, and the test-facing surface — but not yet wired into extension.ts (Task 5) and with the toggle UX added in Task 4. Verify with typecheck + build. (Host-layer behavior is verified by the SLICE-4 E2E — the F2F5 precedent: vscode-dependent controllers are covered by host E2E, not unit tests.)

  • Step 1: Create the controller (lifecycle + provider, no toggle yet)

Create src/diffViewController.ts:

/**
 * DiffViewController — F6 diff-view toggle (spec §6.2/§6.4). Owns the baseline
 * lifecycle (initialize at first track / advance at every machine landing / pin
 * on demand), serves the baseline as a readonly `cowriting-baseline:` virtual
 * document, and toggles a native vscode.diff (baseline left, the LIVE document
 * right). A pure view: never mutates the document, sidecar, or attribution
 * state (INV-19). Baselines persist via the vscode-free BaselineStore; if
 * storage is unavailable the controller degrades to in-memory baselines + one
 * warning (reload survival is lost; the toggle still works) — §6.5 PUC-5.
 */
import * as path from "node:path";
import * as vscode from "vscode";
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";

export const BASELINE_SCHEME = "cowriting-baseline";

export class DiffViewController implements vscode.Disposable {
  private readonly disposables: vscode.Disposable[] = [];
  /** Source of truth for the content provider; mirrors what the store persists. */
  private readonly baselines = new Map<string, Baseline>();
  private readonly onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
  private storageWarned = false;

  constructor(
    private readonly store: BaselineStore | null,
    private readonly rootDir: string,
  ) {
    const provider: vscode.TextDocumentContentProvider = {
      onDidChange: this.onDidChangeEmitter.event,
      provideTextDocumentContent: (uri) => {
        const docPath = this.docPathFromBaselineUri(uri);
        return this.baselines.get(docPath)?.text ?? "";
      },
    };
    this.disposables.push(
      this.onDidChangeEmitter,
      vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, provider),
      vscode.commands.registerCommand("cowriting.toggleDiffView", () =>
        this.toggle(vscode.window.activeTextEditor),
      ),
      vscode.commands.registerCommand("cowriting.pinDiffBaseline", () =>
        this.pinCommand(vscode.window.activeTextEditor),
      ),
    );
  }

  // ---- tracking / uri helpers --------------------------------------------------------

  private isTracked(document: vscode.TextDocument): boolean {
    return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir);
  }
  private docPathOf(uri: vscode.Uri): string {
    return vscode.workspace.asRelativePath(uri, false);
  }
  /** The readonly virtual-doc URI whose content the provider serves for this doc. */
  private baselineUri(docPath: string): vscode.Uri {
    return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: "/" + docPath });
  }
  private docPathFromBaselineUri(uri: vscode.Uri): string {
    return uri.path.replace(/^\//, "");
  }

  // ---- baseline lifecycle (§6.4) -----------------------------------------------------

  /** First sight of a tracked doc: load the stored baseline, else capture `opened`. */
  ensureBaseline(document: vscode.TextDocument): void {
    if (!this.isTracked(document)) return;
    const docPath = this.docPathOf(document.uri);
    if (this.baselines.has(docPath)) return;
    if (this.store) {
      try {
        const stored = this.store.load(docPath);
        if (stored) {
          this.baselines.set(docPath, stored);
          return;
        }
      } catch {
        this.warnStorageOnce();
      }
    }
    this.capture(document, "opened");
  }

  /** Machine landing (INV-18): re-capture so landed text never shows as a change. */
  advance(document: vscode.TextDocument): void {
    if (!this.isTracked(document)) return;
    this.capture(document, "machine-landing");
  }

  /** Human pin: baseline := now; the open diff visibly empties (left = right). */
  pin(document: vscode.TextDocument): void {
    if (!this.isTracked(document)) return;
    this.capture(document, "pinned");
  }

  /** Capture buffer text at this epoch, persist, and refresh any open diff's left side. */
  private capture(document: vscode.TextDocument, reason: BaselineReason): void {
    const docPath = this.docPathOf(document.uri);
    const baseline: Baseline = {
      docPath,
      text: document.getText(),
      capturedAt: new Date().toISOString(),
      reason,
    };
    this.baselines.set(docPath, baseline);
    if (this.store) {
      try {
        this.store.save(docPath, baseline);
      } catch {
        this.warnStorageOnce();
      }
    }
    // An open diff re-requests the left side when its baseline URI changes.
    this.onDidChangeEmitter.fire(this.baselineUri(docPath));
  }

  private warnStorageOnce(): void {
    if (this.storageWarned) return;
    this.storageWarned = true;
    void vscode.window.showWarningMessage(
      "Cowriting: diff-view storage is unavailable — baselines are kept in memory only and won't survive a reload.",
    );
  }

  // ---- commands (toggle added in Task 4) ---------------------------------------------

  private pinCommand(editor: vscode.TextEditor | undefined): void {
    if (!editor || !this.isTracked(editor.document)) {
      void vscode.window.showWarningMessage("Cowriting: open a tracked workspace document to pin its diff baseline.");
      return;
    }
    this.pin(editor.document);
  }

  // ---- test-facing surface (§6.4) ----------------------------------------------------

  getBaseline(docPath: string): { text: string; reason: BaselineReason; capturedAt: string } | undefined {
    const b = this.baselines.get(docPath);
    return b ? { text: b.text, reason: b.reason, capturedAt: b.capturedAt } : undefined;
  }
  /** Absolute on-disk path of this doc's persisted baseline, or undefined if in-memory. */
  baselineFilePath(docPath: string): string | undefined {
    return this.store?.baselinePath(docPath);
  }

  dispose(): void {
    for (const d of this.disposables) d.dispose();
  }
}

toggle and isDiffOpen are added in Task 4. The two registerCommand calls are already present so the commands exist from this task; toggle is referenced by cowriting.toggleDiffView and is added next — keep Task 3 and Task 4 in order, or the build fails on the missing toggle method. (If executing strictly task-by-task, add a temporary private toggle(_e: vscode.TextEditor | undefined): void {} stub in Step 1 and replace it in Task 4. The committed result after Task 4 has the real method.)

To keep Task 3 independently building, include this stub at the end of the class (replaced in Task 4):

  // Replaced by the real implementation in Task 4 (toggle UX, SLICE-3).
  private toggle(_editor: vscode.TextEditor | undefined): void {
    /* stub */
  }
  • Step 2: Typecheck + build

Run: npm run typecheck Expected: no errors (the toggle stub satisfies the command registration).

Run: npm run build Expected: succeeds.

  • Step 3: Commit
git add src/diffViewController.ts
git commit -m "feat(f6): DiffViewController — baseline lifecycle + content provider (SLICE-2)

F6 §6.2/§6.4, INV-18/INV-19. ensure/advance/pin capture buffer text, persist
via BaselineStore, refresh the open diff via onDidChange. Storage-unavailable
degrades to in-memory + one warning. toggle UX lands in SLICE-3."

Task 4: Toggle UX via the tab-groups API — SLICE-3

Files:

  • Modify: src/diffViewController.ts (replace the toggle stub; add isDiffOpen + a title helper)

  • Step 1: Replace the toggle stub with the real implementation

In src/diffViewController.ts, replace the Task-3 stub:

  // Replaced by the real implementation in Task 4 (toggle UX, SLICE-3).
  private toggle(_editor: vscode.TextEditor | undefined): void {
    /* stub */
  }

with the toggle + helpers:

  /**
   * PUC-1: if this doc's baseline diff is the active/open tab → close it and
   * reveal the normal editor; if the active editor is a tracked doc with no diff
   * open → open vscode.diff (baseline left, the live document right). Untracked
   * → warn, no diff.
   */
  private async toggle(editor: vscode.TextEditor | undefined): Promise<void> {
    if (!editor || !this.isTracked(editor.document)) {
      void vscode.window.showWarningMessage(
        "Cowriting: open a tracked workspace document to toggle its diff view.",
      );
      return;
    }
    const document = editor.document;
    const docPath = this.docPathOf(document.uri);
    const openTab = this.findDiffTab(document.uri);
    if (openTab) {
      await vscode.window.tabGroups.close(openTab);
      await vscode.window.showTextDocument(document, { preview: false });
      return;
    }
    this.ensureBaseline(document);
    const baseline = this.baselines.get(docPath)!;
    const title = `${path.basename(docPath)} — my changes since ${this.epochLabel(baseline)}`;
    await vscode.commands.executeCommand(
      "vscode.diff",
      this.baselineUri(docPath),
      document.uri,
      title,
      { preview: false },
    );
  }

  /** The open baseline-diff tab for this document, if any. */
  private findDiffTab(modified: vscode.Uri): vscode.Tab | undefined {
    for (const group of vscode.window.tabGroups.all) {
      for (const tab of group.tabs) {
        const input = tab.input;
        if (
          input instanceof vscode.TabInputTextDiff &&
          input.original.scheme === BASELINE_SCHEME &&
          input.modified.toString() === modified.toString()
        ) {
          return tab;
        }
      }
    }
    return undefined;
  }

  /** Human-readable epoch for the diff tab title (§5 / §6.5). */
  private epochLabel(baseline: Baseline): string {
    const time = new Date(baseline.capturedAt).toLocaleTimeString();
    switch (baseline.reason) {
      case "opened":
        return `opened ${time}`;
      case "machine-landing":
        return `Claude landed ${time}`;
      case "pinned":
        return `pinned ${time}`;
    }
  }

  /**
   * Test-facing (§6.4): is this doc's baseline diff currently open in any tab
   * group? The diff's `modified` side is the document's own file: URI.
   */
  isDiffOpen(docPath: string): boolean {
    return this.findDiffTab(vscode.Uri.file(path.join(this.rootDir, docPath))) !== undefined;
  }

- [ ] **Step 2: Make the toggle method's signature match its registration**

`toggle` is now `async` (returns `Promise<void>`). The command registration in the constructor is `() => this.toggle(vscode.window.activeTextEditor)`  a void-returning arrow that ignores the promise, which is fine for `registerCommand`. No change needed there.

- [ ] **Step 3: Typecheck + build**

Run: `npm run typecheck`
Expected: no errors.

Run: `npm run build`
Expected: succeeds.

- [ ] **Step 4: Commit**

```bash
git add src/diffViewController.ts
git commit -m "feat(f6): toggle UX — vscode.diff open/close via tab groups (SLICE-3)

F6 §6.5 PUC-1. Detects the baseline diff tab (original scheme cowriting-baseline,
modified == the doc) to close-and-reveal; else opens vscode.diff with an epoch
title. Live document on the right — editor state preserved by construction."

Task 5: Wire DiffViewController into the extension — SLICE-3

Files:

  • Modify: src/extension.ts

  • Step 1: Import the controller and the store

At the top of src/extension.ts, after the existing imports (e.g. after import { VersionGuard } from "./versionGuard";), add:

import { BaselineStore } from "./baselineStore";
import { DiffViewController } from "./diffViewController";
  • Step 2: Add diffViewController to the API type

In the CowritingApi interface, add the field:

export interface CowritingApi {
  threadController: ThreadController;
  attributionController: AttributionController;
  proposalController: ProposalController;
  versionGuard: VersionGuard;
  diffViewController: DiffViewController;
}
  • Step 3: Add the two new commands to the no-folder stub list

In the if (!root) { ... } block, the stub for loop lists the commands. Add the two F6 commands to that array:

    for (const command of [
      "cowriting.createThread",
      "cowriting.reply",
      "cowriting.resolveThread",
      "cowriting.reopenThread",
      "cowriting.editSelection",
      "cowriting.toggleAttribution",
      "cowriting.applyAgentEdit",
      "cowriting.acceptProposal",
      "cowriting.rejectProposal",
      "cowriting.proposeAgentEdit",
      "cowriting.toggleDiffView",
      "cowriting.pinDiffBaseline",
    ]) {
      context.subscriptions.push(vscode.commands.registerCommand(command, stub));
    }
  • Step 4: Construct the store + controller after the ProposalController block

After the proposalController is constructed and pushed (around line 84), add:

  // --- F6: diff-view toggle (Feature #17) ---
  // Baseline lives in VS Code workspace storage, never the repo (INV-19).
  // storageUri can be undefined in odd host states → in-memory fallback (§6.5).
  const storageDir = context.storageUri?.fsPath;
  const baselineStore = storageDir ? new BaselineStore(storageDir) : null;
  const diffViewController = new DiffViewController(baselineStore, root);
  context.subscriptions.push(diffViewController);
  // The seam's single machine-landing signal advances the baseline (INV-18).
  context.subscriptions.push(
    attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
  );
  • Step 5: Capture the baseline on first sight in renderIfOpen

The renderIfOpen function currently reads:

  const renderIfOpen = (doc: vscode.TextDocument) => {
    if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
      threadController.renderAll(doc);
      attributionController.loadAll(doc);
      proposalController.renderAll(doc);
    }
  };

Add the ensureBaseline call inside the guard:

  const renderIfOpen = (doc: vscode.TextDocument) => {
    if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
      threadController.renderAll(doc);
      attributionController.loadAll(doc);
      proposalController.renderAll(doc);
      diffViewController.ensureBaseline(doc);
    }
  };
  • Step 6: Return the controller in the API

The final return of activate currently reads:

  return { threadController, attributionController, proposalController, versionGuard };

Change it to:

  return { threadController, attributionController, proposalController, versionGuard, diffViewController };
  • Step 7: Typecheck + build

Run: npm run typecheck Expected: no errors.

Run: npm run build Expected: succeeds.

  • Step 8: Commit
git add src/extension.ts
git commit -m "feat(f6): wire DiffViewController into activation (SLICE-3)

F6 §6.2. BaselineStore from context.storageUri (in-memory fallback), advance
subscribed to the seam's onDidApplyAgentEdit, ensureBaseline on first sight in
renderIfOpen, diffViewController on the CowritingApi, commands stubbed in the
no-folder path (#8 precedent)."

Task 6: package.json contributions — commands + keybinding — SLICE-3

Files:

  • Modify: package.json

  • Step 1: Contribute the two commands

In contributes.commands, after the last entry (cowriting.proposeAgentEdit), add:

      {
        "command": "cowriting.toggleDiffView",
        "title": "Cowriting: Toggle Diff View",
        "category": "Cowriting"
      },
      {
        "command": "cowriting.pinDiffBaseline",
        "title": "Cowriting: Pin Diff Baseline to Now",
        "category": "Cowriting"
      }
  • Step 2: Contribute the keybinding

Add a keybindings array inside contributes (sibling of commands and menus):

    "keybindings": [
      {
        "command": "cowriting.toggleDiffView",
        "key": "ctrl+alt+d",
        "when": "editorTextFocus"
      }
    ]

Both commands stay palette-visible (no commandPalette when: false entry — unlike the internal seam commands). ctrl+alt+d is unbound in stock VS Code and user-remappable (§6.7).

  • Step 3: Verify the JSON is valid and the build still runs

Run: node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('package.json OK')" Expected: package.json OK.

Run: npm run build Expected: succeeds.

  • Step 4: Commit
git add package.json
git commit -m "feat(f6): contribute toggleDiffView + pinDiffBaseline commands + ctrl+alt+d (SLICE-3)

F6 §5/§6.4. Palette-visible commands; ctrl+alt+d (when editorTextFocus),
unbound in stock VS Code, user-remappable."

Task 7: Host E2E suite — SLICE-4

Files:

  • Create: test/e2e/fixtures/workspace/docs/diffview.md
  • Create: test/e2e/suite/diffView.test.ts

The suite OWNS docs/diffview.md (disjoint from sample/attrib/proposal/crossrung — the F2F5 fixture-disjointness rule). It drives the same programmatic seam ingress the proposals suite uses (cowriting.proposeAgentEdit + acceptById) — no LLM.

  • Step 1: Create the fixture document

Create test/e2e/fixtures/workspace/docs/diffview.md:

# Diff view fixture

The baseline opening paragraph stays put so the diff suite can anchor on it.

A target sentence Claude will rewrite via the seam.

A closing paragraph for the operator to edit by hand.
  • Step 2: Write the E2E suite

Create test/e2e/suite/diffView.test.ts:

import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";

const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/diffview.md";

async function openDoc(): Promise<vscode.TextDocument> {
  const uri = vscode.Uri.file(path.join(WS, DOC_REL));
  const doc = await vscode.workspace.openTextDocument(uri);
  await vscode.window.showTextDocument(doc);
  return doc;
}
async function getApi(): Promise<CowritingApi> {
  const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
  const api = (await ext.activate()) as CowritingApi;
  assert.ok(api?.diffViewController, "extension exports diffViewController");
  return api;
}
const settle = () => new Promise((r) => setTimeout(r, 300));

// Order-dependent (F2F4 pattern): later tests consume earlier state. Owns
// docs/diffview.md exclusively.
suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", () => {
  const TARGET = "A target sentence Claude will rewrite via the seam.";
  const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";

  test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    // renderIfOpen on open already called ensureBaseline; assert it captured.
    const baseline = api.diffViewController.getBaseline(DOC_REL);
    assert.ok(baseline, "baseline captured on first sight");
    assert.strictEqual(baseline!.reason, "opened");
    assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer");
  });

  test("toggle opens a diff tab (original scheme cowriting-baseline) over the live doc (PUC-1)", async () => {
    const api = await getApi();
    assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), false, "no diff open yet");
    await vscode.commands.executeCommand("cowriting.toggleDiffView");
    await settle();
    assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), true, "diff tab open");
    // The active tab is a TextDiff whose original is the baseline scheme.
    const active = vscode.window.tabGroups.activeTabGroup.activeTab;
    assert.ok(active && active.input instanceof vscode.TabInputTextDiff, "active tab is a diff");
    assert.strictEqual(
      (active!.input as vscode.TabInputTextDiff).original.scheme,
      "cowriting-baseline",
      "left side served by the baseline provider",
    );
  });

  test("typing leaves the baseline unchanged while the buffer diverges", async () => {
    const doc = await openDoc();
    const api = await getApi();
    const before = api.diffViewController.getBaseline(DOC_REL)!.text;
    const edit = new vscode.WorkspaceEdit();
    edit.insert(doc.uri, new vscode.Position(0, 0), "OPERATOR ADDED LINE\n");
    assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
    await settle();
    assert.strictEqual(api.diffViewController.getBaseline(DOC_REL)!.text, before, "baseline unchanged by typing");
    assert.notStrictEqual(doc.getText(), before, "buffer diverged");
  });

  test("accepting a proposal advances the baseline past the landed text (PUC-2, INV-18)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    const start = doc.getText().indexOf(TARGET);
    assert.ok(start >= 0, "fixture contains the target");
    const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
      uri: doc.uri.toString(),
      start,
      end: start + TARGET.length,
      newText: REPLACEMENT,
      model: "sonnet",
      sessionId: "e2e-diff",
      turnId: "turn-d1",
    });
    assert.ok(id, "propose returns an id");
    assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
    await settle();
    const baseline = api.diffViewController.getBaseline(DOC_REL)!;
    assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing");
    assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)");
    assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too");
    assert.strictEqual(baseline.text, doc.getText(), "baseline == buffer right after the landing");
  });

  test("an operator edit after the landing makes baseline ≠ buffer (the operator delta)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    const edit = new vscode.WorkspaceEdit();
    edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n");
    assert.ok(await vscode.workspace.applyEdit(edit));
    await settle();
    assert.notStrictEqual(
      api.diffViewController.getBaseline(DOC_REL)!.text,
      doc.getText(),
      "operator changes show against the advanced baseline",
    );
  });

  test("pin resets the baseline to now: baseline == buffer, reason pinned (PUC-3)", async () => {
    const doc = await openDoc();
    const api = await getApi();
    await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
    await settle();
    const baseline = api.diffViewController.getBaseline(DOC_REL)!;
    assert.strictEqual(baseline.reason, "pinned");
    assert.strictEqual(baseline.text, doc.getText(), "pinned baseline == current buffer (diff empties)");
  });

  test("the baseline is persisted on disk under the storage dir with the expected content (PUC-4)", async () => {
    const api = await getApi();
    const p = api.diffViewController.baselineFilePath(DOC_REL);
    assert.ok(p, "storage-backed baseline path is available");
    assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`);
    const onDisk = JSON.parse(fs.readFileSync(p!, "utf8"));
    assert.strictEqual(onDisk.docPath, DOC_REL);
    assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted");
    assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(DOC_REL)!.text, "on-disk == in-memory");
    // INV-19: nothing leaked into the repo's .threads sidecar tree.
    assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree");
  });

  test("toggle again closes the diff tab and reveals the normal editor (PUC-1)", async () => {
    const api = await getApi();
    if (!api.diffViewController.isDiffOpen(DOC_REL)) {
      await vscode.commands.executeCommand("cowriting.toggleDiffView");
      await settle();
    }
    assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), true, "diff open before close");
    await vscode.commands.executeCommand("cowriting.toggleDiffView");
    await settle();
    assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), false, "diff tab closed");
    const active = vscode.window.tabGroups.activeTabGroup.activeTab;
    assert.ok(
      active && active.input instanceof vscode.TabInputText,
      "a normal text editor tab is active after closing the diff",
    );
  });

  test("toggling on an untracked doc warns and opens no diff (PUC-5)", async () => {
    const api = await getApi();
    const untracked = await vscode.workspace.openTextDocument({ content: "scratch", language: "markdown" });
    await vscode.window.showTextDocument(untracked);
    await settle();
    await vscode.commands.executeCommand("cowriting.toggleDiffView");
    await settle();
    // No baseline-diff tab for an untitled doc anywhere.
    const anyDiff = vscode.window.tabGroups.all.some((g) =>
      g.tabs.some(
        (t) => t.input instanceof vscode.TabInputTextDiff && t.input.original.scheme === "cowriting-baseline" &&
          t.input.modified.toString() === untracked.uri.toString(),
      ),
    );
    assert.strictEqual(anyDiff, false, "no diff opened for the untracked doc");
  });
});
  • Step 3: Run the host E2E

Run: npm run test:e2e Expected: PASS — the new F6 suite green alongside the existing threads/attribution/proposals/crossrung suites; the no-workspace pass runs after (updated in Task 8).

If a timing flake appears (tab not yet registered), bump that test's settle() once — the F2F5 suites use the same 300ms settle.

  • Step 4: Commit
git add test/e2e/fixtures/workspace/docs/diffview.md test/e2e/suite/diffView.test.ts
git commit -m "test(f6): host E2E — toggle, advance-on-accept, pin, persist, untracked (SLICE-4)

F6 §6.8. Drives the programmatic seam ingress (propose + accept, no LLM):
opened baseline → toggle opens cowriting-baseline diff → type (baseline steady)
→ accept advances past the landing (INV-18) → operator delta → pin → on-disk
baseline outside .threads (INV-19) → toggle closes → untracked warns."

Task 8: Update the no-workspace stub regression — SLICE-4

Files:

  • Modify: test/e2e/suite-no-workspace/noWorkspace.test.ts

  • Step 1: Add the two F6 commands to the stub-registration assertion

In the for loop listing commands that must be registered as stubs, add the two F6 commands:

    for (const command of [
      "cowriting.createThread",
      "cowriting.reply",
      "cowriting.resolveThread",
      "cowriting.reopenThread",
      "cowriting.editSelection",
      "cowriting.toggleAttribution",
      "cowriting.applyAgentEdit",
      "cowriting.acceptProposal",
      "cowriting.rejectProposal",
      "cowriting.proposeAgentEdit",
      "cowriting.toggleDiffView",
      "cowriting.pinDiffBaseline",
    ]) {
      assert.ok(all.includes(command), `${command} is registered`);
    }
  • Step 2: Run the host E2E (both passes)

Run: npm run test:e2e Expected: PASS — including the no-workspace second pass asserting the two new stubs exist.

  • Step 3: Commit
git add test/e2e/suite-no-workspace/noWorkspace.test.ts
git commit -m "test(f6): assert toggleDiffView + pinDiffBaseline are no-folder stubs (#8, SLICE-4)"

Task 9: Manual smoke doc + README pointer — SLICE-4

Files:

  • Create: docs/MANUAL-SMOKE-F6.md

  • Modify: README.md

  • Step 1: Write the manual smoke doc

Create docs/MANUAL-SMOKE-F6.md:

# Manual smoke — F6 diff-view toggle (live)

Pre-req: a real machine (not CI). Step 4 uses a live Claude turn — Claude Code
installed + signed in (the `claude-code` provider rides that login, INV-8). The
rest of F6 needs no credentials and no network.

1. `npm run build`, launch the extension (F5 in VS Code opens the committed
   `sandbox/` playground), open `playground.md`.
2. Edit a sentence by hand, then **Cmd/Ctrl-Alt-D** (or run **Cowriting: Toggle
   Diff View**). ✅ A diff opens: the readonly baseline on the left, your live
   document on the right; the tab title reads `playground.md — my changes since
   opened <time>`. Your hand edit shows as a change.
3. Keep typing **in the diff's right pane**. ✅ Editing continues in place;
   decorations/threads still work. Toggle again. ✅ The diff tab closes and the
   normal editor is back.
4. Select a sentence → right-click → **Ask Claude to Edit Selection** → give an
   instruction → **✓ Accept Proposal**. Now toggle the diff. ✅ The accepted
   text is **not** shown as a change (the baseline advanced past the landing —
   INV-18); only your own subsequent edits show.
5. Run **Cowriting: Pin Diff Baseline to Now**. ✅ The diff empties (baseline =
   now); the title reads `pinned <time>`. Type → your changes show against the
   pin.
6. Reload the window (Developer: Reload Window). ✅ Toggle: the baseline is the
   same as before the reload (persisted in workspace storage, not the repo —
   INV-19; `git status` shows nothing new).
7. Toggle on a file outside the workspace folder. ✅ A warning, no diff.
  • Step 2: Add the README pointer

In README.md, in the develop/test bullet list (where npm run test:e2e and the smoke docs are described), add a bullet near the F3/F4 smoke references:

- **F6 diff-view toggle:** `Cmd/Ctrl-Alt-D` flips a tracked document into a
  `vscode.diff` against a coauthoring baseline (live document on the right) that
  auto-advances at every machine landing and can be pinned — manual smoke in
  [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md). Baselines live in VS
  Code workspace storage, never the repo (INV-19). Host E2E:
  `test/e2e/suite/diffView.test.ts` (no LLM).
  • Step 3: Full green gate

Run: npm run typecheck Expected: no errors.

Run: npx vitest run Expected: PASS (all unit suites, incl. baselineStore).

Run: npm run test:e2e Expected: PASS (all host E2E suites + the no-workspace pass).

  • Step 4: Commit
git add docs/MANUAL-SMOKE-F6.md README.md
git commit -m "docs(f6): manual smoke + README pointer (SLICE-4)"

Done criteria (issue #17 acceptance, §7.3)

  • Toggle (command and ctrl+alt+d) flips a tracked doc editor ↔ diff and back, without losing editor state (live doc is the diff's right side).
  • The diff is the operator's own changes against a coauthoring-meaningful baseline (auto-advances at machine landings — INV-18; pin on demand).
  • Sidecar / cross-rung contract / SCHEMA_VERSION untouched; baseline outside the repo (INV-19).
  • Unit (baselineStore) + host E2E (diffView) green; no LLM in CI.
  • Live smoke performed once on this machine per docs/MANUAL-SMOKE-F6.md.