From 6f0b903596945ee79a6699ecc8ec7cc8114fda94 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 07:11:42 -0700 Subject: [PATCH 1/9] =?UTF-8?q?feat(f6):=20BaselineStore=20=E2=80=94=20vsc?= =?UTF-8?q?ode-free=20per-doc=20baseline=20persistence=20(SLICE-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../plans/2026-06-11-f6-diff-view-toggle.md | 1108 +++++++++++++++++ src/baselineStore.ts | 43 + test/baselineStore.test.ts | 53 + 3 files changed, 1204 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-f6-diff-view-toggle.md create mode 100644 src/baselineStore.ts create mode 100644 test/baselineStore.test.ts diff --git a/docs/superpowers/plans/2026-06-11-f6-diff-view-toggle.md b/docs/superpowers/plans/2026-06-11-f6-diff-view-toggle.md new file mode 100644 index 0000000..5e1b5bb --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-f6-diff-view-toggle.md @@ -0,0 +1,1108 @@ +# 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`: + +```typescript +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/.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`: + +```typescript +/** + * 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) {} + + /** `/baselines/.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** + +```bash +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: + +```typescript + /** + * 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: + +```typescript + this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output); +``` + +Change it to also dispose the emitter: + +```typescript + 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): + +```typescript + 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): + +```typescript + 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** + +```bash +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 F2–F5 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`: + +```typescript +/** + * 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(); + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + 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): + +```typescript + // 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** + +```bash +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: + +```typescript + // Replaced by the real implementation in Task 4 (toggle UX, SLICE-3). + private toggle(_editor: vscode.TextEditor | undefined): void { + /* stub */ + } +``` + +with the toggle + helpers: + +```typescript + /** + * 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 { + 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`). 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: + +```typescript +import { BaselineStore } from "./baselineStore"; +import { DiffViewController } from "./diffViewController"; +``` + +- [ ] **Step 2: Add `diffViewController` to the API type** + +In the `CowritingApi` interface, add the field: + +```typescript +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: + +```typescript + 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: + +```typescript + // --- 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: + +```typescript + 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: + +```typescript + 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: + +```typescript + return { threadController, attributionController, proposalController, versionGuard }; +``` + +Change it to: + +```typescript + 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** + +```bash +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: + +```json + { + "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`): + +```json + "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** + +```bash +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 F2–F5 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`: + +```markdown +# 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`: + +```typescript +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 { + 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 { + 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 (F2–F4 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("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 F2–F5 suites use the same 300ms settle. + +- [ ] **Step 4: Commit** + +```bash +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: + +```typescript + 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** + +```bash +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`: + +```markdown +# 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