add plan ./plans/2026-06-11-f9-authorship-preview.md

This commit is contained in:
Ben Stull
2026-06-11 14:41:18 -07:00
parent 89ec228269
commit e4c23cbbe1
+788
View File
@@ -0,0 +1,788 @@
# F9 Authorship Preview 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 an **Authorship** mode to the F7 rendered preview — a header toggle that re-renders the current markdown doc with each span colored by its F3 author (Claude blue / human green), inline and char-precise — so a writer can see what Claude composed even after the F6 baseline absorbs it.
**Architecture:** A new pure render engine `renderAuthorship(currentText, authorSpans)` in `src/trackChangesModel.ts` (vscode-free, unit-tested) injects Private-Use-Area sentinels at attribution-span boundaries through markdown-it, then post-processes them into `<span class="cw-by-claude|cw-by-human">`; code/mermaid fences stay atomic with a block-level author badge. `AttributionController.spansFor(document)` supplies the spans (resolving the URI-vs-keyOf key mismatch). `TrackChangesPreviewController` gains a per-panel mode and an attribution dependency; the webview header gets a segmented `[ Track changes | Authorship ]` toggle that posts `setMode` back.
**Tech Stack:** TypeScript, VS Code webview API, `markdown-it`, vitest (unit), `@vscode/test-electron` + mocha (host E2E). Design: `docs/superpowers/specs/2026-06-11-authorship-preview-design.md`.
**Conventions (read first):**
- The render engine is **vscode-free** and **deterministic** (INV-22/28) — spans are passed as data, tested under vitest like `test/trackChangesModel.test.ts`.
- Avoid `*/` inside block comments (an earlier F8 hiccup: a `**/` glob closed a comment early). Keep glob examples out of JSDoc.
- Attribution offsets are char offsets into `document.getText()` — the same string passed to the renderer as `currentText`, so they align.
- Run unit: `npm test`. Typecheck: `npm run typecheck`. Build: `npm run build`. Host E2E: `npm run test:e2e`.
- After each task: `npm test` green + `npm run typecheck` clean, then commit.
---
## File Structure
**Modify:**
- `src/trackChangesModel.ts` — add `AuthorKind`/`AuthorSpan`, `splitBlocksWithRanges`, `renderAuthorship`.
- `src/attributionController.ts` — add `spansFor(document): AuthorSpan[]`.
- `src/trackChangesPreview.ts` — attribution dependency; per-panel mode; `setMode` handling; render branch; post `mode`/`legend`.
- `src/extension.ts` — reorder so attribution exists before the F7 controller; pass it in.
- `media/preview.ts` — segmented toggle + `acquireVsCodeApi` postMessage; legend vs summary per mode.
- `media/preview.css``.cw-by-claude` / `.cw-by-human`, toggle, legend, fence-badge styles.
- `README.md` — F9 note.
**Create:**
- `test/e2e/suite/authorship.test.ts` — host E2E.
- `docs/MANUAL-SMOKE-F9.md` — manual smoke runbook.
**Untouched:** `renderTrackChanges`/`diffBlocks` behavior (track-changes mode), F6 baseline, F3 capture, the seam, persistence.
---
## Task 1: `splitBlocksWithRanges` (block offsets)
**Files:**
- Modify: `src/trackChangesModel.ts`
- Test: `test/trackChangesModel.test.ts`
- [ ] **Step 1: Write the failing test (append to `test/trackChangesModel.test.ts`)**
```typescript
import { splitBlocksWithRanges } from "../src/trackChangesModel";
describe("splitBlocksWithRanges — block offsets align with the source string", () => {
it("each block's [start,end) slices back to its raw from the source", () => {
const text = "# Title\n\nA prose paragraph.\n\n```js\ncode()\n```\n";
const blocks = splitBlocksWithRanges(text);
expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "code"]);
for (const b of blocks) {
expect(text.slice(b.start, b.end)).toBe(b.raw);
}
expect(blocks[1].raw).toBe("A prose paragraph.");
});
it("marks a mermaid fence and preserves its source offsets", () => {
const text = "intro\n\n```mermaid\ngraph TD; A-->B;\n```\n";
const blocks = splitBlocksWithRanges(text);
expect(blocks[1].type).toBe("mermaid");
expect(text.slice(blocks[1].start, blocks[1].end)).toBe(blocks[1].raw);
});
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npm test -- trackChangesModel`
Expected: FAIL (`splitBlocksWithRanges` is not exported).
- [ ] **Step 3: Implement `splitBlocksWithRanges` in `src/trackChangesModel.ts`**
Add after `splitBlocks`:
```typescript
export interface BlockWithRange extends Block {
/** char offset of the block's first char in the source string. */
start: number;
/** char offset one past the block's last char (source.slice(start,end) === raw). */
end: number;
}
/**
* Like splitBlocks, but each block carries its [start,end) char range in the
* SOURCE string (offsets align with document.getText(), so F3 attribution
* offsets map directly). Lines are tracked with their true source offsets
* (a trailing CR stays in the line, so \r\n sources keep correct offsets).
*/
export function splitBlocksWithRanges(text: string): BlockWithRange[] {
// line raw (excluding the \n terminator) + its source start offset.
const lines: { raw: string; start: number }[] = [];
let from = 0;
for (let i = 0; i <= text.length; i++) {
if (i === text.length || text[i] === "\n") {
lines.push({ raw: text.slice(from, i), start: from });
from = i + 1;
if (i === text.length) break;
}
}
const out: BlockWithRange[] = [];
const range = (lo: number, hi: number): { start: number; end: number } => {
const start = lines[lo].start;
const end = lines[hi].start + lines[hi].raw.length;
return { start, end };
};
let buf: number[] = []; // indices of buffered prose lines
const flushProse = () => {
if (buf.length) {
const { start, end } = range(buf[0], buf[buf.length - 1]);
const raw = text.slice(start, end);
if (raw.trim()) out.push({ ...makeBlockRange(raw, "prose"), start, end });
}
buf = [];
};
let i = 0;
while (i < lines.length) {
const line = lines[i].raw;
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
if (fence) {
flushProse();
const marker = fence[2][0];
const info = fence[3].trim().split(/\s+/)[0].toLowerCase();
const open = i;
i++;
while (i < lines.length) {
const closed = lines[i].raw.trim().startsWith(marker.repeat(3));
i++;
if (closed) break;
}
const close = i - 1;
const { start, end } = range(open, close);
const raw = text.slice(start, end);
out.push({ ...makeBlockRange(raw, info === "mermaid" ? "mermaid" : "code"), start, end });
continue;
}
if (line.trim() === "") {
flushProse();
i++;
continue;
}
buf.push(i);
i++;
}
flushProse();
return out;
}
function makeBlockRange(raw: string, type: BlockType): Block {
return makeBlock(raw, type);
}
```
(`makeBlockRange` is a thin alias so the spread `{ ...makeBlockRange(...), start, end }` reads clearly; it reuses the existing `makeBlock`.)
- [ ] **Step 4: Run to verify it passes**
Run: `npm test -- trackChangesModel`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat(f9): splitBlocksWithRanges — block offsets aligned to the source"
```
---
## Task 2: `renderAuthorship` — inline spans + atomic fences
**Files:**
- Modify: `src/trackChangesModel.ts`
- Test: `test/trackChangesModel.test.ts`
- [ ] **Step 1: Write the failing tests (append)**
```typescript
import { renderAuthorship, type AuthorSpan } from "../src/trackChangesModel";
const spanAt = (text: string, sub: string, author: "claude" | "human"): AuthorSpan => {
const start = text.indexOf(sub);
return { start, end: start + sub.length, author };
};
describe("renderAuthorship", () => {
it("empty spans → plain render (no author wrappers)", () => {
const text = "# Hi\n\nplain paragraph.\n";
const html = renderAuthorship(text, []);
expect(html).not.toContain("cw-by-claude");
expect(html).not.toContain("cw-by-human");
expect(html).toContain("plain paragraph.");
});
it("wraps a single Claude span inline", () => {
const text = "The cat sat on the mat.\n";
const html = renderAuthorship(text, [spanAt(text, "cat sat", "claude")]);
expect(html).toContain('<span class="cw-by-claude">cat sat</span>');
});
it("marks two authors in one paragraph at exact boundaries", () => {
const text = "Alpha beta gamma.\n";
const html = renderAuthorship(text, [
spanAt(text, "Alpha", "human"),
spanAt(text, "gamma", "claude"),
]);
expect(html).toContain('<span class="cw-by-human">Alpha</span>');
expect(html).toContain('<span class="cw-by-claude">gamma</span>');
});
it("clips a span to its block (does not bleed across blocks)", () => {
const text = "Para one.\n\nPara two.\n";
// a span covering the whole text; each block wraps only its own slice
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
expect(html).toContain('<span class="cw-by-claude">Para one.</span>');
expect(html).toContain('<span class="cw-by-claude">Para two.</span>');
});
it("a code fence overlapping a span gets a block badge, NOT inner sentinels (atomic)", () => {
const text = "```js\nconst x = 1;\n```\n";
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
expect(html).toContain("cw-by-claude");
expect(html).toContain("cw-badge");
expect(html).not.toContain(""); // no sentinel leaked into the code
expect(html).toContain("const x = 1;");
});
it("a mermaid fence authored by Claude renders as a diagram with a badge", () => {
const text = "```mermaid\ngraph TD; A-->B;\n```\n";
const html = renderAuthorship(text, [{ start: 0, end: text.length, author: "claude" }]);
expect(html).toContain('pre class="mermaid"');
expect(html).toContain("cw-by-claude");
expect(html).toContain("cw-badge");
});
it("is deterministic", () => {
const text = "Stable input paragraph.\n";
const spans: AuthorSpan[] = [spanAt(text, "input", "claude")];
expect(renderAuthorship(text, spans)).toBe(renderAuthorship(text, spans));
});
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npm test -- trackChangesModel`
Expected: FAIL (`renderAuthorship`/`AuthorSpan` not exported).
- [ ] **Step 3: Implement in `src/trackChangesModel.ts`**
```typescript
export type AuthorKind = "claude" | "human";
export interface AuthorSpan {
start: number;
end: number;
author: AuthorKind;
}
// Private-Use-Area sentinels (never appear in real content; markdown-it passes
// them through as plain text). Paired open/close per author.
const SENT = {
claude: { open: "", close: "" },
human: { open: "", close: "" },
} as const;
function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } | null {
if (authors.size === 0) return null;
if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" };
const only = [...authors][0];
return only === "claude"
? { cls: "cw-by-claude", label: "Claude" }
: { cls: "cw-by-human", label: "You" };
}
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
// Build insertions as (localOffset, marker); apply right-to-left so offsets stay valid.
const inserts: { at: number; marker: string }[] = [];
for (const s of spans) {
const lo = Math.max(0, s.start - blockStart);
const hi = Math.min(raw.length, s.end - blockStart);
if (hi <= lo) continue;
inserts.push({ at: lo, marker: SENT[s.author].open });
inserts.push({ at: hi, marker: SENT[s.author].close });
}
// Close markers must come BEFORE open markers at the same offset to keep nesting
// tidy; otherwise sort by offset descending and, at equal offset, close first.
inserts.sort((a, b) => (b.at - a.at) || (isClose(a.marker) ? -1 : 1));
let out = raw;
for (const ins of inserts) out = out.slice(0, ins.at) + ins.marker + out.slice(ins.at);
return out;
}
function isClose(m: string): boolean {
return m === SENT.claude.close || m === SENT.human.close;
}
/** Replace the rendered sentinels with author <span> tags. */
function sentinelsToSpans(html: string): string {
return html
.split(SENT.claude.open).join('<span class="cw-by-claude">')
.split(SENT.claude.close).join("</span>")
.split(SENT.human.open).join('<span class="cw-by-human">')
.split(SENT.human.close).join("</span>");
}
/**
* Pure authorship render (INV-26/28): the CURRENT text with each F3-attributed
* span colored by author. Prose blocks get inline `<span class="cw-by-*">`;
* code/mermaid fences stay ATOMIC (INV-27) — an overlapping span yields a
* block-level author badge, never inner sentinels. Deterministic.
*/
export function renderAuthorship(
currentText: string,
spans: AuthorSpan[],
opts: RenderOptions = {},
): string {
const render = opts.render ?? defaultRender;
const safe = (src: string): string => {
try {
return render(src);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
};
return splitBlocksWithRanges(currentText)
.map((b) => {
const overlapping = spans.filter((s) => s.end > b.start && s.start < b.end);
if (b.type !== "prose") {
const badge = authorBadge(new Set(overlapping.map((s) => s.author)));
const inner = safe(b.raw);
if (!badge) return `<div class="cw-blk">${inner}</div>`;
return `<div class="cw-blk ${badge.cls}"><span class="cw-badge">${badge.label}</span>${inner}</div>`;
}
const injected = injectSentinels(b.raw, b.start, overlapping);
return `<div class="cw-blk">${sentinelsToSpans(safe(injected))}</div>`;
})
.join("\n");
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `npm test -- trackChangesModel`
Expected: PASS. If the inline-span test fails because markdown-it wraps prose in `<p>`, the assertion still holds (`<span ...>cat sat</span>` appears inside the `<p>`). If a sentinel survives in output, check `sentinelsToSpans` ordering.
- [ ] **Step 5: Commit**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat(f9): renderAuthorship — inline author spans + atomic fence badges (INV-26/27/28)"
```
---
## Task 3: `AttributionController.spansFor`
**Files:**
- Modify: `src/attributionController.ts`
- [ ] **Step 1: Add the method**
In `src/attributionController.ts`, import the type and add a public method near `getSpans`:
```typescript
import type { AuthorSpan } from "./trackChangesModel";
```
```typescript
/**
* F9: the document's live attribution as authorship spans for the preview —
* current-buffer char ranges mapped to author kind (agent→claude). Computes
* the document key internally (so callers pass a TextDocument, not the key).
*/
spansFor(document: vscode.TextDocument): AuthorSpan[] {
return this.getSpans(this.keyOf(document)).map((s) => ({
start: s.range.start,
end: s.range.end,
author: s.authorKind === "agent" ? "claude" : "human",
}));
}
```
- [ ] **Step 2: Verify typecheck**
Run: `npm run typecheck`
Expected: clean.
- [ ] **Step 3: Commit**
```bash
git add src/attributionController.ts
git commit -m "feat(f9): AttributionController.spansFor — authorship spans for the preview"
```
---
## Task 4: Wire mode + attribution into the preview controller
**Files:**
- Modify: `src/trackChangesPreview.ts`, `src/extension.ts`
- [ ] **Step 1: Controller — attribution dep, mode state, render branch, setMode**
In `src/trackChangesPreview.ts`:
Imports + fields:
```typescript
import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel";
import type { AttributionController } from "./attributionController";
```
Add a per-panel mode map field:
```typescript
private readonly mode = new Map<string, "changes" | "authorship">();
```
Constructor — add the attribution param (after `extensionUri`):
```typescript
constructor(
private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
) {
```
In `show(...)`, after creating the panel, wire incoming messages (the toggle). Add to the `panel.onDidDispose` registration area:
```typescript
panel.webview.onDidReceiveMessage(
(m: { type?: string; mode?: "changes" | "authorship" }) => {
if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) {
this.mode.set(key, m.mode);
this.refresh(document);
}
},
null,
this.disposables,
);
```
Rewrite `refresh(document)` to branch on mode:
```typescript
refresh(document: vscode.TextDocument): void {
const key = document.uri.toString();
const panel = this.panels.get(key);
if (!panel) return;
const mode = this.mode.get(key) ?? "changes";
const current = document.getText();
if (mode === "authorship") {
const spans = this.attribution.spansFor(document);
void panel.webview.postMessage({
type: "render",
mode,
html: renderAuthorship(current, spans),
legend: { claude: spans.some((s) => s.author === "claude"), human: spans.some((s) => s.author === "human") },
});
this.lastModel.set(key, diffBlocks(this.diffView.getBaseline(key)?.text ?? current, current));
return;
}
const baseline = this.diffView.getBaseline(key);
const baselineText = baseline?.text ?? current;
const ops = diffBlocks(baselineText, current);
this.lastModel.set(key, ops);
const summary = {
added: ops.filter((o) => o.kind === "added").length,
removed: ops.filter((o) => o.kind === "removed").length,
changed: ops.filter((o) => o.kind === "changed").length,
};
void panel.webview.postMessage({
type: "render",
mode,
html: renderTrackChanges(baselineText, current),
epoch: this.epochLabel(baseline),
summary,
});
}
```
Add a test seam for the current mode (used by E2E):
```typescript
getMode(uriString: string): "changes" | "authorship" {
return this.mode.get(uriString) ?? "changes";
}
setMode(uriString: string, mode: "changes" | "authorship"): void {
this.mode.set(uriString, mode);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc) this.refresh(doc);
}
```
- [ ] **Step 2: extension.ts — reorder + pass attribution**
In `src/extension.ts`, the F7 controller is currently constructed in the F6/F7 block (before the authoring stack). Move the `TrackChangesPreviewController` construction to AFTER `attributionController` is created, and pass it:
1. Delete the existing block:
```typescript
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
);
context.subscriptions.push(trackChangesPreviewController);
```
2. Re-create it right after `const attributionController = new AttributionController(...)` + its `context.subscriptions.push(attributionController);`:
```typescript
// --- F7: rendered track-changes preview (Feature #21) + F9 authorship mode ---
// Constructed after attribution so the authorship view can read F3 spans.
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
attributionController,
);
context.subscriptions.push(trackChangesPreviewController);
```
(The `CowritingApi` return already includes `trackChangesPreviewController` — unchanged. The F6 `diffViewController` stays where it is.)
- [ ] **Step 3: Verify typecheck + unit**
Run: `npm run typecheck && npm test`
Expected: clean + 150+ tests green (Tasks 13 added tests).
- [ ] **Step 4: Commit**
```bash
git add src/trackChangesPreview.ts src/extension.ts
git commit -m "feat(f9): preview gains authorship mode + attribution dep + setMode wiring"
```
---
## Task 5: Webview toggle, legend, and CSS
**Files:**
- Modify: `src/trackChangesPreview.ts` (shell HTML), `media/preview.ts`, `media/preview.css`
- [ ] **Step 1: Shell HTML — add the segmented toggle + legend slot**
In `src/trackChangesPreview.ts` `shellHtml(...)`, replace the `<div id="cw-header">…</div>` with:
```html
<div id="cw-header">
<div id="cw-mode" role="group">
<button id="cw-mode-changes" class="cw-seg cw-seg-on" data-mode="changes">Track changes</button>
<button id="cw-mode-authorship" class="cw-seg" data-mode="authorship">Authorship</button>
</div>
<span id="cw-epoch">Track changes</span>
<span id="cw-summary"></span>
<span id="cw-legend" hidden></span>
</div>
```
- [ ] **Step 2: `media/preview.ts` — post setMode; render per mode**
Replace the message interface + handler and add the toggle wiring:
```typescript
interface RenderMessage {
type: "render";
mode: "changes" | "authorship";
html: string;
epoch?: string;
summary?: { added: number; removed: number; changed: number };
legend?: { claude: boolean; human: boolean };
}
const vscodeApi = acquireVsCodeApi();
const body = document.getElementById("cw-body")!;
const header = document.getElementById("cw-epoch")!;
const summary = document.getElementById("cw-summary")!;
const legend = document.getElementById("cw-legend")!;
const segs = Array.from(document.querySelectorAll<HTMLButtonElement>(".cw-seg"));
for (const seg of segs) {
seg.addEventListener("click", () => {
vscodeApi.postMessage({ type: "setMode", mode: seg.dataset.mode });
});
}
window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
const msg = event.data;
if (msg?.type !== "render") return;
body.innerHTML = msg.html;
for (const seg of segs) seg.classList.toggle("cw-seg-on", seg.dataset.mode === msg.mode);
const authorship = msg.mode === "authorship";
header.hidden = authorship;
summary.hidden = authorship;
legend.hidden = !authorship;
if (authorship) {
const parts: string[] = [];
if (msg.legend?.claude) parts.push('<span class="cw-by-claude cw-swatch">Claude</span>');
if (msg.legend?.human) parts.push('<span class="cw-by-human cw-swatch">You</span>');
legend.innerHTML = parts.join(" ") || "no attribution yet";
} else {
header.textContent = `Track changes since ${msg.epoch ?? ""}`;
summary.innerHTML =
`<span class="cw-add">+${(msg.summary?.added ?? 0) + (msg.summary?.changed ?? 0)}</span> ` +
`<span class="cw-del">${(msg.summary?.removed ?? 0) + (msg.summary?.changed ?? 0)}</span>`;
}
void renderMermaid();
});
```
(`acquireVsCodeApi` is a webview global; add `declare function acquireVsCodeApi(): { postMessage(m: unknown): void };` near the top of the file if the type isn't already present.)
- [ ] **Step 3: `media/preview.css` — author + toggle + legend styles**
Append:
```css
.cw-by-claude { background: var(--vscode-editorInfo-foreground, rgba(64, 120, 242, 0.18)); text-decoration: none; }
.cw-by-human { background: var(--vscode-gitDecoration-addedResourceForeground, rgba(46, 160, 67, 0.18)); text-decoration: none; }
/* Block-level author badges reuse .cw-badge; tint the whole fence block. */
.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; }
.cw-seg {
background: transparent; color: var(--vscode-foreground);
border: 1px solid var(--vscode-panel-border); padding: 0 0.5em; cursor: pointer; font-size: 0.9em;
}
.cw-seg:first-child { border-radius: 3px 0 0 3px; }
.cw-seg:last-child { border-radius: 0 3px 3px 0; border-left: none; }
.cw-seg-on { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
```
(Background tints use translucent fallbacks so they read against any theme; the inline spans are intentionally subtle.)
- [ ] **Step 4: Build + verify**
Run: `npm run build && npm run typecheck && npm test`
Expected: build emits `out/media/preview.js`; typecheck clean; unit green.
- [ ] **Step 5: Commit**
```bash
git add src/trackChangesPreview.ts media/preview.ts media/preview.css
git commit -m "feat(f9): webview segmented mode toggle + authorship legend + author CSS"
```
---
## Task 6: Host E2E
**Files:**
- Create: `test/e2e/suite/authorship.test.ts`
- [ ] **Step 1: Write the suite**
```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 settle = () => new Promise((r) => setTimeout(r, 300));
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal");
return api;
}
// F9 host E2E (no LLM): authorship mode marks Claude's landed span. Owns its own
// markdown doc, disjoint from the other suites' fixtures.
suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => {
const DOC_REL = "docs/f9authorship.md";
const TARGET = "The sentence Claude will compose over.";
test("authorship mode marks Claude's accepted edit; track-changes mode still works", async () => {
const abs = path.join(WS, DOC_REL);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, `# F9\n\n${TARGET}\n`, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
const api = await getApi();
const key = uri.toString();
// open the preview (track-changes mode by default)
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes", "defaults to track-changes");
// Claude composes via the seam (propose → accept)
const start = doc.getText().indexOf(TARGET);
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: key, start, end: start + TARGET.length, newText: "The sentence CLAUDE COMPOSED.", model: "sonnet", sessionId: "e2e-f9", turnId: "turn-f9",
});
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies");
await settle();
// attribution has a Claude span now
const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent");
assert.ok(claudeSpan, "Claude span recorded by F3");
// flip to authorship mode → the model should reflect Claude authorship
api.trackChangesPreviewController.setMode(key, "authorship");
await settle();
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "authorship");
const spans = api.attributionController.spansFor(doc);
assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview");
// back to track-changes — still functional (regression)
api.trackChangesPreviewController.setMode(key, "changes");
await settle();
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes");
assert.ok((api.trackChangesPreviewController.getLastModel(key) ?? []).length >= 1, "track-changes model still computed");
});
});
```
- [ ] **Step 2: Run E2E**
Run: `npm run test:e2e`
Expected: both EDH passes green (the new F9 suite + all prior suites). Debug per `superpowers:systematic-debugging` if red; do not weaken assertions.
- [ ] **Step 3: Commit**
```bash
git add test/e2e/suite/authorship.test.ts
git commit -m "test(f9): host E2E — authorship mode marks Claude's landed span"
```
---
## Task 7: Docs
**Files:**
- Create: `docs/MANUAL-SMOKE-F9.md`
- Modify: `README.md`
- [ ] **Step 1: `docs/MANUAL-SMOKE-F9.md`**
```markdown
# Manual smoke — F9 authorship view in the preview
Confirms the rendered preview's Authorship mode colors Claude's vs your text. One
live turn hits the SDK.
## Prereqs
- `npm run build`; launch the Extension Development Host (F5) with `sandbox/` open.
## Steps
1. Open a markdown file. Open **Cowriting: Open Track-Changes Preview** (`Ctrl+Alt+R`).
2. The header shows a `[ Track changes | Authorship ]` toggle. It opens in **Track
changes** (unchanged behavior).
3. Select a sentence → **Ask Claude to Edit Selection** → instruct → accept.
4. Click **Authorship**. Expect: Claude's accepted text is tinted (blue) and your
own typing is tinted (green); the header shows a legend (● Claude / ● You).
Text you never touched (original content) is plain.
5. Type a few words yourself, mixing into a Claude sentence. Expect: the colors
split mid-paragraph at the exact boundaries.
6. If the doc has a mermaid or code fence Claude authored, expect the whole fence
to carry a small author badge (not per-character).
7. Flip back to **Track changes**. Expect: the diff view is exactly as before.
```
- [ ] **Step 2: README F9 note** — add after the F8 section (match heading style):
```markdown
## F9 — Authorship view in the preview (Feature ~#27)
The rendered preview (F7) gains a second mode, switched by a `[ Track changes |
Authorship ]` toggle in its header. **Authorship** mode re-renders the current
document with each span colored by its F3 author — Claude (blue) vs you (green),
inline and char-precise — with a legend. Unlike track-changes (which diffs against
the F6 baseline, and so hides Claude's text once the baseline advances past a
landing), authorship reads F3 attribution directly, so Claude's contributions stay
visible. Code/mermaid fences carry a block-level author badge (atomic). Read-only,
sealed webview, no new persistence (INV-26..28).
Design: `docs/superpowers/specs/2026-06-11-authorship-preview-design.md`.
Live smoke: [`docs/MANUAL-SMOKE-F9.md`](docs/MANUAL-SMOKE-F9.md).
```
- [ ] **Step 3: Verify + commit**
```bash
npm run typecheck && npm test
git add docs/MANUAL-SMOKE-F9.md README.md
git commit -m "docs(f9): manual smoke + README authorship-mode note"
```
---
## Self-Review checklist (run before PR)
- **Spec coverage:** §2 modes → Tasks 4/5; §3.1 render engine → Tasks 1/2; §3.2 wiring → Tasks 3/4/5; §3.3 INV-26/27/28 → Tasks 2/4; §5 testing → Tasks 1/2/6 + smoke; §6 slices → Tasks 17.
- **Untouched:** `renderTrackChanges`/`diffBlocks` logic, F6 baseline, F3 capture, the seam, persistence (`git diff --stat` should show no behavioral change to those).
- **Done (spec §6):** authorship mode marks Claude (blue) + you (green) inline; header toggle flips modes; fences get a block badge; track-changes unchanged; unit + host E2E green; smoke performed once.
```