Files
vscode-cowriting-plugin/docs/superpowers/plans/2026-06-11-f9-authorship-preview.md
T
2026-06-11 14:34:04 -07:00

789 lines
30 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.
# 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.
```