Files
vscode-cowriting-plugin/docs/superpowers/plans/2026-06-11-f6-diff-view-toggle.md
T
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

1109 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<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`:
```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) {}
/** `<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**
```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 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`:
```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<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):
```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<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:
```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 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`:
```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<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**
```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 <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:
```markdown
- **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**
```bash
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`.
```