37 Commits

Author SHA1 Message Date
BenStullsBets ef61b141b6 plan(0064): native-surfaces migration — ONE plan from coauthoring-native-surfaces v0.2.1 (D17/§6.10/§7.1 rung 3+4), 9 tasks, sunsets gated on green replacements
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:40:40 -07:00
BenStullsBets 55fe663bd0 claim vscode-cowriting-plugin session 0064 (placeholder) + sessions.json entry 2026-07-01 22:19:33 -07:00
BenStullsBets 7b767a759d update sessions/0063/SESSION-0063.0-TRANSCRIPT-2026-07-01T18-49--INPROGRESS.md 2026-07-01 19:13:17 -07:00
BenStullsBets 2a1d7623f5 claim vscode-cowriting-plugin session 0063 (placeholder) + sessions.json entry 2026-07-01 18:49:59 -07:00
BenStullsBets fdab92ae6d claim vscode-cowriting-plugin session 0062 (placeholder) + sessions.json entry 2026-06-27 17:03:29 -07:00
BenStullsBets c9050c3e08 Merge origin/main (session 0061 claim) into main 2026-06-27 05:18:51 -07:00
BenStullsBets b3c476e4d9 claim vscode-cowriting-plugin session 0061 (placeholder) + sessions.json entry 2026-06-27 05:14:59 -07:00
BenStullsBets 15e6f02bdf Merge: author-colored track changes — Phase 2 (editor pane)
Brings author-colored track changes inline into the main editor (reverses
INV-32): four per-author decoration types, decorateCommitted decorating all
committed changes-since-baseline by author (human green / Claude blue + struck
hints), standalone deletions neutral, proposals recolored Claude, overlap
stacking. Subagent-driven execution of Tasks 6-9; opus whole-branch review +
fix. typecheck + 265 unit + build + E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:54:10 -07:00
BenStullsBets c3cecdfaa2 fix: editor renders standalone committed deletions neutral; doc/comment cleanups (final review)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:10:25 -07:00
BenStullsBets 02fbe9c77e chore: sweep retired semantic-diff markup; full suite green
- Delete dead `authorBadge` function (no callers; grep-verified)
- Merge adjacent duplicate imports from trackChangesModel in test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:57:16 -07:00
BenStullsBets ae19df78fb feat: overlap renders stacked author marks (insert + delete) in both panes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:50:39 -07:00
BenStullsBets 7b59c324d5 feat: Task 7 — decorateCommitted fills committed author-colored changes in the editor (reverses INV-32)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:40:15 -07:00
BenStullsBets dcdd2ffc1c feat: per-author editor decoration types; proposals decorate in Claude blue/purple 2026-06-26 17:31:13 -07:00
BenStullsBets 1d284d2868 Merge: author-colored track changes — Phase 1 (preview pane)
Preview pane renders document changes as author-colored track changes:
underline=insert / strikethrough=remove, color=author (human green/red,
Claude blue/purple). Adjacency heuristic for deletion authorship; unchanged
text plain; pin->clean preserved. Phase 2 (editor pane) to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:38:19 -07:00
BenStullsBets 05ca497900 chore: drop dead cw-by-*/cw-mixed preview CSS; fix added-block comment to cw-ins-*
Final-review follow-up: the live preview colors added blocks via colorByAuthor(kind="ins")
emitting cw-ins-*; the cw-by-*/cw-mixed rules (only authorBadge, which has no callers)
were styled-but-never-emitted. Cosmetic only; build + 260 unit green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:47:05 -07:00
BenStullsBets c579f67a43 test(e2e): assert author-colored track changes render in the preview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:37:29 -07:00
BenStullsBets 5d1000096a feat: author-colored track-changes CSS for the preview (human green/red, Claude blue/purple) 2026-06-26 14:22:12 -07:00
BenStullsBets 2955e8d929 feat: color pending proposal blocks by author (Claude blue/purple)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:17:40 -07:00
BenStullsBets 44ef0a21db fix: render added blocks via markdown-safe sentinel path emitting cw-ins-* (heading/list structure preserved)
Defect: an ADDED block was colored via `render(wordDiffByAuthor("", raw, ...))`, which
wrapped the whole raw markdown in an inline `<ins>` BEFORE markdown-it parsed it — so
`## X` rendered as literal text, not `<h2>`. Same structural bug for list, blockquote,
and thematic-break blocks.

Fix (three-part, pure / vscode-free):
1. `blockContentStart(raw)`: new helper returning the offset past block-level markers
   (ATX headings `## `, unordered/ordered list markers, blockquotes). Open sentinels must
   not precede these markers or markdown-it cannot recognise the block construct.
2. `injectSentinels`: clamp the open-sentinel lower-bound to `blockContentStart(raw)`,
   so for `## New Section` the sentinel lands AFTER `## ` (at position 3) instead of at
   position 0, letting the heading parse correctly.
3. `sentinelsToSpans` / `colorByAuthor`: add optional `kind: "by" | "ins" = "by"`
   parameter so the sentinel path can emit `cw-ins-{author}` for added blocks while all
   existing `colorByAuthor`/`cw-by-*` call-sites are unchanged.
4. `renderReview` ADDED block path: `render(wordDiffByAuthor("", raw, ...))` →
   `colorByAuthor(raw, blk.start, landedSpans, render, "ins")`. The CHANGED non-atomic
   prose path (`wordDiffByAuthor(before, after, ...)`) is untouched — it correctly keeps
   the block markers at column 0 of the unchanged prefix.
5. `renderReviewOp`: removed the now-vestigial `colored` parameter (was always `render`
   after the added-block path moved to `changedHtml`); unchanged blocks now call
   `render(op.block.raw)` directly.

Regression test added: "renderReview: an added heading block renders as a heading AND
is author-colored as an insertion" — RED on old code, GREEN after fix. All 259 tests
pass; typecheck clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:09:19 -07:00
BenStullsBets 08557827da feat: author-colored changed/added blocks in review; unchanged blocks render plain 2026-06-26 13:54:24 -07:00
BenStullsBets 0ad040711f feat: authorAt + wordDiffByAuthor pure helpers (author-colored word diff w/ adjacency del) 2026-06-26 13:34:12 -07:00
BenStullsBets 94b1a9b0c2 docs: author-colored track-changes design + implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:32:34 -07:00
BenStullsBets 126d71fa06 add sessions/0060/SESSION-0060.0-TRANSCRIPT-2026-06-26T12-52--2026-06-26T13-14.md + replace placeholder/variant SESSION-0060.0-TRANSCRIPT-2026-06-26T12-52--INPROGRESS.md 2026-06-26 13:15:04 -07:00
benstull e671c4bf03 fix(#59): disable claude-code IDE auto-connect to stop spurious macOS Apple-Events prompt (#68) 2026-06-26 20:12:24 +00:00
BenStullsBets 5d83f290f5 claim vscode-cowriting-plugin session 0060 (placeholder) + sessions.json entry 2026-06-26 12:52:49 -07:00
BenStullsBets e38fe1b8ca add sessions/0059/SESSION-0059.0-TRANSCRIPT-2026-06-26T06-01--2026-06-26T08-32.md + replace placeholder/variant SESSION-0059.0-TRANSCRIPT-2026-06-26T06-01--INPROGRESS.md 2026-06-26 08:33:00 -07:00
benstull 844a2789fd chore: remove stray code-repo spec copy (canonical in content repo) (#67) 2026-06-26 15:30:59 +00:00
benstull 7b98249286 F12: inline editable proposed-change diff in the Markdown editor + Accept/Reject control parity (#64) (#66) 2026-06-26 15:28:14 +00:00
BenStullsBets d2ef9457c4 add sessions/0057/SESSION-0057.0-TRANSCRIPT-2026-06-26T04-35--2026-06-26T06-55.md + replace placeholder/variant SESSION-0057.0-TRANSCRIPT-2026-06-26T04-35--INPROGRESS.md 2026-06-26 06:56:48 -07:00
benstull 7e42d115c0 feat(ux): unify Ask-Claude-to-Edit on a split-below multi-line webview (#65) 2026-06-26 13:50:31 +00:00
BenStullsBets 56cc9eb6ad claim vscode-cowriting-plugin session 0059 (placeholder) + sessions.json entry 2026-06-26 06:01:30 -07:00
BenStullsBets 2069029d73 add sessions/0058/SESSION-0058.0-TRANSCRIPT-2026-06-26T05-12--2026-06-26T05-58.md + replace placeholder/variant SESSION-0058.0-TRANSCRIPT-2026-06-26T05-12--INPROGRESS.md 2026-06-26 05:58:49 -07:00
BenStullsBets a99430c247 claim vscode-cowriting-plugin session 0058 (placeholder) + sessions.json entry 2026-06-26 05:13:09 -07:00
benstull 9432300e3c feat(ux): unify "Ask Claude to Edit" + inline prompt at selection; fix keybindings (#62) 2026-06-26 12:11:43 +00:00
BenStullsBets a42e5f145d add sessions/0056/SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--2026-06-26T04-54.md + replace placeholder/variant SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--INPROGRESS.md 2026-06-26 04:55:04 -07:00
BenStullsBets 26628c0dbe claim vscode-cowriting-plugin session 0057 (placeholder) + sessions.json entry 2026-06-26 04:53:46 -07:00
benstull 644885c6ec #60: live turn progress (activity line + token count + OutputChannel stream + cancel) (#61)
Surface Claude live output/progress during the asking-Claude status: notification activity line + token count, a Cowriting: Claude OutputChannel streaming assistant text, and a cancellable turn. Pure turnProgress reducer + runEditTurn onProgress/AbortSignal (vscode-free) + both call sites. INV-43..47.

Fixes #60
2026-06-26 11:53:10 +00:00
46 changed files with 6047 additions and 244 deletions
+3
View File
@@ -8,3 +8,6 @@ test/e2e/fixtures/workspace/.threads/
# Sandbox playground churn (the EDH workspace - play freely, commit nothing)
sandbox/.threads/
# brainstorming visual-companion scratch
.superpowers/
@@ -0,0 +1,827 @@
# Author-colored track changes 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:** Render every change as a track-changes diff where **style = operation** (underline = inserted, strikethrough = removed) and **color = author** (human green/red, Claude blue/purple), in both the rendered preview pane and the main editor pane.
**Architecture:** Fuse the two color systems that exist today — the *semantic* diff (green=add/red=remove) and the *authorship* tint (cw-by-claude/cw-by-human) — into one author-colored diff. Insertions are colored by their attribution span (data already exists). Deletions are colored by an **adjacency heuristic**: a struck run inherits the author of the insertion it is paired with in the same changed hunk; a standalone removed block falls back to neutral. Phase 1 does this in the pure render engine (preview). Phase 2 reverses INV-32 to bring the same author-colored track changes inline into the editor.
**Tech Stack:** TypeScript, VS Code extension API, `markdown-it`, `diff` (jsdiff), vitest (unit), Playwright/electron host harness (E2E). Render engine (`src/trackChangesModel.ts`) is pure and vscode-free.
## Global Constraints
- **Render engine stays pure / vscode-free / deterministic** — `src/trackChangesModel.ts` must not import `vscode`; same inputs → identical HTML (INV-22).
- **Author color mapping (verbatim):** human added `#3fb950` / human removed `#f85149` / Claude added `#58a6ff` / Claude removed `#bc8cff`. Insert = 2px underline in the author color + faint author-hued background; remove = line-through in the author color + faint background.
- **Convention:** underline = inserted, strikethrough = removed, color = author.
- **"Color means changed since the pinned baseline."** Unchanged-since-baseline text renders plain (no author coloring). The pin→clean behavior (#48) is preserved: a pinned, zero-diff doc renders with no annotation.
- **Deletion authorship = adjacency heuristic** (locked decision): paired deletions inherit the paired insertion's author; standalone deletions render neutral.
- **Two roles only:** `human` and `claude` (`AuthorKind`). No arbitrary palettes, no user-configurable colors (YAGNI).
- **Test command:** `npm test` (vitest, unit). E2E: `npm run test:e2e`. Typecheck: `npm run typecheck`.
- Commit after each task. Branch off `main` (do not commit to `main` directly).
---
# Phase 1 — Preview pane (independently shippable)
Delivers the full author-colored track-changes language in the rendered preview, where the attribution data already exists.
## File Structure (Phase 1)
- `src/trackChangesModel.ts` — add `authorAt()` + `wordDiffByAuthor()` pure helpers; route the review path through them; color proposal blocks by author; stop coloring unchanged blocks. Add `author?` to `ProposalView`.
- `src/trackChangesPreview.ts` — pass each proposal's author into its `ProposalView`.
- `media/preview.css` — author×operation classes; retire the semantic green/red and flat authorship tints.
- `test/trackChangesModel.test.ts` — new unit tests; update tests asserting old semantic classes.
---
### Task 1: `authorAt()` + `wordDiffByAuthor()` pure helpers
**Files:**
- Modify: `src/trackChangesModel.ts` (add after `wordMergedMarkdown`, ~line 475)
- Test: `test/trackChangesModel.test.ts`
**Interfaces:**
- Consumes: existing `AuthorKind`, `AuthorSpan` (trackChangesModel.ts:557-562), `diffWordsWithSpace` (already imported, line 12).
- Produces:
- `authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null` — the author whose span covers `offset` (half-open), else null.
- `wordDiffByAuthor(beforeRaw: string, afterRaw: string, afterStart: number, spans: AuthorSpan[]): string` — inline HTML where each inserted run is `<ins class="cw-ins-{author}">` (author looked up at the run's `afterStart`-relative landed offset) and each removed run is `<del class="cw-del-{author}">` (author = the insertion it's paired with in the same change cluster; `cw-del-none` if standalone). Unchanged parts are emitted verbatim.
- [ ] **Step 1: Write the failing test**
Add to `test/trackChangesModel.test.ts` (import `authorAt`, `wordDiffByAuthor` from `../src/trackChangesModel`):
```ts
describe("authorAt", () => {
const spans: AuthorSpan[] = [
{ start: 0, end: 5, author: "human" },
{ start: 5, end: 10, author: "claude" },
];
test("returns the covering span's author (half-open)", () => {
expect(authorAt(0, spans)).toBe("human");
expect(authorAt(4, spans)).toBe("human");
expect(authorAt(5, spans)).toBe("claude");
expect(authorAt(9, spans)).toBe("claude");
});
test("returns null outside any span", () => {
expect(authorAt(10, spans)).toBeNull();
expect(authorAt(-1, spans)).toBeNull();
});
});
describe("wordDiffByAuthor", () => {
test("an inserted run is colored by the author covering its landed offset", () => {
// before "hello " ; after "hello brave " ; "brave " inserted at landed offset 6..12
const spans: AuthorSpan[] = [{ start: 6, end: 12, author: "claude" }];
const html = wordDiffByAuthor("hello ", "hello brave ", 0, spans);
expect(html).toContain('<ins class="cw-ins-claude">brave </ins>');
expect(html).toContain("hello ");
});
test("a paired deletion inherits the paired insertion's author (adjacency)", () => {
// "light" -> "dark", both in one cluster; "dark" is claude-inserted
const spans: AuthorSpan[] = [{ start: 0, end: 4, author: "claude" }];
const html = wordDiffByAuthor("light", "dark", 0, spans);
expect(html).toContain('<del class="cw-del-claude">light</del>');
expect(html).toContain('<ins class="cw-ins-claude">dark</ins>');
});
test("a standalone deletion (no paired insertion) is neutral", () => {
const html = wordDiffByAuthor("keep this word", "keep word", 0, []);
expect(html).toContain('<del class="cw-del-none">this </del>');
expect(html).not.toContain("cw-del-human");
expect(html).not.toContain("cw-del-claude");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- trackChangesModel`
Expected: FAIL — `authorAt`/`wordDiffByAuthor` not exported.
- [ ] **Step 3: Write minimal implementation**
Add to `src/trackChangesModel.ts` after `wordMergedMarkdown` (line 475):
```ts
/** The author whose span covers `offset` (half-open [start,end)), or null. */
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
return null;
}
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
/**
* Author-colored word diff for a changed PROSE block in the review.
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
* offset maps into `spans`. Insertions are colored by the author covering their
* offset; deletions inherit the author of the insertion they are paired with in
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
* cluster has no insertion. Pure, deterministic.
*/
export function wordDiffByAuthor(
beforeRaw: string,
afterRaw: string,
afterStart: number,
spans: AuthorSpan[],
): string {
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
// First pass: find each change cluster's insertion author (first added run).
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
let afterOff = afterStart;
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
{
let off = afterStart;
let i = 0;
while (i < parts.length) {
const p = parts[i];
if (!p.added && !p.removed) {
clusterAuthor[i] = null;
off += p.value.length;
i++;
continue;
}
// a cluster: scan to its end, capturing the first added run's author
let j = i;
let author: AuthorKind | null = null;
let scanOff = off;
while (j < parts.length && (parts[j].added || parts[j].removed)) {
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
if (parts[j].added) scanOff += parts[j].value.length;
j++;
}
for (let k = i; k < j; k++) clusterAuthor[k] = author;
off = scanOff;
i = j;
}
}
// Second pass: emit.
const out: string[] = [];
parts.forEach((p, i) => {
if (p.added) {
const a = authorAt(afterOff, spans);
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
afterOff += p.value.length;
} else if (p.removed) {
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
} else {
out.push(p.value);
afterOff += p.value.length;
}
});
return out.join("");
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- trackChangesModel`
Expected: PASS (new describes green).
- [ ] **Step 5: Commit**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat: authorAt + wordDiffByAuthor pure helpers (author-colored word diff w/ adjacency del)"
```
---
### Task 2: Route the review path through `wordDiffByAuthor`; stop coloring unchanged blocks
**Files:**
- Modify: `src/trackChangesModel.ts``renderReviewOp` (769-780), the `colored` closure + changed-op handling in `renderReview` (889-897)
- Test: `test/trackChangesModel.test.ts`
**Interfaces:**
- Consumes: `wordDiffByAuthor` (Task 1), `landedSpans` + `ranges` already computed in `renderReview`.
- Produces: review HTML where changed prose blocks emit `cw-ins-{author}`/`cw-del-{author}`, added blocks are author-colored as insertions, and **unchanged blocks render plain**.
- [ ] **Step 1: Write the failing test**
Add to the `renderReview` describe in `test/trackChangesModel.test.ts`:
```ts
test("renderReview: a changed prose block colors ins/del by author (claude replace)", () => {
// baseline "light" -> current "dark"; "dark" attributed to claude at 0..4
const html = renderReview("light", "dark", [{ start: 0, end: 4, author: "claude" }], []);
expect(html).toContain('cw-ins-claude');
expect(html).toContain('cw-del-claude'); // adjacency: struck "light" inherits claude
});
test("renderReview: an unchanged block renders plain (no author coloring)", () => {
const doc = "Alpha stays.";
const html = renderReview(doc, doc, [{ start: 0, end: 12, author: "human" }], []);
expect(html).not.toContain("cw-by-");
expect(html).not.toContain("cw-ins-");
});
test("renderReview: an added block is author-colored as an insertion", () => {
// baseline empty-ish -> add a new paragraph authored by human
const html = renderReview("Keep.", "Keep.\n\nFresh line.", [{ start: 6, end: 17, author: "human" }], []);
expect(html).toContain("cw-ins-human");
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- trackChangesModel`
Expected: FAIL — changed blocks still emit plain `<ins>/<del>` via `wordMergedMarkdown`; unchanged blocks still carry `cw-by-*`.
- [ ] **Step 3: Write minimal implementation**
In `src/trackChangesModel.ts`:
**(a)** Change `renderReviewOp` (769-780) so a changed PROSE block uses author coloring. Give it the colored-insertion path for `added` and the author word-diff for non-atomic `changed`. Replace the function body:
```ts
function renderReviewOp(
op: BlockOp,
render: (src: string) => string,
colored: (raw: string) => string,
src: string,
changedHtml?: string,
): string {
// A non-atomic changed prose block is pre-rendered with author-colored ins/del
// (changedHtml). Removed blocks and atomic changed fences stay neutral via renderOp
// (standalone deletion → neutral, per the adjacency heuristic). `src` is "" for a
// removed block (no live source — INV-36).
if (op.kind === "changed" && !op.atomic && changedHtml !== undefined) {
return `<div class="cw-blk cw-changed"${src}>${changedHtml}</div>`;
}
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
}
```
**(b)** In `renderReview` (the loop at 889-897), compute the author-colored changed HTML and stop coloring unchanged blocks. Replace the loop body:
```ts
let ci = 0; // pointer into ranges; advances for every op with a current-side block
const bodyParts: string[] = [];
for (const op of ops) {
const blockIndex = op.kind === "removed" ? -1 : ci;
const blk = op.kind === "removed" ? undefined : ranges[ci++];
// Only ADDED blocks are author-colored (they are insertions since baseline);
// UNCHANGED blocks render plain (color means "changed since baseline").
const colored = (raw: string): string =>
blk && !clean && op.kind === "added" ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw);
// A non-atomic changed prose block: author-colored word diff (ins by author,
// del by adjacency). `blk.start` is the landed offset of the after-side text.
const changedHtml =
op.kind === "changed" && !op.atomic && blk && !clean
? render(wordDiffByAuthor(op.before.raw, op.block.raw, blk.start, landedSpans))
: undefined;
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk), changedHtml));
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
}
for (const p of trailing) bodyParts.push(proposalBlockHtml(p, render));
return bodyParts.join("\n");
```
> Note: `render(wordDiffByAuthor(...))` passes the HTML-bearing string through markdown-it (same as `wordMergedMarkdown` was rendered). `colorByAuthor` on added blocks emits `cw-by-{author}` spans — Task 4 restyles those to insert styling in CSS.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- trackChangesModel`
Expected: PASS for the new tests.
- [ ] **Step 5: Update tests that assert the OLD review markup**
Two existing tests assert the old semantic output (test/trackChangesModel.test.ts:226-232). Update them:
```ts
test("renderReview: human addition since baseline renders author-colored ins", () => {
const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
expect(html).toContain("cw-ins-human");
});
test("renderReview: a standalone deletion since baseline renders neutral struck del", () => {
const html = renderReview("hello world", "hello", [], []);
expect(html).toMatch(/cw-del-none|cw-removed/);
});
```
Run: `npm test -- trackChangesModel`
Expected: PASS (whole file green).
- [ ] **Step 6: Commit**
```bash
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat: author-colored changed/added blocks in review; unchanged blocks render plain"
```
---
### Task 3: Color pending proposal blocks by author
**Files:**
- Modify: `src/trackChangesModel.ts``ProposalView` (731-742) + `proposalBlockHtml` (744-767)
- Modify: `src/trackChangesPreview.ts` — populate `author` on each `ProposalView`
- Test: `test/trackChangesModel.test.ts`
**Interfaces:**
- Consumes: `AuthorKind` (557).
- Produces: `ProposalView.author?: AuthorKind` (defaults to `"claude"`); `proposalBlockHtml` emits `cw-del-{author}` / `cw-ins-{author}`.
- [ ] **Step 1: Write the failing test**
```ts
test("renderReview: a proposal block colors its del/ins by author (claude default)", () => {
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
const html = renderReview("hello", "hello", [], proposals);
expect(html).toContain('cw-del-claude');
expect(html).toContain('cw-ins-claude');
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- trackChangesModel`
Expected: FAIL — `proposalBlockHtml` still emits `cw-del` / `cw-add`.
- [ ] **Step 3: Write minimal implementation**
In `src/trackChangesModel.ts`, add `author?` to `ProposalView` (after line 741):
```ts
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
original?: string;
/** Who made the proposal — colors the del/ins. Defaults to "claude". */
author?: AuthorKind;
}
```
Update `proposalBlockHtml` (753-754):
```ts
const who = p.author ?? "claude";
const before = p.replaced ? `<del class="cw-del-${who}">${safe(p.replaced)}</del>` : "";
const after = `<ins class="cw-ins-${who}">${safe(p.replacement)}</ins>`;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- trackChangesModel`
Expected: PASS.
- [ ] **Step 5: Populate `author` from the live proposal**
In `src/trackChangesPreview.ts` find where `ProposalView`s are built for `renderReview` (the `listProposals` mapping near line 373). Add `author` from the proposal's provenance. Locate the mapping and add:
```ts
author: p.author?.kind === "agent" ? "claude" : "human",
```
(where `p` is the live `Proposal` with `author: Provenance`). If the preview builds `ProposalView` via a helper, add the field there. Run `npm run typecheck` to confirm the field name matches the live `Proposal.author` shape (`model.ts:79-108`).
- [ ] **Step 6: Run typecheck + tests**
Run: `npm run typecheck && npm test -- trackChangesModel`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/trackChangesModel.ts src/trackChangesPreview.ts test/trackChangesModel.test.ts
git commit -m "feat: color pending proposal blocks by author (Claude blue/purple)"
```
---
### Task 4: CSS — author×operation color system (preview)
**Files:**
- Modify: `media/preview.css`
**Interfaces:**
- Consumes: classes emitted by Tasks 1-3: `cw-ins-human|claude|none`, `cw-del-human|claude|none`, `cw-by-human|claude` (added blocks), `cw-added`, `cw-removed`, `cw-changed`, `.cw-proposal`.
- Produces: the locked color mapping; unchanged text plain.
- [ ] **Step 1: Replace the semantic diff + authorship-tint rules**
In `media/preview.css`, replace lines 21-33 and 55-58 with author-colored rules. New block:
```css
/* Author-colored track changes — style = operation, color = author.
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
#cw-summary .cw-add { color: #3fb950; }
#cw-summary .cw-del { color: #f85149; }
.cw-blk { position: relative; }
/* base ins/del carry the operation; author classes carry the color */
ins { text-decoration: none; }
del { text-decoration: line-through; opacity: 0.7; }
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
/* whole-block ops: an added block is an insertion (author-colored inside via cw-by-*);
a standalone removed block is neutral struck (adjacency heuristic fallback) */
.cw-added { background: transparent; }
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
.cw-changed { outline: none; }
/* added-block author runs (colorByAuthor sentinels) read as insertions */
.cw-by-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
.cw-by-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; background: transparent; }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
```
- [ ] **Step 2: Recolor the proposal block accent to neutral (author lives inside it now)**
The proposal block's left border is currently blue (`--vscode-charts-blue`). Keep it as a neutral "pending" frame — the del/ins inside now carry the author color. Change `media/preview.css:79-80`:
```css
border-left: 3px solid var(--vscode-panel-border, #555);
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
```
- [ ] **Step 3: Manual visual verification**
Run the extension host (or load the smoke doc) and confirm in the preview: human insertions green-underlined, Claude insertions blue-underlined, Claude deletions purple-struck, standalone deletions neutral, unchanged text plain.
Run: `npm run build`
Expected: builds clean (CSS is bundled into the webview assets per the build).
- [ ] **Step 4: Commit**
```bash
git add media/preview.css
git commit -m "feat: author-colored track-changes CSS for the preview (human green/red, Claude blue/purple)"
```
---
### Task 5: Phase-1 host E2E — author colors render in the preview
**Files:**
- Test: the existing F10/F7 preview E2E suite (find under `test/e2e/` — the preview/review specs)
**Interfaces:**
- Consumes: the rendered preview webview HTML.
- [ ] **Step 1: Write the failing E2E assertions**
In the preview E2E spec, after producing a Claude change + a human change on a doc, assert the webview body contains `cw-ins-claude` and `cw-ins-human`, and that an unchanged paragraph has no `cw-ins-`/`cw-by-` class. (Follow the existing pattern in that spec for opening the preview and reading `panel.webview` HTML or the DOM via the harness.)
- [ ] **Step 2: Run to verify it fails (or update an assertion that checked old classes)**
Run: `npm run test:e2e`
Expected: FAIL if any existing assertion checked `cw-add`/`cw-by-*`-as-authorship; update those to the new classes.
- [ ] **Step 3: Make it pass**
Adjust any E2E assertion referencing retired classes (`cw-add`, semantic `ins`/`del` colors, `cw-by-*` on unchanged blocks) to the new author classes.
Run: `npm run test:e2e`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add test/e2e
git commit -m "test(e2e): assert author-colored track changes render in the preview"
```
**>>> Phase 1 is independently shippable here. Open a PR for the preview pane before starting Phase 2, or continue.**
---
# Phase 2 — Editor pane (reverses INV-32)
Brings the same author-colored track changes inline into the main editor: all changes-since-baseline (committed + pending), not just pending proposals.
## File Structure (Phase 2)
- `src/editorProposalController.ts` — per-author decoration types; recolor proposals; subscribe to baseline + attribution; decorate all committed insertions (author-colored) + committed deletion hints (adjacency); overlap stacks naturally.
- `src/extension.ts` — inject `DiffViewController` + `AttributionController` into `EditorProposalController`.
- Test: editor E2E suite.
---
### Task 6: Per-author decoration types; recolor proposals to Claude
**Files:**
- Modify: `src/editorProposalController.ts` (20-27 decoration types; 80-106 renderEditor)
- Test: editor E2E
**Interfaces:**
- Consumes: `decorationPlan` (already imported), proposal list.
- Produces: four insertion decoration types (`human`/`claude`) keyed by author, deletion-hint types colored by author; proposals decorate with Claude colors.
- [ ] **Step 1: Replace the two decoration types with per-author maps**
In `src/editorProposalController.ts`, replace lines 20-27:
```ts
private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
human: vscode.window.createTextEditorDecorationType({
backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950",
border: "0 0 2px 0", borderStyle: "solid",
}),
claude: vscode.window.createTextEditorDecorationType({
backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff",
border: "0 0 2px 0", borderStyle: "solid",
}),
};
private readonly delDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }),
claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }),
};
```
Update the constructor's disposables push (39-40) to dispose all four:
```ts
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.lensEmitter,
```
- [ ] **Step 2: Recolor proposal decorations to Claude in `renderEditor`**
Replace the body of `renderEditor` (81-106) so it groups ranges per author and applies the right decoration type. Proposals are Claude:
```ts
private renderEditor(editor: vscode.TextEditor): void {
const doc = editor.document;
const clearAll = () => {
for (const a of ["human", "claude"] as const) {
editor.setDecorations(this.insDeco[a], []);
editor.setDecorations(this.delDeco[a], []);
}
};
if (doc.languageId !== "markdown") return clearAll();
const key = this.proposals.keyFor(doc);
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
const del: Record<"human" | "claude", vscode.DecorationOptions[]> = { human: [], claude: [] };
// Pending proposals — always Claude (INV: proposals are agent-authored).
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
for (const i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
for (const d of plan.deletions) {
del.claude.push({
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
});
}
}
// Committed changes-since-baseline are added in Task 7 (pushes into ins/del[author]).
this.decorateCommitted(editor, ins, del); // no-op stub until Task 7
for (const a of ["human", "claude"] as const) {
editor.setDecorations(this.insDeco[a], ins[a]);
editor.setDecorations(this.delDeco[a], del[a]);
}
}
/** Overridden in Task 7 to add committed (non-proposal) author-colored changes. */
private decorateCommitted(
_editor: vscode.TextEditor,
_ins: Record<"human" | "claude", vscode.Range[]>,
_del: Record<"human" | "claude", vscode.DecorationOptions[]>,
): void {}
```
- [ ] **Step 3: Typecheck + build**
Run: `npm run typecheck && npm run build`
Expected: clean.
- [ ] **Step 4: E2E — proposal shows Claude colors**
Update/extend the editor E2E to assert a pending proposal decorates (smoke: the decoration types exist and apply; the harness asserts via the proposal-applied path used today). Run: `npm run test:e2e` → PASS.
- [ ] **Step 5: Commit**
```bash
git add src/editorProposalController.ts test/e2e
git commit -m "feat: per-author editor decoration types; proposals decorate in Claude blue/purple"
```
---
### Task 7: Decorate committed changes-since-baseline by author (reverse INV-32)
**Files:**
- Modify: `src/editorProposalController.ts` — inject baseline + attribution; implement `decorateCommitted`; subscribe to baseline/attribution change events
- Modify: `src/extension.ts` (line 122) — pass the two new deps
- Test: editor E2E
**Interfaces:**
- Consumes: `DiffViewController.getBaseline(uri)` (diffViewController.ts:133) + `onDidChangeBaseline` (32); `AttributionController.spansFor(document)` (attributionController.ts:405); pure `wordEditHunks` + `authorAt` (trackChangesModel.ts).
- Produces: committed insertions colored by `authorAt(currentOffset)`; committed deletion hints colored by adjacency (the paired insertion's author).
- [ ] **Step 1: Inject the new dependencies**
Change the constructor signature (38):
```ts
constructor(
private readonly proposals: ProposalController,
private readonly diffView: import("./diffViewController").DiffViewController,
private readonly attribution: import("./attributionController").AttributionController,
) {
```
Add subscriptions in the constructor disposables list:
```ts
this.diffView.onDidChangeBaseline(({ uri }) => {
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
}),
```
In `src/extension.ts` (122) pass the deps (both already constructed above — `diffViewController` and `attributionController`):
```ts
const editorProposalController = new EditorProposalController(
proposalController,
diffViewController,
attributionController,
);
```
(Confirm the local variable names in `extension.ts` for the diff-view + attribution controllers via `npm run typecheck`.)
- [ ] **Step 2: Implement `decorateCommitted`**
Replace the stub with the real implementation. It diffs baseline → current, attributes insertions, and colors deletion hints by adjacency:
```ts
private decorateCommitted(
editor: vscode.TextEditor,
ins: Record<"human" | "claude", vscode.Range[]>,
del: Record<"human" | "claude", vscode.DecorationOptions[]>,
): void {
const doc = editor.document;
const baseline = this.diffView.getBaseline(doc.uri.toString());
if (!baseline || baseline.reason === "pinned") return; // pin→clean: no committed marks
const current = doc.getText();
const spans = this.attribution.spansFor(doc); // AuthorSpan[] in current coords
const hunks = wordEditHunks(baseline.text, current); // start/end index INTO current
for (const h of hunks) {
// The replacement text occupies [h.start, h.start+replacement.length) in current.
// Re-derive the intra-hunk word diff to split ins vs del and find the author.
const original = baseline.text.slice(/* mapped */ 0, 0); // see note
const plan = decorationPlan(h.start, hunkOriginal(baseline.text, current, h), current.slice(h.start, h.end));
const author = authorAt(h.start, spans) ?? "human";
for (const i of plan.insertions) ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
for (const d of plan.deletions) {
del[author].push({
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
});
}
}
}
```
> **Implementation note for the engineer:** `wordEditHunks(baseline, current)` returns hunks whose `start`/`end` index into **current** with a `replacement` that is the *baseline* text for that span (see trackChangesModel.ts:241-264 — `replacement` accumulates `removed` parts = baseline text, `end` advances over them). So for a committed change, the *original* (baseline) text of a hunk is `h.replacement` and the *applied* (current) text is `current.slice(h.start, h.end)`. Call `decorationPlan(h.start, h.replacement, current.slice(h.start, h.end))` directly — drop the `hunkOriginal`/`original` placeholder lines above. Verify this against a unit test before wiring (Step 3).
- [ ] **Step 3: Unit-test the hunk→plan derivation (pure)**
Because the offset semantics are subtle, add a pure unit test in `test/trackChangesModel.test.ts` proving `wordEditHunks` + `decorationPlan` reconstruct a committed change. Add an exported helper if needed:
```ts
test("committed change: wordEditHunks replacement is baseline text; current slice is applied", () => {
const baseline = "the light mode";
const current = "the dark mode";
const [h] = wordEditHunks(baseline, current);
expect(h.replacement).toContain("light"); // baseline side
expect(current.slice(h.start, h.end)).toContain("dark"); // applied side
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
expect(plan.insertions.length).toBeGreaterThan(0);
expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true);
});
```
Run: `npm test -- trackChangesModel` → PASS. Then finalize `decorateCommitted` to use `h.replacement` + `current.slice(h.start, h.end)` (removing the placeholder lines).
- [ ] **Step 4: Add the attribution-change refresh**
Editor decorations must refresh when attribution changes (e.g. after a human edit). If `AttributionController` exposes a change event, subscribe to it and call `renderEditor`; otherwise refresh on `vscode.workspace.onDidChangeTextDocument` for the active editor (debounced like `scheduleApply`). Add to the constructor disposables:
```ts
vscode.workspace.onDidChangeTextDocument((e) => {
const ed = vscode.window.activeTextEditor;
if (ed && e.document === ed.document) this.scheduleRender(ed);
}),
```
with a small `scheduleRender(ed)` that debounces `renderEditor(ed)` (mirror `scheduleApply`, 50ms).
- [ ] **Step 5: Typecheck + build + E2E**
Run: `npm run typecheck && npm run build && npm run test:e2e`
Expected: clean. E2E: a committed human edit shows green underline; an accepted Claude replace shows blue underline + purple struck hint; a pinned baseline shows nothing.
- [ ] **Step 6: Commit**
```bash
git add src/editorProposalController.ts src/extension.ts test/trackChangesModel.test.ts test/e2e
git commit -m "feat: author-colored committed track changes inline in the editor (reverse INV-32)"
```
---
### Task 8: Overlap — stacked marks where both authors touch one span
**Files:**
- Test: `test/trackChangesModel.test.ts` (preview overlap) + editor E2E (editor overlap)
- Modify (if needed): `media/preview.css` (ensure `<ins>` nested in `<del>` and vice-versa render both marks)
**Interfaces:**
- Consumes: existing emitted classes.
- Produces: a span both inserted (author A) and being deleted (author B) shows both A's underline and B's strikethrough.
- [ ] **Step 1: Editor overlap — verify decorations stack**
In the editor, an overlap arises when a committed insertion range (author A) is also covered by a pending proposal deletion (Claude). VS Code applies multiple decoration types to overlapping ranges, so the author-A insertion underline and the Claude deletion hint already coexist. Add an editor E2E asserting both decoration types are present on the overlap region. No code change expected; if the proposal's optimistic apply *replaces* the text (so A's range no longer exists), document that committed-insert + pending-delete overlap manifests as the proposal's struck original (Claude purple) adjacent to A's surviving insertion — assert that instead.
- [ ] **Step 2: Preview overlap — CSS nesting**
Add a CSS test fixture / assertion: a `<del class="cw-del-claude"><span class="cw-ins-human">…</span></del>` renders with both strikethrough (Claude purple) and underline (human green). Add to `media/preview.css` if the nested case collapses:
```css
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude,
.cw-del-claude .cw-by-human, .cw-del-human .cw-by-claude { text-decoration: inherit; }
```
- [ ] **Step 3: Run tests**
Run: `npm test && npm run test:e2e`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add media/preview.css test/trackChangesModel.test.ts test/e2e
git commit -m "feat: overlap renders stacked author marks (insert + delete) in both panes"
```
---
### Task 9: Sweep retired markup + full suite + manual smoke
**Files:**
- `test/**`, `media/preview.css`, any code referencing retired classes.
- [ ] **Step 1: Grep for retired classes/behaviors**
Run:
```bash
grep -rn "cw-add\b\|cw-del\b\|diffEditor.insertedTextBackground\|gitDecoration.deletedResourceForeground" src/ media/ test/
```
Reconcile every hit: `cw-add`/`cw-del` (without `-author`) only legitimately remain in `#cw-summary`. The editor's old semantic ThemeColors are gone (replaced in Task 6).
- [ ] **Step 2: Full unit + typecheck + build**
Run: `npm run typecheck && npm test && npm run build`
Expected: all green.
- [ ] **Step 3: Full E2E**
Run: `npm run test:e2e`
Expected: all green (update any remaining old-class assertions).
- [ ] **Step 4: Manual smoke (both panes)**
Open the sandbox doc, make a human edit + ask Claude to edit. Confirm: editor shows green/blue underlines + purple/red struck hints; preview shows the same; unchanged text plain in both; pin baseline → both panes clean; accept a proposal → it recolors as a committed change until re-pin.
- [ ] **Step 5: Commit + open PR**
```bash
git add -A
git commit -m "chore: sweep retired semantic-diff markup; full suite green"
```
---
## Self-Review
**1. Spec coverage:**
- Model (style=op, color=author) → Tasks 1-4 (preview), 6-7 (editor). ✓
- Human green/red + Claude blue/purple, exact hexes → Global Constraints + Task 4 CSS + Task 6 decorations. ✓
- Both panes → Phase 1 (preview) + Phase 2 (editor). ✓
- Reverses INV-32 → Task 7. ✓
- Both overlap directions, stacked → Task 8. ✓
- Color = changed-since-baseline; pin→clean preserved → Task 2 (unchanged plain), Task 7 (`reason === "pinned"` guard). ✓
- Deletion authorship = adjacency heuristic; standalone neutral → Task 1 (`wordDiffByAuthor` cluster author + `cw-del-none`), Task 7 (`authorAt(h.start)`). ✓
- Components: trackChangesModel, editorProposalController, attributionController (read via spansFor), preview.css, extension.ts wiring → covered. ✓
- Testing: unit (pure render), editor decorations (E2E), host E2E, retire old assertions → Tasks 1-3, 5, 7-9. ✓
**2. Placeholder scan:** Task 7 Step 2 intentionally shows a *wrong* placeholder (`hunkOriginal`/`original`) and immediately corrects it in the implementation note + Step 3 unit test that pins the exact offsets — this is a guided derivation, not an unresolved placeholder. No other TBD/TODO.
**3. Type consistency:** `AuthorKind` (`"claude" | "human"`) used throughout; decoration maps keyed `"human" | "claude"`; `authorAt` returns `AuthorKind | null``?? "human"`/`?? "none"` at every call site; `ProposalView.author?: AuthorKind`; `wordDiffByAuthor(beforeRaw, afterRaw, afterStart, spans)` signature matches both its test and its `renderReview` call site.
## Execution Handoff
(Filled in after you review.)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
# Author-colored track changes (both panes) — design
**Date:** 2026-06-26
**Status:** Design approved; ready for implementation planning
**Mockups:** `.superpowers/brainstorm/15095-1782504559/content/panes-states-v2.html`
## Problem
Today the plugin uses two unrelated color systems:
- **The diff** (proposed/changed text in both panes) uses *semantic* colors —
green = added, red = removed — regardless of who made the change.
- **Authorship** (F3) uses *author* colors (blue = Claude, green = human) but
only as a flat tint in the **preview** pane, and only on committed text, not
as a diff.
So you cannot look at a change and immediately see **who** made it. The product
goal is an experience like two humans collaborating in a track-changes document
(Google Docs suggesting mode / Word track changes): every edit is a diff, and a
glance tells you both *what changed* and *who changed it*.
## The model
Every change is rendered as a diff — insertions **and** deletions — where:
- **Style encodes the operation:** underline = inserted, strikethrough = removed.
- **Color encodes the author.**
| | Added | Removed |
| ------ | ---------------- | ---------------------- |
| Human | green underline | red strikethrough |
| Claude | blue underline | purple strikethrough |
Human keeps today's exact green/red. Claude gets blue/purple (blue is already
Claude's established attribution hue; purple reads as a removal without colliding
with human red).
**Color means "changed since the pinned baseline,"** not "authored forever." Once
a change is accepted and the baseline re-pins, its marks clear and the text
returns to plain. This preserves the existing pin→clean behavior (#48): a pinned,
zero-diff document renders with no annotation.
### Concrete colors
Carry the existing human values; add Claude's pair. (Final hex values are a
polish detail for implementation; these are the design intent.)
- Human added: `#3fb950` underline, bg `rgba(63,185,80,0.14)`
- Human removed: `#f85149` strikethrough, bg `rgba(248,81,73,0.11)`
- Claude added: `#58a6ff` underline, bg `rgba(88,166,255,0.15)`
- Claude removed: `#bc8cff` strikethrough, bg `rgba(188,140,255,0.13)`
Each change run is a subtle author-hued background tint **plus** a solid 2px
underline (insert) or strikethrough (delete) in the full author color — the solid
line carries the signal even where backgrounds are faint.
## Both panes (decision A)
Author-colored track changes appear in **both** the main editor pane and the
rendered preview pane.
- **Main editor pane:** gains full inline track-changes for *all*
changes-since-baseline — human and Claude, committed and pending — not just
pending proposals. **This reverses INV-32** ("editor fully clean") from F10:
the editor was deliberately kept clean, with all who-wrote-what coloring living
only in the preview. We are intentionally undoing that.
- **Rendered preview pane:** shows the same model (it already renders a diff +
authorship; this unifies them so ins/del runs are author-colored rather than
semantically colored).
Pending Claude proposals keep their Accept/Reject affordance in both panes —
CodeLens (`✓ Accept ▾ / ✗ Reject ▾`) in the editor, buttons in the preview.
## States (must all render correctly)
Base states: human-added, human-removed, Claude-added, Claude-removed.
**Overlap** — a single span both authors touched — stacks both marks:
- Human added it, Claude is removing it → green underline **+** purple strikethrough.
- Claude added it, human is removing it → blue underline **+** red strikethrough.
Block-level: a whole added block carries the author's add treatment (left border +
tint); a whole removed block carries the author's remove treatment (struck + tint).
Resting state: unchanged-since-baseline text renders plain. After Accept + re-pin,
changed text returns to plain.
## Architecture
The core change is **fusing the currently-separate semantic-diff and authorship
systems** so that every inserted/deleted run carries its author, and rendering
colors by that author rather than by add/remove semantics.
Components affected (per the current code map):
- **`src/trackChangesModel.ts`** — `renderReview` / `renderOp` /
`wordMergedMarkdown` / `colorByAuthor`. Today `<ins>`/`<del>` are emitted with
semantic green/red classes independent of author. Change: ins/del runs become
author-aware and emit author-colored classes; overlap emits a stacked-mark
class.
- **`src/editorProposalController.ts`** — today only pending proposals decorate,
using semantic `ThemeColor`s. Change: per-author/op `TextEditorDecorationType`s
(human-add/human-del/claude-add/claude-del + overlap), and decorate **all**
changes-since-baseline, not just proposals (the INV-32 reversal).
- **`src/attributionController.ts`** — supplies the per-character/per-run author
spans the diff needs to attribute insertions (and, see below, deletions).
- **`media/preview.css`** — recolor: keep human green/red, add Claude
blue/purple, encode underline=insert / strikethrough=delete, add overlap
(stacked-mark) classes for both directions.
## Open technical question (for the plan / tech-design stage)
**Deletion authorship.** Removed text is no longer in the buffer, so the renderer
must know *who deleted it* to color the strikethrough.
- For **pending proposals**, the deleter is trivially Claude (the proposal owns
the deletion).
- For **committed deletions** (baseline → current), we must confirm the F3 seam /
attribution model carries the **deleting actor**. F3 attributes
*current-buffer* characters; deleted characters are gone. If the seam's edit
events carry the actor that performed each removal, we attribute from that; if
not, deriving deletion authorship is the main thing the implementation plan has
to solve. Until resolved, a safe fallback is to color committed deletions by the
**turn actor** that produced the diff hunk.
This is the one genuinely open architectural item; everything else is
presentation.
## Out of scope (YAGNI)
- More than two authors / arbitrary per-collaborator palettes. Two roles
(human, Claude) only.
- User-configurable colors / theme settings. Ship one good scheme.
- Changing accept/reject *mechanics* — this is purely how changes are
**visualized**; the F4 proposal lifecycle is unchanged.
- Per-word authorship inside an accepted, re-pinned (plain) document.
## Testing
- **Unit (pure render):** `trackChangesModel` renders each state to the expected
author-colored markup — the four base states, both overlap directions, block
add/remove, and resting/plain. Reuse the existing pure-render test pattern.
- **Editor decorations:** the decoration plan produces the right per-author/op
ranges, including the INV-32-reversal (committed changes now decorate) and
stacked overlap ranges.
- **Host E2E:** open a co-edited doc, assert both panes show author-colored
marks; accept a proposal + re-pin → marks clear (pin→clean preserved).
- Update/replace any test that asserts the **old** semantic green=add/red=remove
classes or the INV-32 clean-editor behavior.
+40 -20
View File
@@ -18,19 +18,40 @@ body {
display: flex;
gap: 1rem;
}
#cw-summary .cw-add { color: var(--vscode-gitDecoration-addedResourceForeground); }
#cw-summary .cw-del { color: var(--vscode-gitDecoration-deletedResourceForeground); }
/* Author-colored track changes — style = operation, color = author.
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
#cw-summary .cw-add { color: #3fb950; }
#cw-summary .cw-del { color: #f85149; }
.cw-blk { position: relative; }
ins, .cw-added {
background: var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.15));
text-decoration: none;
}
del, .cw-removed {
background: var(--vscode-diffEditor-removedTextBackground, rgba(255, 0, 0, 0.15));
text-decoration: line-through;
opacity: 0.7;
}
.cw-changed { outline: 2px solid var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.25)); outline-offset: 2px; }
/* base ins/del carry the operation; author classes carry the color */
ins { text-decoration: none; }
del { text-decoration: line-through; opacity: 0.7; }
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B},
both marks must show. cw-ins-* sets text-decoration:none which would otherwise
suppress the parent del's strikethrough. `inherit` restores the parent's
line-through while the child's border-bottom underline is unaffected.
The engine currently emits SIBLING ins/del (never nested) so this rule is a
forward defensive guarantee only. See task-8-report.md for details. */
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; }
/* whole-block ops: an added block is an insertion (its author runs are emitted as
cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral
struck (adjacency heuristic fallback) */
.cw-added { background: transparent; }
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
.cw-changed { outline: none; }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
.cw-badge {
position: absolute;
top: 0;
@@ -52,12 +73,6 @@ del, .cw-removed {
pre.mermaid { text-align: center; background: transparent; }
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
/* F9 authorship mode — inline author tints + block-level fence badges + toggle. */
.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; }
.cw-blk.cw-by-claude, .cw-blk.cw-by-human, .cw-blk.cw-mixed { outline: 2px solid currentColor; outline-offset: 2px; background: transparent; }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
#cw-header button {
@@ -76,8 +91,8 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
.cw-proposal {
position: relative;
border-left: 3px solid var(--vscode-charts-blue, #4daafc);
background: color-mix(in srgb, var(--vscode-charts-blue, #4daafc) 12%, transparent);
border-left: 3px solid var(--vscode-panel-border, #555);
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px;
}
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
@@ -89,6 +104,11 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
}
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
.cw-btngroup { display: inline-flex; }
.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; }
.cw-actions .cw-accept { font-weight: 600; }
.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
/* F7.1 (#22) intra-diagram mermaid diff legend. */
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
+4 -3
View File
@@ -94,13 +94,14 @@ acceptAllEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "acceptAll" });
});
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block.
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return;
const block = btn.closest<HTMLElement>(".cw-proposal");
const id = block?.dataset.proposalId;
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId;
const action = btn.dataset.action;
if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" });
if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" });
if (id && (action === "accept" || action === "reject")) {
vscodeApi.postMessage({ type: action, proposalId: id });
}
+52 -15
View File
@@ -59,6 +59,11 @@
"title": "Apply Agent Edit (internal seam)",
"category": "Cowriting"
},
{
"command": "cowriting.edit",
"title": "Ask Claude to Edit",
"category": "Cowriting"
},
{
"command": "cowriting.editSelection",
"title": "Ask Claude to Edit Selection",
@@ -98,6 +103,21 @@
"command": "cowriting.acceptAllProposals",
"title": "Accept All Claude Proposals",
"category": "Cowriting"
},
{
"command": "cowriting.rejectAllProposals",
"title": "Reject All Claude Proposals",
"category": "Cowriting"
},
{
"command": "cowriting.proposalAcceptMenu",
"title": "Accept Claude Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.proposalRejectMenu",
"title": "Reject Claude Proposal",
"category": "Cowriting"
}
],
"menus": {
@@ -123,12 +143,32 @@
"when": "editorLangId == markdown"
},
{
"command": "cowriting.editDocument",
"command": "cowriting.edit",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.editSelection",
"when": "false"
},
{
"command": "cowriting.editDocument",
"when": "false"
},
{
"command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.rejectAllProposals",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.proposalAcceptMenu",
"when": "false"
},
{
"command": "cowriting.proposalRejectMenu",
"when": "false"
}
],
"editor/title": [
@@ -140,13 +180,8 @@
],
"editor/title/context": [
{
"command": "cowriting.editSelection",
"when": "editorHasSelection && resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
"command": "cowriting.editDocument",
"when": "!editorHasSelection && resourceLangId == markdown",
"command": "cowriting.edit",
"when": "resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
@@ -164,13 +199,8 @@
],
"editor/context": [
{
"command": "cowriting.editSelection",
"when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"group": "1_cowriting@1"
},
{
"command": "cowriting.editDocument",
"when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"command": "cowriting.edit",
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"group": "1_cowriting@1"
},
{
@@ -203,7 +233,14 @@
{
"command": "cowriting.showTrackChangesPreview",
"key": "ctrl+alt+r",
"mac": "cmd+alt+r",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.edit",
"key": "ctrl+alt+e",
"mac": "cmd+alt+e",
"when": "editorTextFocus && editorLangId == markdown"
}
]
},
@@ -0,0 +1,106 @@
# Session 0056.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T04-24 (PST)
> End: 2026-06-26T04-54 (PST)
> Type: planning-and-executing
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md`
(Continuation of brainstorming session 0055, which graduated the spec; the
operator said "implement it" → this coding session.)
## Plan
Plan + execute **#60** (P1 feature — live turn progress) from the graduated
Solution Design `coauthoring-live-progress.md`. Four cuts: (1) pure
`turnProgress.ts` reducer + tests; (2) `runEditTurn` + `onProgress`/`AbortSignal`;
(3) `liveProgressUi` + setting + both call sites; (4) host-E2E + smoke.
Branch `s60-live-progress` → PR → main.
## Pre-state
- Clean `main` at session start (fast-forwarded 13 commits — the sessions/ repo
is the code repo itself). Baseline: 222 unit green.
- #60 has a graduated Solution Design → §4.3 R2(b) satisfied.
## Session arc
1. **Claim + plan.** Claimed 0056 (no in-flight). Wrote the implementation plan
with `superpowers:writing-plans``docs/superpowers/plans/2026-06-26-live-turn-progress.md`
(self-reviewed: full spec coverage, no placeholders). Grounded in the exact SDK
types (`AgentToolCallPart.toolName`, `AgentUsage.inputTokens+outputTokens`), the
vitest/host-E2E patterns, and the injectable `editTurn` seam. Branch
`s60-live-progress`.
2. **Task 1 — pure reducer.** `src/turnProgress.ts` + `test/turnProgress.test.ts`
(13 cases). Fixed the type-only event import: `AgentRuntimeEvent` is exported
from **`@cline/shared`**, not `@cline/sdk` (which doesn't re-export it) — caught
by typecheck; amended the commit.
3. **Task 2 — `runEditTurn`.** Added `RunEditTurnOptions {onProgress, signal}`;
subscribes to the agent, folds events through the reducer, wires `signal`
`agent.abort()`; `finally` unsubscribes. Mocked-agent unit tests (vitest
`doMock` intercepts the dynamic import).
4. **Task 3 — host relay.** `src/liveProgressUi.ts` (notification line +
"Cowriting: Claude" OutputChannel, reveal gated by setting) + `package.json`
`contributes.configuration` `cowriting.liveProgress.revealOutput`.
5. **Tasks 4+5 — both call sites.** `editSelection` (extension.ts) and preview
`askClaude`/`runEditAndPropose` (trackChangesPreview.ts) made cancellable,
relaying via the shared `liveProgressUi`; widened the `EditTurn` seam to accept
`opts?` (back-compat). Added `liveProgressUi` to `CowritingApi`.
6. **Task 6 — host E2E.** `test/e2e/suite/liveProgress.test.ts`: progress is
additive (INV-44), aborted turn proposes nothing (INV-47).
7. **Task 7 — verify + smoke.** typecheck + 237 unit + build green; enhanced
`scripts/smoke-live-turn.mjs` to log progress.
8. **Subagent review.** Found a **Medium** (a pre-aborted `AbortSignal` was a
no-op — `agent.abort()` runs before the SDK's AbortController exists, so the
turn ran to completion) + a **Low** (a throwing `onProgress` relay could fail
the turn). Fixed both: short-circuit (throw) before `run()` on a pre-aborted
signal; wrap the relay in try/catch. Strengthened the unit + E2E to prove it.
9. **Ship.** Pushed; opened PR #61; operator approved merge despite the
concurrency (below). Squash-merged `644885c`; issue #60 auto-closed.
## ⚠️ Concurrency event (mid-session)
Partway through, the shared working tree gained **uncommitted changes this session
did NOT make** — a `cowriting.edit` / `routeEdit` reach refactor across
`src/workspacePath.ts`, `test/workspacePath.test.ts`, and the menu/`routeEdit`
parts of `package.json` + `src/extension.ts`. It broke 3 E2E (`f12Reach` ×2,
`f11Toolbar` ×1) that still expect the old `editSelection`/`editDocument` command
surface. Handling: committed **nothing** of it, discarded **nothing**; verified
#60 green in isolation by stashing it (by path, reversible) — 237 unit + both #60
E2E pass, only the documented `F10 #38` undo-sandbox flake remains — then popped
the stash to restore it. Surfaced to the operator, who chose to merge #60 (its 7
commits are isolated; the PR diff contains none of the foreign WIP).
## Cut state (at finalize)
- **#60 merged** to main (PR #61 squash `644885c`); issue #60 closed.
- **Plan archived:** `submit-plan.sh` → content repo `plans/2026-06-26-live-turn-progress.md` (`22ec57e`).
- **Foreign refactor WIP:** still uncommitted in the working tree, untouched —
for whoever is doing it to continue/commit (it rebases onto the new main).
- **Local tree:** left on branch `s60-live-progress` (merged) DELIBERATELY — a
`checkout main`/`pull` would have disturbed the foreign uncommitted WIP. Local
main not synced; origin/main has #60. Operator should reconcile the local tree
+ the in-flight refactor.
- **Memory:** added `session-0056-60-live-progress-shipped.md` + index line.
## Next-session prompt
```
/wgl-planning-and-executing reconcile the local tree (sync main; land/branch the in-flight cowriting.edit/routeEdit refactor), then pick the next item (open: #59 P1 bug, #57, #58, #32, #35, #40, OQ-2)
```
## Deferred decisions
- **Left local on `s60-live-progress` (did not sync main):** to avoid disturbing
the concurrent uncommitted refactor in the shared tree. Low confidence this is
the tidiest end state, but it is the safest for the foreign WIP. Operator to
reconcile.
- **Token field for the activity line:** used `inputTokens + outputTokens` from
`AgentUsage` (no `totalTokens` field exists). Cache tokens excluded.
- **OutputChannel append-not-clear + auto-reveal gating:** per spec §3.5; carried
from session 0055's confirmed sub-decisions.
@@ -0,0 +1,111 @@
# Session 0057.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T04-35 (PST)
> End: 2026-06-26T06-55 (PST)
> Type: executing-plans
> Posture: yolo
> Claude-Session: 3d66a467-8026-472d-9693-52a37939d493
> Goal: Operator-feedback UX fixes on the Ask-Claude-to-Edit experience (broken
> keybinding, missing/duplicated shortcuts, edit-box placement) — then iterate the
> edit-input UX to its final form.
> Outcome: Shipped two PRs to vscode-cowriting-plugin main (#62, #65); fixed and
> shipped a dev-plugin bug (#134 / PR #135, v0.60.0) surfaced mid-session.
## Plan
Operator opened with three asks on the Ask-Claude UX: the live/review-panel
shortcut didn't work, there was no shortcut for "Ask Claude to Edit
Selection/Document" (wanted one combined shortcut), and the edit box appeared in
a disliked spot (near the command palette) — with a request to recommend a
VS-Code-idiomatic placement. Routed as an executing-plans (yolo) session.
The session became an extended live iterate-and-verify loop on the edit-input UX,
driven by the operator testing each build in an Extension Development Host.
## Pre-session state
- On branch `s60-live-progress`; `#60` (live turn progress) work in flight.
- A **concurrent session (0056)** was live in the SAME working checkout, actively
committing/stashing `#60` — which clobbered this session's uncommitted edits
twice before isolation.
## Turn-by-turn arc
1. **Phase A (keybindings + command unify).** Added one `cowriting.edit` command
that routes selection-vs-document at runtime via a pure `routeEdit` helper
(unit-tested); hid the two underlying commands from the palette; collapsed the
split menus to one entry. Added `⌘⌥E`/`Ctrl+Alt+E` (edit) and `mac` variants
for `⌘⌥R`/`Ctrl+Alt+R` (review panel) — the missing `mac` variant was why the
panel shortcut "didn't work" (Option-key combos are unreliable on macOS).
2. **Concurrent-checkout incident + recovery.** Discovered session 0056 was
clobbering the shared tree (git stash/pop/commit out from under this session).
Exported the edits as a durable patch, created an **isolated git worktree**, and
moved all work there. (§5.4 lesson — should have isolated up front.)
3. **Input-box placement (design Q).** Recommended + implemented an inline input
via the Comments API; then iterated heavily per operator feedback:
inline-at-selection → top-of-document → **finally a multi-line webview in a
split pane below the document for BOTH scopes**. Each iteration was built and
verified live in the EDH.
4. **Dev-plugin bug #134 (the root cause of the clobber).** Filed plugin feedback
#134 (two sessions can share one working tree — isolation was prose, not a
deterministic guard), then implemented the fix: `claim-session-id.sh` now stamps
`> Checkout:` and **refuses a claim when another live session occupies the same
checkout** (self-session excepted; `WGL_ALLOW_SHARED_CHECKOUT=1` override).
Shipped as PR #135 / v0.60.0 and made live in the running install.
5. **Final edit-input form (PR #65).** Unified both scopes on the split-below
multi-line webview (`editInstructionInput.ts` / `promptEditInstruction`):
auto-focus, ⌘↵ send, Esc cancel-with-confirm-if-text, split collapses + focus
restored to the doc on close (no Output/Debug panel popping), selection stays
highlighted above. **Deleted the entire inline-comment mechanism** (`inlineAsk.ts`,
`cowriting.askClaude.submit/cancel`, their menus, the Escape keybinding) — net
deletion. Verified interactively, merged.
## Cut state (end of session)
| Repo | Change | Ref |
| --- | --- | --- |
| vscode-cowriting-plugin | Phase A: unify `cowriting.edit` + `routeEdit` + mac keybindings + inline Comments-API input | PR #62 → main (squash `9432300`) |
| vscode-cowriting-plugin | Final: split-below multi-line webview for both scopes; remove inline-comment mechanism | PR #65 → main (squash `7e42d11`) |
| wiggleverse-dev-claude-plugin | Deterministic shared-checkout guard in `claim-session-id.sh` + Step 6b docs; v0.60.0 | PR #135 → main (merge `ca383c4`); installed live |
| wiggleverse-dev-claude-plugin (tracker) | Filed + closed feedback #134 (shared-checkout hazard) | issue #134 `resolution:done` |
- All worktrees (`s60-edit-ux`, `edit-top-anchor`, dev-plugin `issue134`) removed;
branches deleted; all temporary EDH windows closed.
- 242 unit + typecheck (src + e2e) + build green on the final tip. E2E electron
suite not run (known env-broken locally); webview UX verified interactively.
- Local `main` is 3 behind `origin/main` (sessions 0058/0059 + PR #65 merge) —
cosmetic; a future session pulls.
- Untracked strays `docs/superpowers/plans/2026-06-26-live-turn-progress.md` and
`specs/` predate this session (from the `#60` line / a known `specs/` stray);
left untouched — not this session's to land.
## Deferred decisions
None logged. No silent low-confidence calls — every judgment call (worktree
recovery, each UX fork, cancel/placement/size trade-offs) was surfaced live and
decided by the operator or flagged as a hard VS Code API limit.
## What lands on the operator's plate
- Nothing blocking. The edit-input UX line is complete and shipped.
- The dev-plugin shared-checkout guard (#134/v0.60.0) is **live for new
sessions** (this session's running install was updated; new sessions get it on
start).
- Known VS Code API limits captured in memory ([[ask-claude-edit-input-ux]]): no
multi-line input at the command-palette location; no API to set an editor split
ratio; no API to read bottom-panel visibility.
## Prompt the operator can paste into the next session
This session's thread (Ask-Claude edit UX) is **complete** — there is no single
forced next step. Open backlog items the operator may choose from (each its own
session): `#32` scroll-sync (feature, needs design), `#35` repo rename, `#40` undo
provenance (P3), F11 spec graduation (OQ-2), content-repo draft reconciliation.
No `Next /goal:` is recorded — the next move is an operator pick from the backlog
above.
@@ -0,0 +1,85 @@
# Session 0058.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T05-12 (PST)
> End: 2026-06-26T05-58 (PST)
> Type: brainstorming
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
Operator opened by asking what was in the Gitea backlog, then directed a
brainstorming session for a new feature: *"We should also show the proposed change
in the Markdown file in the same way. The proposed change in the markdown file
should be human-editable, show the diff just like it would after the change is
completed in both the markdown file and the webview as the change is made. The
diff in both the markdown file and preview webview should be exactly as they will
be shown if the edit is accepted. Create a new working tree in a directory other
than main for this work."* — extended mid-session with: *"replace the checkbox
icon and 'x' icon on the proposed change as 'Accept' and 'Reject'. Each should
have an arrow next to them for a dropdown that is 'Accept All' or 'Reject All'.
One should be able to accept, reject, accept all, or reject all from either the
markdown file or the web preview file."*
## Pre-state
- Branch `main`, clean/pushed baseline (after FF). Latest shipped work: #60 live
turn progress (session 0056, PR #61). Session 0057 (a `cowriting.edit`/`routeEdit`
refactor) still **in-flight** sharing the checkout.
- Backlog at session start (8 open): #59 (P1 bug, Apple-Events prompt), #54/#57/#58
(P2), #32/#35/#40 (P3), #62 (untriaged).
## Turn-by-turn arc
1. **Backlog query** — listed the 8 open Gitea issues for `benstull/vscode-cowriting-plugin`
by priority; flagged #62 as the only untriaged item.
2. **Session routing** — operator's feature request was feature-shaped *and*
reversed a locked invariant (F10/INV-32 "clean editor"), so the session type
was ambiguous. Asked; operator chose **brainstorm a spec first**. Routed via
`wgl-brainstorming`; claimed session **0058**.
3. **Isolation** — session 0057 still in-flight on the shared checkout → created an
isolated worktree `vscode-cowriting-plugin-s58` on branch
`s58-inline-editor-diff` off `origin/main` (§5.4).
4. **Exploration** — dispatched an Explore agent that mapped five areas: webview
render engine (`trackChangesModel.ts`), F4 proposal model + seam, the F10
clean-editor decision (zero editor decorations remain), controllers/wiring, and
commands/toolbar. Key constraint surfaced: **VS Code text editors cannot host
editable phantom text** — anything editable in the editor is buffer content;
decorations only style existing text or inject *non-editable* before/after
content.
5. **Four design forks (AskUserQuestion, with ASCII previews):**
- Editor model → **optimistic apply + decorations**.
- Timing → **on proposal** (not live token-stream into the editor).
- Editor affordance → **CodeLens `Accept ▾ / Reject ▾` + QuickPick dropdown**.
- (Operator-added) Control parity → Accept/Reject/Accept-all/Reject-all from
both surfaces; ✓/✗ superseded.
6. **Design presented** section-by-section; operator approved with "keep going"
(nodded the two flagged sub-decisions: dedicated `EditorProposalController`;
allow save of pending text).
7. **Anchor + spec** — filed Feature **#64** (`type/feature`, `priority/P2`) since
no tracker anchor existed; wrote `specs/coauthoring-inline-editor-diff.md`
(INV-48..54, reversing INV-10/INV-32). Self-review clean. Operator **approved**.
## Cut state
- Spec **graduated** → content repo `vscode-cowriting-plugin-content` at `80d5d1a`
(`specs/coauthoring-inline-editor-diff.md`).
- Feature **#64** filed + labelled.
- Worktree `vscode-cowriting-plugin-s58` (branch `s58-inline-editor-diff`) stands
ready for the downstream planning-and-executing session; no code committed this
session (brainstorming output is the spec only).
- Memory updated: `f12-inline-editor-diff-spec-graduated.md` + MEMORY.md index.
## Deferred decisions
No autonomous low-confidence calls — every fork was decided live with the operator
via AskUserQuestion, and the spec was operator-approved before submission. (The
two design sub-decisions — dedicated controller, allow-save-of-pending — were
flagged in the presented design and nodded by the operator.)
## Next-session prompt
```
/goal Plan-and-execute #64 (F12 inline editable proposed-change diff in the Markdown editor) from specs/coauthoring-inline-editor-diff.md, in worktree vscode-cowriting-plugin-s58 (branch s58-inline-editor-diff); follow the spec's 5-slice Delivery Plan
```
@@ -0,0 +1,101 @@
# Session 0059.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T06-01 (PST)
> End: 2026-06-26T08-32 (PST)
> Type: planning-and-executing
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
Plan-and-execute #64 (F12 inline editable proposed-change diff in the Markdown
editor) from the graduated spec `specs/coauthoring-inline-editor-diff.md`, in
isolated worktree `vscode-cowriting-plugin-s58` (branch `s58-inline-editor-diff`),
opened straight off brainstorming session 0058.
## Plan
Implement #64 per spec: optimistic-apply the proposed change into the editor
buffer (editable, tinted insertions + struck-red deletion hints), accept =
finalize-in-place / reject = revert-in-place, `Accept ▾`/`Reject ▾` (+ Accept-all
/Reject-all) from both editor (CodeLens+QuickPick) and webview. Reverses
INV-10/INV-32; INV-48..54. Implementation plan:
`docs/superpowers/plans/2026-06-26-inline-editor-diff.md`.
## Outcome — SHIPPED
#64 (F12) **shipped to main** — PR #66 squash `7b98249`, issue auto-closed.
Worktree built, subagent-driven (fresh implementer per task + two-stage review),
adversarial final review, then merged through the concurrent #65 landing.
## Turn-by-turn arc
1. **Init** — claimed session 0059 (0057 still in-flight; isolated worktree made
it §5.4-safe). Synced worktree, `npm ci`, green baseline (242 unit).
2. **Plan** — read the whole subsystem (proposalController, trackChangesModel,
attributionController, trackChangesPreview, preview.ts/css, extension.ts,
package.json, E2E harness); wrote an 8-task TDD plan. Surfaced a **spec
refinement**: optimistic apply must re-anchor the proposal to the applied text
+ store `Proposal.original` (F4's `fp.text` is the original, gone once applied).
3. **Execute (subagent-driven, sonnet implementers):**
- T1 `Proposal.original` (model) — `bea9fd5`.
- T2 `setProposalApplied` re-anchor helper — `92ff4dd`.
- T3 seam `landBaseline` opt + `signalLanded``6d54963`. **Surfaced the
host-E2E was blocked by a macOS UNIX-socket-length `EINVAL`** (the long
worktree path). Fixed by pinning `--user-data-dir` to a short `/tmp` path
(`930b4ab`) → host-E2E now launches from a worktree.
- T4 `optimisticApply`/`finalizeInPlace`/`revertInPlace`/`rejectAll`
`ddbbb7a` (subagent caught a real `keyOf` vs `uri.toString()` test bug).
- T5 `EditorProposalController` + pure `decorationPlan``8c6e782`+`e599284`
(subagent added a setTimeout(0) apply-debounce for the batch race; updated 5
existing E2E for the INV-10 reversal — verified legitimate, not weakened).
- T6 render-once INV-50 reconciliation — `6529332`; I added a coloring-
alignment fix (`landedSpans` via `toLanded`) the subagent's note flagged —
`3805323`.
- T7 webview `Accept ▾`/`Reject ▾` + dropdown + `rejectAllProposals``f4594da`.
4. **Adversarial final review (opus subagent)** — found a **CRITICAL** (revert
target clobbered on save+reload, in-memory `applied` empty post-reload) and a
MINOR (toolbar summary double-counts). Both fixed + a reload-safety regression
E2E — `6560894`. Verdict invariants INV-48..54 upheld; decorationPlan alignment
verified correct.
5. **Concurrency** — session 0057 had merged **#65** (unify Ask-Claude on a
split-below webview; removed `InlineAskController`) to main mid-session. Merged
origin/main into the branch, resolved the one `extension.ts` conflict (drop
inlineAsk, keep `EditorProposalController`); suite green; squash-merged PR #66.
6. **Hygiene** — a `git add -A` during the merge accidentally tracked the spec
copy in the code repo; removed it (PR #67 `844a278`) — specs are canonical in
the content repo. Plan archived (`8c14f54`); spec patched to **v0.1.1** with the
re-anchor refinement and submitted (`43913dc`).
## Cut state
- **main @ `844a278`** (clean, pushed). #64 closed.
- Tests: **249 unit + 87/5 host-E2E green**, typecheck clean.
- Content repo: spec v0.1.1 (`43913dc`) + plan (`8c14f54`).
- Worktree removed.
## Deferred decisions
- **Re-anchor on optimistic apply (spec refinement, RESOLVED into v0.1.1).** Added
`Proposal.original` + re-fingerprint to the applied text; captured once →
reload-safe. Folded into the spec.
- **`finalizeInPlace` has no orphan re-resolve guard** (CodeLens Accept), unlike
the webview `acceptById`. Intentional — spec §2.2 says Accept keeps the human's
in-place edits (which would orphan the exact-text fp) — but it's asymmetric. Left
as-is; noted as a follow-up nit.
- **Per-proposal webview carets always show Accept-all/Reject-all** even at 1
pending (the toolbar button correctly hides <2). Cosmetic; not fixed.
## Not done
- **Manual smoke (interactive F5)** — the live editor diff, CodeLens dropdown,
both-surface parity, and save-while-pending need an Extension Development Host
run (operator-side). No flotilla/PPE for this app — the PR-merge to main is the
ship.
## Next-session prompt
```
/goal Manually smoke-test #64 (F12 inline editor diff) in an Extension Development Host (F5) — verify the live editable diff + deletion hints, the Accept ▾/Reject ▾ CodeLens dropdown, editor↔webview parity, and save-while-pending — then start the next backlog item; #59 (P1, macOS Apple-Events prompt, likely @cline/sdk) is highest priority
```
@@ -0,0 +1,124 @@
# Session 0060.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T12-52 (PST)
> End: 2026-06-26T13-14 (PST)
> Type: planning-and-executing
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/goal next` — resume the stored next-goal from session 0059's finalize:
"Manually smoke-test #64 in an Extension Development Host (F5), then pick the
next backlog item — #59 (P1 Apple-Events prompt) is highest priority."
The #64 F5 smoke is an interactive operator gesture (flagged, not performed by
the session). The autonomous substantive work was **fixing #59** — a standalone
leaf `type/bug` (P1), eligible for plan-and-execute under §4.3 R2(b) with no
design required.
## Plan
Fix #59: the plugin triggers a spurious macOS "control other applications"
(Apple Events / Automation TCC) permission prompt; works fine when declined.
Root-cause via systematic-debugging, fix via TDD, ship through branch → PR →
merge to main.
## Turn-by-turn arc
1. **Session init.** Classified `/goal next` → planning-and-executing; claimed
session 0060 (`claim-session-id.sh`, no other sessions in flight). Baseline:
pulled `main` (was 1 behind), clean apart from two pre-existing stray
untracked files (`specs/coauthoring-live-progress.md`,
`docs/superpowers/plans/2026-06-26-live-turn-progress.md` — canonical copies
live in the content repo; left untouched).
2. **Read #59** (Gitea API). P1, type/bug. Lead in the issue: not the extension's
own code; probable origin `@cline/sdk` agent activation / Claude-Code
hub-discovery, which falls back gracefully when denied.
3. **Root-cause investigation (systematic-debugging Phase 12).**
- The extension's own code and the **entire JS dependency tree** contain no
osascript/AppleScript (only the build-only `vite`). The Apple Event is sent
by a spawned subprocess.
- Traced the spawn chain: `src/liveTurn.ts` `new sdk.Agent({providerId:
"claude-code"})` → `@cline/sdk` → `@cline/llms` `createClaudeCodeProviderModule`
→ `ai-sdk-provider-claude-code` `createClaudeCode(options)` → `@anthropic-ai/
claude-agent-sdk` `query()` → **spawns the bundled `claude` CLI**
(`@anthropic-ai/claude-agent-sdk-darwin-arm64/claude`) headless
(`--print --output-format stream-json`).
- `runEditTurn` (liveTurn.ts:63) is the **sole** agent-spawn site (`cline.ts`
only reads the static tool catalog).
- Binary forensics: the `claude` binary contains **26 osascript calls** incl.
`tell application "Terminal"/"iTerm"/"Electron"/"Finder"/"System Events"`.
Categorized all 26 — every real call is **user-action-gated** (clipboard
image, screenshot, `Apple_Terminal`-only theme read, notifications,
terminal-setup, Claude-Desktop deep link via `tell application "Electron"`,
browser-default-handler detection). The only path that fires **without user
action** when `claude` is launched inside the VS Code extension host is
**IDE auto-connect** (osascripts to identify/activate the editor). We never
use the IDE connection → declining is harmless = exact symptom.
- Gate confirmed: `if(!((autoConnectIde||…||CLAUDE_CODE_SSE_PORT||…||
CH(CLAUDE_CODE_AUTO_CONNECT_IDE)) && !mK(CLAUDE_CODE_AUTO_CONNECT_IDE))) return;`
with `mK("0")===true` → `CLAUDE_CODE_AUTO_CONNECT_IDE=0` hard-disables it.
4. **Fix design (verified end-to-end, statically).** Pass `options.env` to the
Agent. The provider merges `{...getBaseProcessEnv(), ...settings.env}` and the
agent-SDK spawns the child with `env = L4 ? {...L4} : {...process.env}`. Seeding
`options.env` with the full `process.env` keeps default inheritance intact
(login under HOME, proxy/CA vars) and adds only `CLAUDE_CODE_AUTO_CONNECT_IDE=0`
(+ `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1`, belt-and-suspenders for the
extension-install deep link). `options` type is `Record<string,unknown>` — no
cast.
5. **TDD.** RED: unit test asserts `runEditTurn` constructs the Agent with both
switches set and the rest of `process.env` preserved (fake Agent captures
`cfg`). Watched it fail (`env` undefined). GREEN: added `options.env` to the
Agent constructor in `liveTurn.ts`. 250 unit + typecheck + build green.
6. **Empirical live-turn smoke (`scripts/smoke-live-turn.mjs` + osascript shim)
was DECLINED** by the operator (it makes a live network/auth'd agent call).
Env-preservation rests on the static chain proof + the unit test instead.
7. **Ship.** Branch `s60-59-apple-events-prompt` → commit → push → **PR #68** →
squash-merge to main (`e671c4b`), branch deleted, **#59 auto-closed**.
Post-merge: 250 unit green on fresh main.
## Cut state
- **#59 SHIPPED** to main (squash `e671c4b`, PR #68; issue closed).
- `src/liveTurn.ts` (+19) and `test/liveTurn.test.ts` (+38) only.
- 250 unit + typecheck + build green; E2E unaffected (it stubs the turn, never
reaching the real spawn path).
- No implementation-plan artifact (small leaf bug) → nothing to archive in
`plans/`. No deploy pipeline stage (VS Code extension, no flotilla/PPE) →
change ships at merge.
## Deferred decisions
- **Did not run the real-turn empirical smoke** (operator declined the live
agent call). Alternative: run it with an osascript shim to observe the trigger
directly. Why proceeded: the env→spawn chain and gate semantics are proven by
static analysis, the plumbing is unit-tested, and the definitive confirmation
(fresh-TCC GUI check) is inherently an operator gesture per the issue's
acceptance — so the smoke was not load-bearing for shipping.
- **Set both `AUTO_CONNECT_IDE=0` and `IDE_SKIP_AUTO_INSTALL=1`** rather than the
single minimal switch. Alternative: auto-connect=0 alone (likely sufficient,
since auto-install rides the auto-connect flow). Why both: cheap, explicit,
both are no-ops outside an IDE; defense-in-depth against a separately-gated
install path.
## Operator plate (next session)
1. **Operator F5 smoke of #64** (F12 inline editor diff) — still pending from 0059.
2. **Fresh-TCC GUI smoke of #59** — reset macOS Automation TCC for the host app,
exercise a Claude edit, confirm the "control other applications" prompt is
gone and the edit still works.
3. Then a backlog item — **#58** (P2 story, self-contained) is the most actionable.
## Next-session prompt
```
/goal Operator-smoke #64 (F5) + fresh-TCC GUI smoke of #59, then plan-and-execute #58 (proposal accept/decline word-level diff — route proposalBlockHtml through wordMergedMarkdown)
```
@@ -1,13 +1,13 @@
# Session 0056.0 — Transcript
# Session 0061.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T04-24 (PST)
> Start: 2026-06-27T05-14 (PST)
> Type: planning-and-executing
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0056 for vscode-cowriting-plugin. The driver replaces this
> This file reserves session ID 0061 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--<end>.md form at session end.
> SESSION-0061.0-TRANSCRIPT-2026-06-27T05-14--<end>.md form at session end.
## Launch prompt
@@ -0,0 +1,23 @@
# Session 0062.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-27T17-02 (PST)
> Type: brainstorming
> Posture: careful
> Claude-Session: 2c832f9a-a70c-4d71-ab13-c4f39d6eb595
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0062 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0062.0-TRANSCRIPT-2026-06-27T17-02--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,65 @@
# Session 0063.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-01T18-49 (PST)
> Type: brainstorming
> Posture: careful
> Claude-Session: 1a8fc96b-6f36-4102-be7b-decb53251b2b
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-s0063
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0063 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0063.0-TRANSCRIPT-2026-07-01T18-49--<end>.md form at session end.
## Launch prompt
```
Examine the existing vs plugin, the prototype, and the half-baked spec that I used Opus to create and make a recommendation for a VS Code experience that allows humans and machines to collaborate on a document, with the machine making suggestions based on human input and the human able to approve, reject, or modify those suggestions. This should use as many VS Code UX paradigms as possible, use as many existing VS Code components or extensions, but create custom components/UX when needed. You can assume this is just Claude as the 'machine' to start
```
## Plan
> Anchor: design-only north-star recommendation (no tracker anchor; lineage =
> Epic #1 / F1F12 + session 0062's `coauthoring-native-surfaces.md` draft —
> same anchor treatment 0062 recorded)
1. Examine the three inputs: the shipped plugin (F1F12 code on `main`), the
prototype, and the half-baked Opus-authored spec (untracked `specs/` in the
primary checkout).
2. Identify/confirm the anchoring issue (§4.3 R1 gate) — capture one if none fits.
3. Walk the consequential UX decisions with the operator (AskUserQuestion
checkpoints), grounded in native VS Code paradigms vs custom components.
4. Draft the recommendation as a Solution-Design spec in the content repo's
`specs/`; whole-document review at the Gitea render URL.
## Session record (running)
- Examined the three inputs: shipped plugin (F1F12 inventory), prototype
(`vscode-cowriting-prototype` = rung-2 spike: E1/E3/E5 PASS — key finding:
CodeLens does not render in a diff editor; E2/E4 unbuilt), and the 0062 draft
`coauthoring-native-surfaces.md` v0.1.0 + companion spike plan.
- Recommendation delivered + four forks decided with the operator
(AskUserQuestion checkpoints, all recommended options chosen):
1. Review model = **in-buffer pending changes + per-hunk CodeLens** (D18;
supersedes multi-diff Keep/Undo; amends INV-5/INV-12; resolves Q2/Q7).
2. Packaging (Q1) = **evolve the shipped extension in place** (D17; §6.10
rewritten from "greenfield").
3. Ask surface = **comments-first** (D19; split-pane input webview sunsets).
4. Artifact = **amend the 0062 spec to v0.2.0** on content-repo branch
`session-0063` (0062's working tree untouched). Also D20 (tweaks × per-hunk
decisions), D21 (D11 → shipped reality).
- Spec v0.2.0 + spike-spec status committed (`431ebeb`) and pushed to
`session-0063`; whole-document review handed to the operator at the Gitea
render URL (terminal gate).
## Deferred decisions
- Proceeded past the concurrent-session warning (0061/0062 both INPROGRESS,
placeholder-only claims from 2026-06-27) because the operator explicitly
launched this session; isolated in worktree `session-0063` per #134/§5.4.
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,23 @@
# Session 0064.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-07-01T22-19 (PST)
> Type: writing-plans
> Posture: careful
> Claude-Session: 7c03a44e-1f92-4445-afd9-928efd55afba
> Checkout: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-wp
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0064 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0064.0-TRANSCRIPT-2026-07-01T22-19--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
+24
View File
@@ -166,5 +166,29 @@
},
"0056": {
"title": ""
},
"0057": {
"title": ""
},
"0058": {
"title": ""
},
"0059": {
"title": ""
},
"0060": {
"title": ""
},
"0061": {
"title": ""
},
"0062": {
"title": ""
},
"0063": {
"title": ""
},
"0064": {
"title": ""
}
}
+15 -5
View File
@@ -249,7 +249,7 @@ export class AttributionController implements vscode.Disposable {
range: vscode.Range,
newText: string,
provenance: Provenance,
opts?: { expectedVersion?: number; turnId?: string },
opts?: { expectedVersion?: number; turnId?: string; landBaseline?: boolean },
): Promise<boolean> {
if (!this.isTracked(document)) return false;
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
@@ -290,15 +290,25 @@ export class AttributionController implements vscode.Disposable {
"(host minimized differently?) — the edit may be mis-attributed (INV-9).",
);
}
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.
if (ok && opts?.landBaseline !== false) {
// F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48)
// suppresses this for optimistic apply: the proposed text is in the buffer
// but the change stays PENDING (baseline at pre-proposal) until accept.
this.applyEmitter.fire({ document });
}
return ok;
}
/**
* F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used
* by finalize-in-place, where the proposed text already landed in the buffer via
* optimistic apply (`landBaseline:false`) and accept only needs to advance the
* F6 baseline so the now-accepted change stops reading as pending.
*/
signalLanded(document: vscode.TextDocument): void {
if (this.isTracked(document)) this.applyEmitter.fire({ document });
}
// ---- PUC-4: persistence on save ----------------------------------------------------
private onDidSave(document: vscode.TextDocument): void {
+147
View File
@@ -0,0 +1,147 @@
import * as vscode from "vscode";
import { randomBytes } from "crypto";
/**
* The multi-line instruction input for "Ask Claude to Edit" — BOTH the selection
* and the whole-document case. A small focused webview (a tall, resizable
* textarea + Send) opened in a split pane BELOW the document — not a comment
* thread, and not the top QuickInput (which is single-line only; VS Code has no
* multi-line input at the command-palette location). For a selection edit the
* document stays in the pane above with the selection still highlighted (VS
* Code's inactive-selection style), so the user can see exactly what Claude will
* edit; the caller has already captured the selection, so we never touch it.
*
* The split collapses when the input is submitted or cancelled, and focus is
* handed back to the document so the collapsing split doesn't reveal the bottom
* panel. `header` names the scope ("Ask Claude to Edit This Selection" /
* "…This Document").
*
* The webview only collects text and posts it to the host — no SDK or secret
* surface lives in it (INV-8/35); the sealed CSP allows no network. Because a
* webview CAN read its own textarea, Cancel / Escape confirms ONLY when there is
* text to lose (closing the tab is an explicit dismiss, no prompt). Resolves with
* the typed instruction, or `undefined` if cancelled / closed / left empty.
*/
export async function promptEditInstruction(header: string): Promise<string | undefined> {
// Remember the document editor we came from so we can hand focus back to it
// when the input closes — otherwise, when the empty split group below collapses,
// focus falls into the bottom panel (Output / Debug Console / …) and it pops
// open. Restoring editor focus leaves whatever panel state the user had untouched.
const source = vscode.window.activeTextEditor;
// Open a split editor group BELOW the current one and host the input there, so
// it sits under the document instead of covering it as a tab. The new group is
// empty, so disposing the panel on submit/cancel leaves it empty and VS Code
// collapses the split (the `workbench.editor.closeEmptyGroups` default). Falls
// back to a tab in the active group if the split command is unavailable.
try {
await vscode.commands.executeCommand("workbench.action.newGroupBelow");
} catch {
/* no split — the panel opens as a tab in the active group instead */
}
return new Promise((resolve) => {
const panel = vscode.window.createWebviewPanel(
"cowriting.askClaudeInput",
header,
{ viewColumn: vscode.ViewColumn.Active, preserveFocus: false },
{ enableScripts: true, retainContextWhenHidden: false },
);
let settled = false;
const done = (value: string | undefined): void => {
if (settled) return;
settled = true;
resolve(value);
panel.dispose();
// Hand focus back to the originating document so the collapsing split
// doesn't leave focus in (and reveal) the bottom panel.
if (source) {
void vscode.window.showTextDocument(source.document, {
viewColumn: source.viewColumn ?? vscode.ViewColumn.One,
preserveFocus: false,
});
}
};
panel.webview.onDidReceiveMessage((m: { type?: string; text?: string }) => {
const text = (m?.text ?? "").trim();
if (m?.type === "submit") {
done(text ? text : undefined);
} else if (m?.type === "cancel") {
// Confirm only if there's something to lose (we can read the textarea here).
if (!text) {
done(undefined);
return;
}
void vscode.window
.showWarningMessage("Discard your Ask-Claude instruction?", { modal: true }, "Discard")
.then((pick) => {
if (pick === "Discard") done(undefined);
// else: leave the panel open so the operator can keep editing.
});
}
});
// Closing the tab is an explicit dismiss — cancel without a prompt.
panel.onDidDispose(() => done(undefined));
panel.webview.html = htmlFor(header);
});
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
}
function htmlFor(header: string): string {
const nonce = randomBytes(16).toString("base64");
// Sealed CSP: no network; inline style only; the one script is nonce-gated.
const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';`;
const title = escapeHtml(header);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<title>${title}</title>
<style>
body { padding: 14px 16px; font-family: var(--vscode-font-family); color: var(--vscode-foreground); }
h2 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
textarea {
width: 100%; min-height: 160px; resize: vertical; box-sizing: border-box;
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 8px;
font-family: var(--vscode-editor-font-family); font-size: var(--vscode-editor-font-size); line-height: 1.4;
}
textarea::placeholder { color: var(--vscode-input-placeholderForeground); }
textarea:focus { outline: 1px solid var(--vscode-focusBorder); border-color: var(--vscode-focusBorder); }
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; gap: 12px; }
.hint { color: var(--vscode-descriptionForeground); font-size: 12px; }
button {
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px;
}
button:hover { background: var(--vscode-button-hoverBackground); }
</style>
</head>
<body>
<h2>${title}</h2>
<textarea id="inst" placeholder="e.g. tighten the intro, add a conclusion, and fix the heading levels" autofocus></textarea>
<div class="row">
<span class="hint">⌘↵ / Ctrl+↵ to send · Esc to cancel</span>
<button id="send">Send to Claude</button>
</div>
<script nonce="${nonce}">
const api = acquireVsCodeApi();
const ta = document.getElementById('inst');
const focus = () => ta.focus();
focus();
window.addEventListener('focus', focus);
const submit = () => api.postMessage({ type: 'submit', text: ta.value });
const cancel = () => api.postMessage({ type: 'cancel', text: ta.value });
document.getElementById('send').addEventListener('click', submit);
ta.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
</script>
</body>
</html>`;
}
+250
View File
@@ -0,0 +1,250 @@
/**
* EditorProposalController — F12/#64 editor surface (spec coauthoring-inline-editor-diff
* §3.5). Reverses INV-32 for pending proposals: on `onDidChangeProposals` it
* (1) optimistically applies any not-yet-applied proposal into the active editor's
* buffer (ProposalController.optimisticApply, INV-48), (2) decorates each applied
* proposal — insertion tint over the proposed text + a non-editable struck-red hint
* for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens
* `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all).
* Owns no proposal STATE — it is a view over ProposalController (which stays the
* pure F4 owner). Phase 2 decorates ALL committed changes-since-baseline by author
* (human green / Claude blue + struck hints for deletions), plus pending proposals;
* a pinned baseline suppresses committed marks entirely (pin→clean, INV-48).
*/
import * as vscode from "vscode";
import type { ProposalController } from "./proposalController";
import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController";
import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel";
import { isAuthorable } from "./workspacePath";
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
private readonly disposables: vscode.Disposable[] = [];
private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
human: vscode.window.createTextEditorDecorationType({
backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950",
border: "0 0 2px 0", borderStyle: "solid",
}),
claude: vscode.window.createTextEditorDecorationType({
backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff",
border: "0 0 2px 0", borderStyle: "solid",
}),
};
private readonly delDeco: Record<"human" | "claude" | "none", vscode.TextEditorDecorationType> = {
human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }),
claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }),
none: vscode.window.createTextEditorDecorationType({ after: { color: new vscode.ThemeColor("descriptionForeground") } }),
};
private readonly lensEmitter = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this.lensEmitter.event;
/** Pending debounce timers — one per URI — coalesce rapid-fire propose events
* (e.g. runEditAndPropose's N sequential propose() calls) into a single
* optimistic-apply pass that runs after ALL proposals are created. Without this,
* each propose() fires onDidChangeProposals synchronously and the controller's
* optimisticApply runs concurrently with the still-in-progress propose loop,
* causing "file changed in the meantime" workspace-edit conflicts. */
private readonly pendingApply = new Map<string, ReturnType<typeof setTimeout>>();
/** Debounce timers for committed-change re-render (one per URI, 50 ms). */
private readonly pendingRender = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly proposals: ProposalController,
private readonly diffView: DiffViewController,
private readonly attribution: AttributionController,
) {
this.disposables.push(
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter,
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
// Refresh committed decorations when the baseline advances (pin / machine-landing / open).
this.diffView.onDidChangeBaseline(({ uri }) => {
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
}),
// Refresh committed decorations (debounced) when the active doc changes — attribution spans shift.
vscode.workspace.onDidChangeTextDocument((e) => {
const ed = vscode.window.activeTextEditor;
if (ed && e.document === ed.document) this.scheduleRender(ed);
}),
// the four QuickPick-backed menu commands
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
);
}
/** Debounce-schedule an optimistic-apply pass for the given URI. Multiple rapid
* onDidChangeProposals events (from a single runEditAndPropose batch) collapse
* into one pass that runs after the batch completes. */
private scheduleApply(uri: string): void {
const prev = this.pendingApply.get(uri);
if (prev !== undefined) clearTimeout(prev);
this.pendingApply.set(
uri,
setTimeout(() => {
this.pendingApply.delete(uri);
void this.onProposalsChanged(uri);
}, 0),
);
}
/** Debounce a committed-change re-render for `editor` (50 ms — lets rapid typing settle). */
private scheduleRender(editor: vscode.TextEditor): void {
const uri = editor.document.uri.toString();
const prev = this.pendingRender.get(uri);
if (prev !== undefined) clearTimeout(prev);
this.pendingRender.set(
uri,
setTimeout(() => {
this.pendingRender.delete(uri);
this.renderEditor(editor);
}, 50),
);
}
/** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */
private async onProposalsChanged(uri: string): Promise<void> {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return;
const key = this.proposals.keyFor(doc);
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
await this.proposals.optimisticApply(doc, v.id); // fires onDidChangeProposals again; guarded by isApplied
}
}
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
this.lensEmitter.fire();
}
/** Decorate the editor for every applied proposal on its document (INV-52). */
private renderEditor(editor: vscode.TextEditor): void {
const doc = editor.document;
const clearAll = () => {
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], []);
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []);
};
if (doc.languageId !== "markdown") return clearAll();
const key = this.proposals.keyFor(doc);
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] };
// Pending proposals — always Claude (INV: proposals are agent-authored).
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
for (const i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
for (const d of plan.deletions) {
del.claude.push({
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
});
}
}
// Committed changes-since-baseline are added by decorateCommitted (pushes into ins/del[author]).
this.decorateCommitted(editor, ins, del);
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], ins[a]);
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], del[a]);
}
/**
* Decorates committed (non-proposal) changes-since-baseline by author (Task 7 / reverse INV-32).
* Diffs current buffer against the F6 baseline; attributes each changed run to its author;
* skips any hunk overlapping an applied pending proposal (the proposal loop owns those).
* pin→clean: returns without marking when baseline.reason === "pinned".
* Adjacency heuristic: a deletion in a hunk that has insertions inherits the hunk author;
* a standalone deletion (hunk with no insertions) renders neutral ("none") — matching the
* preview's cw-del-none treatment.
*/
private decorateCommitted(
editor: vscode.TextEditor,
ins: Record<"human" | "claude", vscode.Range[]>,
del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]>,
): void {
const doc = editor.document;
const baseline = this.diffView.getBaseline(doc.uri.toString());
// No baseline yet, or baseline was pinned (pin→clean: no committed marks, INV-48).
if (!baseline || baseline.reason === "pinned") return;
const current = doc.getText();
// Nothing changed — short-circuit before the diff.
if (current === baseline.text) return;
const spans = this.attribution.spansFor(doc);
const key = this.proposals.keyFor(doc);
// Build the set of current-buffer ranges owned by applied pending proposals so we
// can skip hunks that fall inside them — the proposal loop already decorates those.
const appliedRanges: { start: number; end: number }[] = [];
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart !== null && v.original !== undefined && this.proposals.isApplied(key, v.id)) {
appliedRanges.push({ start: v.anchorStart, end: v.anchorStart + v.replacement.length });
}
}
// wordEditHunks(current, baseline.text): start/end are in current coords; replacement is baseline text.
const hunks = wordEditHunks(current, baseline.text);
for (const h of hunks) {
// Skip hunks whose current-buffer range overlaps an applied pending proposal.
// The skip is whole-hunk and therefore conservative: a committed edit word-merged
// into a proposal's hunk is skipped entirely — rare at word granularity.
if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue;
const author = authorAt(h.start, spans) ?? "human";
// h.replacement = baseline text for this span; current.slice(h.start, h.end) = applied text.
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
for (const i of plan.insertions) {
ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
}
// Adjacency heuristic: a deletion paired with an insertion in THIS hunk inherits the
// insertion author; a standalone deletion (no insertion in the hunk) is neutral.
const delAuthor = plan.insertions.length > 0 ? author : "none";
for (const d of plan.deletions) {
del[delAuthor].push({
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
});
}
}
}
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
if (document.languageId !== "markdown") return [];
const key = this.proposals.keyFor(document);
const lenses: vscode.CodeLens[] = [];
for (const v of this.proposals.listProposals(document)) {
if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue;
const pos = document.positionAt(v.anchorStart);
const line = new vscode.Range(pos.line, 0, pos.line, 0);
lenses.push(
new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }),
new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", arguments: [v.id] }),
);
}
return lenses;
}
/** The dropdown: this-proposal vs all-proposals, then dispatch. */
private async menu(kind: "accept" | "reject", id?: string): Promise<void> {
const doc = vscode.window.activeTextEditor?.document;
if (!doc || !id) return;
const key = this.proposals.keyFor(doc);
const verb = kind === "accept" ? "Accept" : "Reject";
const pick = await vscode.window.showQuickPick(
[`${verb} this proposal`, `${verb} ALL proposals`],
{ placeHolder: `${verb} Claude's proposal` },
);
if (!pick) return;
const all = pick.includes("ALL");
if (kind === "accept") {
if (all) await this.proposals.acceptAllProposals(doc);
else await this.proposals.finalizeInPlace(key, id);
} else {
if (all) await this.proposals.rejectAll(doc);
else await this.proposals.revertInPlace(key, id);
}
}
dispose(): void {
for (const t of this.pendingApply.values()) clearTimeout(t);
this.pendingApply.clear();
for (const t of this.pendingRender.values()) clearTimeout(t);
this.pendingRender.clear();
for (const d of this.disposables) d.dispose();
}
}
+56 -13
View File
@@ -12,7 +12,8 @@ import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi";
import { isAuthorable, selectionRejection } from "./workspacePath";
import { EditorProposalController } from "./editorProposalController";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -25,6 +26,7 @@ export interface CowritingApi {
trackChangesPreviewController: TrackChangesPreviewController;
sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi;
editorProposalController: EditorProposalController;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -115,6 +117,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
);
context.subscriptions.push(trackChangesPreviewController);
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
const editorProposalController = new EditorProposalController(proposalController, diffViewController, attributionController);
context.subscriptions.push(editorProposalController);
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
// (also reachable from the preview toolbar's "Accept all" button). Reuses the
// batched F4 seam + reports applied-vs-skipped.
@@ -129,6 +136,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}),
);
// #64 (INV-53): reject every pending proposal on the active doc in one gesture.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document;
if (!doc || doc.languageId !== "markdown") {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
return;
}
await trackChangesPreviewController.rejectAll(doc);
}),
);
// --- F6 machine-landing wiring — now for ANY authorable doc ---
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
@@ -217,24 +236,25 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
return;
}
if (!editor) return; // unreachable once reason is null, but narrows the type
const instruction = await vscode.window.showInputBox({
prompt: "What should Claude do with the selection?",
placeHolder: "e.g. tighten this paragraph",
});
if (!instruction) return;
if (editor.selection.isEmpty) {
void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first.");
return;
}
const document = editor.document;
const selection = editor.selection;
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
// Capture the selection text + anchor BEFORE prompting — the inline prompt
// moves the cursor to the document top (where its box anchors), which would
// otherwise collapse the selection we act on (spec §6.5 PUC-1: anchor
// captured pre-turn so mid-turn edits can't skew it).
const selectedText = document.getText(selection);
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
// can't skew it — the proposal renders wherever the target re-resolves.
const fp = buildFingerprint(document.getText(), {
start: document.offsetAt(selection.start),
end: document.offsetAt(selection.end),
});
// The instruction prompt is the multi-line split-below webview box (shared
// with the document case, via the preview controller); the document above
// keeps the selection highlighted while it's open. selectedText/fp were
// captured above, so moving focus to the box doesn't affect what we edit.
const instruction = await trackChangesPreviewController.askEditInstruction(
"Ask Claude to Edit This Selection",
);
if (!instruction) return;
const turnId = `turn-${Date.now().toString(36)}`;
try {
await vscode.window.withProgress(
@@ -299,6 +319,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}),
);
// The single user-facing "Ask Claude to Edit" gesture (one command, one
// keybinding, one menu entry). It routes to the selection flow (editSelection)
// or the whole-document flow (editDocument) by selection/context — see
// routeEdit. The two underlying commands stay registered for the host E2E
// harness and the internal seams, but are hidden from the palette.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
const editor = vscode.window.activeTextEditor;
const route = routeEdit({
hasUri: !!uri,
uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(),
hasActiveEditor: !!editor,
selectionEmpty: editor?.selection.isEmpty ?? true,
});
if (route === "selection") {
await vscode.commands.executeCommand("cowriting.editSelection");
} else {
await vscode.commands.executeCommand("cowriting.editDocument", uri);
}
}),
);
// Render threads + attributions for already-open editors, and on future opens.
const renderIfOpen = (doc: vscode.TextDocument) => {
if (isAuthorable(doc.uri.scheme)) {
@@ -319,6 +361,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
trackChangesPreviewController,
sidecarRouter,
liveProgressUi,
editorProposalController,
};
}
+19
View File
@@ -64,6 +64,25 @@ export async function runEditTurn(
providerId: "claude-code",
modelId,
systemPrompt: SYSTEM_PROMPT,
// The claude-code provider spawns the bundled `claude` CLI. On macOS, when
// that process is launched from inside VS Code it performs IDE auto-connect,
// which shells out to `osascript` (`tell application …`) to identify/activate
// the editor — raising the macOS "control other applications" (Apple Events)
// permission prompt (#59). We only consume the turn's text result and never
// use the IDE connection, so we disable it: `CLAUDE_CODE_AUTO_CONNECT_IDE=0`
// hard-returns out of the auto-connect path in the binary, and
// `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1` skips the extension-install deep link.
// Seeding with the full `process.env` keeps the default inheritance intact
// (login under HOME, proxy/CA vars) — we only add the two switches. The
// provider forwards `options.env` into the spawned child (verified end to end
// through @cline/sdk → ai-sdk-provider-claude-code → @anthropic-ai/claude-agent-sdk).
options: {
env: {
...process.env,
CLAUDE_CODE_AUTO_CONNECT_IDE: "0",
CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1",
},
},
});
// Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
+10
View File
@@ -97,6 +97,14 @@ export interface Proposal {
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
*/
granularity?: "block" | "single";
/**
* F12/#64 (INV-48): the pre-apply text, captured when a proposal is
* optimistically applied to the buffer. `replacement` is now in the buffer and
* `fp.text` re-anchors to it, so `original` is the only record of what to revert
* to (revert-in-place) and what to show struck in the `<del>` half. Absent on a
* proposal created but not yet optimistically applied (or older sidecars).
*/
original?: string;
}
export interface Artifact {
@@ -252,6 +260,8 @@ export function serializeArtifact(a: Artifact): string {
createdAt: p.createdAt,
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
...(p.original !== undefined ? { original: p.original } : {}),
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
},
p,
),
+192 -4
View File
@@ -13,8 +13,8 @@
import * as vscode from "vscode";
import { SidecarRouter, docIdentity } from "./sidecarRouter";
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
import { resolve, shift, type OffsetRange } from "./anchorer";
import { addProposal, removeProposal } from "./proposalModel";
import { resolve, shift, buildFingerprint, type OffsetRange } from "./anchorer";
import { addProposal, removeProposal, setProposalApplied } from "./proposalModel";
import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
@@ -39,6 +39,8 @@ interface DocState {
live: Map<string, OffsetRange>;
/** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
unresolved: Set<string>;
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
applied: Set<string>;
}
export class ProposalController implements vscode.Disposable {
@@ -89,8 +91,10 @@ export class ProposalController implements vscode.Disposable {
id: p.id,
anchorStart: resolved === "orphaned" ? null : resolved.start,
anchorEnd: resolved === "orphaned" ? null : resolved.end,
replaced: fp?.text ?? "",
replaced: p.original ?? fp?.text ?? "",
replacement: p.replacement,
original: p.original,
author: p.author?.kind === "agent" ? "claude" : "human",
};
});
}
@@ -104,6 +108,7 @@ export class ProposalController implements vscode.Disposable {
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
live: new Map(),
unresolved: new Set(),
applied: new Set(),
};
this.docs.set(docPath, state);
}
@@ -137,8 +142,33 @@ export class ProposalController implements vscode.Disposable {
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
/** Accept by id — F12: finalize the already-applied text in place (INV-51). */
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
if (this.isApplied(docPath, proposalId)) {
// INV-11 (applied path): if an external write mangled the optimistically-applied
// text so the fingerprint no longer resolves, refuse finalize — same guard as the
// legacy accept path. Direct finalizeInPlace calls (CodeLens Accept gesture where
// the user may have edited inside the applied span) bypass this check intentionally.
const hit = this.byId(docPath, proposalId);
if (hit) {
const document = this.openDoc(hit.state);
if (document) {
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved === "orphaned") {
if (!opts?.silent) {
void vscode.window.showWarningMessage(
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
);
}
return false;
}
}
}
return this.finalizeInPlace(docPath, proposalId);
}
// Fallback: a proposal that was never optimistically applied (e.g. orphaned at
// apply time) keeps the legacy seam-apply accept.
const hit = this.byId(docPath, proposalId);
return hit ? this.accept(hit.state, hit.proposal, opts) : false;
}
@@ -181,6 +211,12 @@ export class ProposalController implements vscode.Disposable {
return true;
}
/** Reject by id — F12: revert the applied text in place (INV-51). */
async rejectByIdInPlace(docPath: string, proposalId: string): Promise<boolean> {
if (this.isApplied(docPath, proposalId)) return this.revertInPlace(docPath, proposalId);
return this.rejectById(docPath, proposalId);
}
private async accept(state: DocState, proposal: Proposal, opts?: { silent?: boolean }): Promise<boolean> {
if (this.guard.isReadOnly(state.docPath)) return false;
const document = this.openDoc(state);
@@ -257,6 +293,158 @@ export class ProposalController implements vscode.Disposable {
return true;
}
/** True once this proposal's text is in the buffer (optimistic apply ran). */
isApplied(docPath: string, proposalId: string): boolean {
return this.docs.get(docPath)?.applied.has(proposalId) ?? false;
}
/**
* F12/#64 (INV-48): optimistically apply a pending proposal INTO the buffer so
* the editor shows the would-be-accepted result (editable). Reuses the F4
* word-precise seam (block → per-word hunks, INV-40; single → whole range) but
* with `landBaseline:false` (the change stays pending). Then re-anchors the
* proposal to the applied text and stores the original (`setProposalApplied`), so
* `resolve()` finds it in the mutated buffer and revert/decorate key off it.
* Idempotent: a no-op if already applied.
*/
async optimisticApply(document: vscode.TextDocument, proposalId: string): Promise<boolean> {
if (!this.isTracked(document) || this.guard.isReadOnly(this.keyOf(document))) return false;
const docPath = this.keyOf(document);
const state = this.ensureState(document);
if (state.applied.has(proposalId)) return true;
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const proposal = state.artifact.proposals.find((p) => p.id === proposalId);
const fp = proposal ? state.artifact.anchors[proposal.anchorId]?.fingerprint : undefined;
if (!proposal || !fp) return false;
// Reload-safety (INV-51/54): a proposal that already carries `original` was
// optimistically applied in a PRIOR session — the buffer holds the applied text
// and `fp` points at it, but this (fresh) controller's in-memory `applied` set is
// empty. Re-applying would recapture `original` from the already-applied buffer
// (= the replacement) and CLOBBER the true revert target, breaking Reject. Mark it
// applied in memory and stop — `original` is captured exactly once, on first apply.
if (proposal.original !== undefined) {
state.applied.add(proposalId);
this.renderAll(document);
return true;
}
const resolved = resolve(document.getText(), fp);
if (resolved === "orphaned") return false;
const original = document.getText(
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
);
const ok =
proposal.granularity === "block"
? await this.applyBlockOptimistic(document, resolved, proposal)
: await this.attribution.applyAgentEdit(
document,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
proposal.replacement,
proposal.author,
{ expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false },
);
if (!ok) return false;
state.applied.add(proposalId);
// Re-anchor to the applied text now in the buffer (its start is unchanged; its
// end shifts by the net length delta of the replacement).
const appliedStart = resolved.start;
const appliedEnd = appliedStart + proposal.replacement.length;
const appliedFp = buildFingerprint(document.getText(), { start: appliedStart, end: appliedEnd });
this.store.update(docPath, (a) => setProposalApplied(a, proposalId, appliedFp, original));
this.renderAll(document);
return true;
}
/** Block optimistic apply: the INV-40 per-word hunks, but landBaseline:false. */
private async applyBlockOptimistic(
document: vscode.TextDocument,
resolved: OffsetRange,
proposal: Proposal,
): Promise<boolean> {
const blockText = document.getText(
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
);
const subHunks = wordEditHunks(blockText, proposal.replacement);
if (subHunks.length === 0) return true;
for (const h of [...subHunks].sort((a, b) => b.start - a.start)) {
const range = new vscode.Range(
document.positionAt(resolved.start + h.start),
document.positionAt(resolved.start + h.end),
);
const ok = await this.attribution.applyAgentEdit(document, range, h.replacement, proposal.author, {
expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false,
});
if (!ok) return false;
}
return true;
}
/**
* F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is
* already in the buffer, so this only advances the F6 baseline (machine-landing,
* via `attribution.signalLanded`) and clears the proposal. No re-application.
*/
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
this.attribution.signalLanded(document);
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
this.renderAll(document);
return true;
}
/**
* F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live
* applied span with the stored `original`, then clear it. Reverts the whole block
* regardless of any in-place edits the human made to the inserted text.
*/
async revertInPlace(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved !== "orphaned" && hit.proposal.original !== undefined) {
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
}
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
this.renderAll(document);
return true;
}
/**
* F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in
* DESCENDING anchor order (so an earlier revert never shifts a later one's
* offsets), symmetric with #46's accept-all. Returns the reverted count.
*/
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number }> {
if (!this.isTracked(document)) return { reverted: 0 };
const docPath = this.keyOf(document);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const text = document.getText();
const ordered = state.artifact.proposals
.map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const r = fp ? resolve(text, fp) : "orphaned";
return { id: p.id, start: r === "orphaned" ? -1 : r.start };
})
.sort((a, b) => b.start - a.start);
let reverted = 0;
for (const it of ordered) if (await this.revertInPlace(docPath, it.id)) reverted++;
return { reverted };
}
private reject(state: DocState, proposal: Proposal): void {
if (this.guard.isReadOnly(state.docPath)) return;
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
+19
View File
@@ -37,6 +37,25 @@ export function removeProposal(artifact: Artifact, proposalId: string): boolean
return artifact.proposals.length < before;
}
/**
* F12/#64 (INV-48): record a proposal as optimistically applied — store the
* pre-apply `original` (for revert + the struck `<del>`) and re-point its anchor
* fingerprint to the now-in-buffer applied text so `resolve()` finds it. Idempotent
* shape: a second call simply overwrites with the same values.
*/
export function setProposalApplied(
artifact: Artifact,
proposalId: string,
appliedFp: Fingerprint,
original: string,
): boolean {
const p = artifact.proposals.find((x) => x.id === proposalId);
if (!p) return false;
p.original = original;
artifact.anchors[p.anchorId] = { fingerprint: appliedFp };
return true;
}
/** Markdown comment body: instruction header + fenced whole-range diff. */
export function proposalBody(targetText: string, p: Proposal): string {
const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**";
+243 -38
View File
@@ -263,6 +263,42 @@ export function wordEditHunks(currentText: string, rewrittenText: string): EditH
return hunks;
}
/** F12/#64 (INV-49/52): the editor render of one optimistically-applied proposal. */
export interface DecorationPlan {
/** buffer ranges of inserted (proposed) text → tinted (INV-52). */
insertions: { start: number; end: number }[];
/** struck original text shown as a non-editable hint at a buffer offset (INV-52). */
deletions: { at: number; text: string }[];
}
/**
* F12/#64 (INV-49): compute the editor decoration plan for a proposal whose applied
* text occupies `[anchorStart, anchorStart+replacement.length)` in the buffer. The
* SAME word diff the webview uses (`wordEditHunks`) drives it, so both surfaces show
* the identical diff. A changed run maps to (a) an insertion range over the run's
* applied text and (b) a deletion hint carrying the run's removed original text at
* the run start; a pure insertion has no deletion hint; a pure deletion has only a
* hint. Pure, vscode-free, deterministic.
*/
export function decorationPlan(anchorStart: number, original: string, replacement: string): DecorationPlan {
const insertions: { start: number; end: number }[] = [];
const deletions: { at: number; text: string }[] = [];
// Walk the word diff once, tracking the applied-side offset as we consume parts.
let appliedOffset = anchorStart;
for (const part of diffWordsWithSpace(original, replacement)) {
if (part.added) {
insertions.push({ start: appliedOffset, end: appliedOffset + part.value.length });
appliedOffset += part.value.length;
} else if (part.removed) {
deletions.push({ at: appliedOffset, text: part.value });
// removed text is NOT in the applied buffer → appliedOffset does not advance
} else {
appliedOffset += part.value.length;
}
}
return { insertions, deletions };
}
const isWs = (c: string): boolean => /\s/.test(c);
/**
@@ -438,6 +474,76 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
.join("");
}
/** The author whose span covers `offset` (half-open [start,end)), or null. */
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
return null;
}
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
/**
* Author-colored word diff for a changed PROSE block in the review.
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
* offset maps into `spans`. Insertions are colored by the author covering their
* offset; deletions inherit the author of the insertion they are paired with in
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
* cluster has no insertion. Pure, deterministic.
*/
export function wordDiffByAuthor(
beforeRaw: string,
afterRaw: string,
afterStart: number,
spans: AuthorSpan[],
): string {
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
// First pass: find each change cluster's insertion author (first added run).
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
let afterOff = afterStart;
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
{
let off = afterStart;
let i = 0;
while (i < parts.length) {
const p = parts[i];
if (!p.added && !p.removed) {
clusterAuthor[i] = null;
off += p.value.length;
i++;
continue;
}
// a cluster: scan to its end, capturing the first added run's author
let j = i;
let author: AuthorKind | null = null;
let scanOff = off;
while (j < parts.length && (parts[j].added || parts[j].removed)) {
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
if (parts[j].added) scanOff += parts[j].value.length;
j++;
}
for (let k = i; k < j; k++) clusterAuthor[k] = author;
off = scanOff;
i = j;
}
}
// Second pass: emit.
const out: string[] = [];
parts.forEach((p, i) => {
if (p.added) {
const a = authorAt(afterOff, spans);
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
afterOff += p.value.length;
} else if (p.removed) {
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
} else {
out.push(p.value);
afterOff += p.value.length;
}
});
return out.join("");
}
export interface RenderOptions {
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
render?: (src: string) => string;
@@ -536,15 +642,6 @@ function isCloseSentinel(m: string): boolean {
return m === SENT.claude.close || m === SENT.human.close;
}
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" };
}
// Markdown emphasis / code delimiters whose RUNS must never be split by an
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
// markdown-it's delimiter pairing (#33 CASE1).
@@ -565,11 +662,38 @@ function clampOffDelimiterRun(raw: string, at: number): number {
return p;
}
/**
* Returns the offset within `raw` after block-level markdown markers (ATX
* headings, list markers, blockquote markers). Open sentinels must not be
* injected before these markers — a PUA char at position 0 would prevent
* markdown-it from recognising the block construct (e.g. `##` is only a
* heading marker when it is the very first char on the line).
*/
function blockContentStart(raw: string): number {
// ATX headings: "#{1,6} " at the start of the line.
const heading = raw.match(/^#{1,6} /);
if (heading) return heading[0].length;
// Unordered list markers: "- ", "* ", "+ ".
const ul = raw.match(/^[-*+] /);
if (ul) return ul[0].length;
// Ordered list markers: "N. " or "N) ".
const ol = raw.match(/^\d+[.)]\s+/);
if (ol) return ol[0].length;
// Blockquotes: "> " or ">".
const bq = raw.match(/^> ?/);
if (bq) return bq[0].length;
return 0;
}
/** 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 {
const inserts: { at: number; marker: string }[] = [];
// Open sentinels must not precede block-level markdown markers (heading/list/
// blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it
// from recognising the heading. Clamp to the content-start offset.
const minOpen = blockContentStart(raw);
for (const s of spans) {
const lo = clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart));
const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart)));
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
inserts.push({ at: lo, marker: SENT[s.author].open });
@@ -596,7 +720,7 @@ const ALL_SENTINELS = new RegExp(
);
/**
* #33: token-aware replacement of the rendered author sentinels with `cw-by-*`
* #33: token-aware replacement of the rendered author sentinels with `cw-ins-*`
* spans. A naive global string-replace (the old approach) could emit a span that
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
@@ -605,13 +729,13 @@ const ALL_SENTINELS = new RegExp(
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
*/
function sentinelsToSpans(html: string): string {
function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
const out: string[] = [];
let current: AuthorKind | null = null; // which author region we're inside
let spanOpen = false; // whether a <span> is currently open in `out`
const openSpan = () => {
if (current && !spanOpen) {
out.push(`<span class="cw-by-${current}">`);
out.push(`<span class="cw-${kind}-${current}">`);
spanOpen = true;
}
};
@@ -659,10 +783,11 @@ export function colorByAuthor(
blockStart: number,
spans: AuthorSpan[],
render: (src: string) => string,
kind: "by" | "ins" = "by",
): string {
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
const injected = injectSentinels(raw, blockStart, overlapping);
return sentinelsToSpans(render(injected));
return sentinelsToSpans(render(injected), kind);
}
/**
@@ -701,6 +826,10 @@ export interface ProposalView {
replaced: string;
/** the proposed replacement text. */
replacement: string;
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
original?: string;
/** Who made the proposal — colors the del/ins. Defaults to "claude". */
author?: AuthorKind;
}
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
@@ -712,12 +841,19 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
}
};
const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : "";
const before = p.replaced ? `<del class="cw-del">${safe(p.replaced)}</del>` : "";
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
const who = p.author ?? "claude";
const before = p.replaced ? `<del class="cw-del-${who}">${safe(p.replaced)}</del>` : "";
const after = `<ins class="cw-ins-${who}">${safe(p.replacement)}</ins>`;
const actions =
`<span class="cw-actions">` +
`<button class="cw-accept" data-action="accept">✓</button>` +
`<button class="cw-reject" data-action="reject">✗</button>` +
`<span class="cw-btngroup">` +
`<button class="cw-accept" data-action="accept">Accept</button>` +
`<button class="cw-caret" data-action="acceptAll" title="Accept all pending proposals">▾</button>` +
`</span>` +
`<span class="cw-btngroup">` +
`<button class="cw-reject" data-action="reject">Reject</button>` +
`<button class="cw-caret" data-action="rejectAll" title="Reject all pending proposals">▾</button>` +
`</span>` +
`</span>`;
return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
}
@@ -725,20 +861,27 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
function renderReviewOp(
op: BlockOp,
render: (src: string) => string,
colored: (raw: string) => string,
src: string,
changedHtml?: string,
): string {
// removed blocks and any changed block (atomic fences diffed whole; non-atomic prose
// word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7).
// `src` is "" for a removed block (no live source — INV-36).
// A non-atomic changed prose block is pre-rendered with author-colored ins/del
// (changedHtml). Removed blocks and atomic changed fences stay neutral via renderOp
// (standalone deletion → neutral, per the adjacency heuristic). `src` is "" for a
// removed block (no live source — INV-36).
if (op.kind === "changed" && !op.atomic && changedHtml !== undefined) {
return `<div class="cw-blk cw-changed"${src}>${changedHtml}</div>`;
}
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
// "added": changedHtml is colorByAuthor sentinel output (markdown-safe, cw-ins-*).
// "unchanged": render(raw) — plain, no author coloring (color = "changed since baseline").
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${changedHtml ?? render(op.block.raw)}</div>`;
}
/**
* On-state body (INV-33): the F7 baseline diff — added/changed PROSE author-colored
* via colorByAuthor (F9 sentinels), deletions struck — overlaid with F4 pending
* proposals as blue cw-proposal blocks (✓/✗). One pass, pure, vscode-free.
* On-state body: the F7 baseline diff — added/changed PROSE author-colored via
* wordDiffByAuthor (cw-ins-{author}/cw-del-{author} per token), unchanged blocks render plain,
* deletions struck neutral — overlaid with F4 pending proposals as blue cw-proposal
* blocks (✓/✗). One pass, pure, vscode-free.
*
* A resolved proposal renders INLINE, right after the current-side block its anchor
* falls in (#31) — so "what is Claude proposing, and where?" is answered by
@@ -747,6 +890,26 @@ function renderReviewOp(
* INV-34). Deterministic: proposals in the same block are ordered by anchorStart
* then id; trailing proposals keep input order.
*/
/**
* F12/#64 (INV-50): `currentText` with every resolved pending proposal's applied
* span reverted to its original (`replaced`) — the "landed" text the baseline diff
* should run against, so a pending proposal renders ONCE (as a proposal), never also
* as a landed change. Reverts high→low so earlier offsets stay valid. Pure. The
* preview's summary tally diffs against this too, so the toolbar count matches the
* body (a pending change is not double-counted as both a landed add/remove and a
* proposal).
*/
export function landedTextOf(currentText: string, proposals: ProposalView[]): string {
const pendingApplied = proposals
.filter((p) => p.anchorStart !== null)
.sort((a, b) => b.anchorStart! - a.anchorStart!);
let landedText = currentText;
for (const p of pendingApplied) {
landedText = landedText.slice(0, p.anchorStart!) + p.replaced + landedText.slice(p.anchorEnd!);
}
return landedText;
}
export function renderReview(
baselineText: string,
currentText: string,
@@ -755,18 +918,47 @@ export function renderReview(
opts: RenderOptions = {},
): string {
const render = opts.render ?? defaultRender;
const ranges = splitBlocksWithRanges(currentText);
const ops = diffBlocks(baselineText, currentText);
// F12/#64 (INV-50): with optimistic apply the proposed text is already in
// `currentText`, so a naive baseline→current diff would render each proposed
// change BOTH as a landed diff and as its proposal block. Diff against the
// "landed" text (current minus pending proposals) so they render once.
const pendingApplied = proposals
.filter((p) => p.anchorStart !== null)
.sort((a, b) => b.anchorStart! - a.anchorStart!);
const landedText = landedTextOf(currentText, proposals);
const ranges = splitBlocksWithRanges(landedText);
const ops = diffBlocks(baselineText, landedText);
// #48: right after a PIN (baseline reason "pinned") with no changes since, the
// panel is fully clean: no change marks (already absent) AND no authorship
// coloring, so the pin reads as "this is my clean starting point". Skip
// colorByAuthor for every block in this case; data-src mapping and any pending
// proposals (review actions, not annotations) are kept below. A baseline advanced
// by a machine-landing (accept) is ALSO zero-diff but is NOT pinned — it keeps
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically.
// coloring, so the pin reads as "this is my clean starting point". Suppress
// wordDiffByAuthor for every block in this case; data-src mapping and any pending
// proposals (review actions, not annotations) are kept below.
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
// Associate each resolved proposal with the current-side block index whose range
// Map a currentText offset to its landedText offset (account for reverted spans
// that precede it; reverts were applied high→low so the cumulative delta is stable).
const toLanded = (curOff: number): number => {
let delta = 0;
for (const p of [...pendingApplied].sort((a, b) => a.anchorStart! - b.anchorStart!)) {
if (p.anchorStart! < curOff) delta += p.replaced.length - (p.anchorEnd! - p.anchorStart!);
}
return curOff + delta;
};
// F12/#64 (INV-49/50): blocks are split from `landedText`, so `blk.start` is a
// landedText offset — but `authorSpans` arrive in `currentText` coordinates. A
// colored block sitting AFTER a length-changing pending proposal would otherwise
// be mis-colored (the offsets diverge by the revert delta). Map the spans into
// landedText coordinates once so authorship coloring stays aligned.
const landedSpans: AuthorSpan[] = authorSpans.map((s) => ({
...s,
start: toLanded(s.start),
end: toLanded(s.end),
}));
// Associate each resolved proposal with the landedText block index whose range
// it anchors into: the largest block with start <= anchorStart (the containing
// block, or the nearest preceding block when the anchor sits in a gap). A
// resolved anchor before all blocks, and every unresolved proposal, trails.
@@ -778,7 +970,7 @@ export function renderReview(
const byBlock = new Map<number, ProposalView[]>();
const trailing: ProposalView[] = [];
for (const p of proposals) {
const j = p.anchorStart === null ? -1 : blockOf(p.anchorStart);
const j = p.anchorStart === null ? -1 : blockOf(toLanded(p.anchorStart));
if (j < 0) {
trailing.push(p);
continue;
@@ -794,9 +986,22 @@ export function renderReview(
for (const op of ops) {
const blockIndex = op.kind === "removed" ? -1 : ci;
const blk = op.kind === "removed" ? undefined : ranges[ci++];
const colored = (raw: string): string =>
blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
// Only ADDED and CHANGED prose blocks are author-colored (they differ since baseline);
// UNCHANGED blocks render plain (color means "changed since baseline").
// A non-atomic changed prose block: author-colored word diff (ins by author,
// del by adjacency). An added block: sentinel-safe colorByAuthor with kind="ins"
// (markdown-it parses the block first, so ## → <h2> before sentinels become spans;
// the old wordDiffByAuthor path wrapped raw in <ins> BEFORE parsing — structural bug).
// `blk.start` is the landed offset of the after-side text.
const changedHtml =
blk && !clean
? op.kind === "changed" && !op.atomic
? render(wordDiffByAuthor(op.before.raw, op.block.raw, blk.start, landedSpans))
: op.kind === "added"
? colorByAuthor(op.block.raw, blk.start, landedSpans, render, "ins")
: undefined
: undefined;
bodyParts.push(renderReviewOp(op, render, srcAttr(blk), changedHtml));
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
}
+37 -13
View File
@@ -13,11 +13,12 @@ import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
import { promptEditInstruction } from "./editInstructionInput";
/**
* F11: a host edit turn (selection/document text + instruction → rewrite).
@@ -43,7 +44,8 @@ type ToolbarMsg =
| { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number }
| { type: "acceptAll" };
| { type: "acceptAll" }
| { type: "rejectAll" };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
@@ -62,6 +64,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts);
};
/**
* The instruction prompt (the multi-line split-below webview box) for BOTH the
* selection and document cases. A field so host E2E can stub it — the webview
* DOM can't run in CI (mirrors `editTurn`). Also used by the editor's
* `cowriting.editSelection` command (via this controller).
*/
askEditInstruction: (header: string) => Promise<string | undefined> = promptEditInstruction;
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
private nextTurnSeq(): number {
@@ -188,8 +197,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
.acceptById(this.proposals.keyFor(document), m.proposalId)
.then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) {
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
this.refresh(document);
void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document));
} else if (m?.type === "pinBaseline") {
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
this.diffView.pin(document);
@@ -200,6 +208,8 @@ export class TrackChangesPreviewController implements vscode.Disposable {
} else if (m?.type === "acceptAll") {
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
void this.acceptAll(document);
} else if (m?.type === "rejectAll") {
void this.rejectAll(document);
}
}
@@ -220,19 +230,29 @@ export class TrackChangesPreviewController implements vscode.Disposable {
);
}
/** #64 (INV-53): revert every pending proposal on the document; report the count. */
async rejectAll(document: vscode.TextDocument): Promise<void> {
const { reverted } = await this.proposals.rejectAll(document);
this.refresh(document);
if (reverted > 0) {
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
);
}
}
/**
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
*/
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
const instruction = await vscode.window.showInputBox({
prompt:
target.kind === "document"
? "What should Claude do with the document?"
: "What should Claude do with the selection?",
placeHolder: "e.g. tighten the prose",
});
// Both scopes use the same multi-line split-below webview box; only the header
// (and the downstream proposal logic) differs. For a selection the document
// above keeps the selection highlighted while the box is open.
const header =
target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection";
const instruction = await this.askEditInstruction(header);
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
@@ -352,9 +372,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
}
const spans = this.attribution.spansFor(document);
const proposals = this.proposals.listProposals(document);
// F12/#64 (INV-50): count added/removed against the LANDED text (current minus
// pending proposals), matching the body — a pending change shows once, as a
// proposal, and is not also tallied as a landed add/remove.
const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals));
const summary = {
added: ops.filter((o) => o.kind === "added").length,
removed: ops.filter((o) => o.kind === "removed").length,
added: landedOps.filter((o) => o.kind === "added").length,
removed: landedOps.filter((o) => o.kind === "removed").length,
proposals: proposals.length,
};
void panel.webview.postMessage({
+26
View File
@@ -60,3 +60,29 @@ export function selectionRejection(ctx: SelectionContext): string | null {
}
return null;
}
export interface EditRouteContext {
/** Was the command invoked on a specific resource (a tab right-click)? */
hasUri: boolean;
/** Does that resource match the focused editor's document? */
uriMatchesActiveEditor: boolean;
/** Is there a focused text editor at all? */
hasActiveEditor: boolean;
/** Is the focused editor's selection empty (no highlight)? */
selectionEmpty: boolean;
}
/**
* Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the
* selection or whole-document flow. One conceptual command, two destinations:
*
* - A tab right-click on a document that ISN'T the focused editor has no
* selection to act on → edit the whole document.
* - Otherwise a non-empty selection in the focused editor → edit the selection;
* an empty selection (or no editor) → edit the whole document.
*/
export function routeEdit(ctx: EditRouteContext): "selection" | "document" {
if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document";
if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection";
return "document";
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, test, expect } from "vitest";
import { decorationPlan } from "../src/trackChangesModel";
describe("decorationPlan (INV-49)", () => {
test("derives insertion ranges + deletion hints from original→replacement", () => {
// anchorStart 10; original 'the brown fox' → 'the red fox'
const plan = decorationPlan(10, "the brown fox", "the red fox");
// one changed run: 'brown ' → 'red ' (word-level); insertion 'red ' tinted,
// deletion hint 'brown ' shown at the run start.
expect(plan.insertions.length).toBeGreaterThan(0);
expect(plan.deletions.length).toBeGreaterThan(0);
// insertion offsets are in buffer coords (>= anchorStart) and lie within the applied text
for (const ins of plan.insertions) {
expect(ins.start).toBeGreaterThanOrEqual(10);
expect(ins.end).toBeLessThanOrEqual(10 + "the red fox".length);
}
// a deletion hint carries the struck original text at a buffer offset
expect(plan.deletions[0].text).toContain("brown");
});
test("pure insertion (no deletion) yields an insertion range and no deletion hint", () => {
// 'hello' → 'hello world': ' world' is a pure word-level insertion (no removed tokens)
const plan = decorationPlan(0, "hello", "hello world");
expect(plan.insertions.length).toBe(1);
expect(plan.deletions.length).toBe(0);
});
});
+12 -2
View File
@@ -13,11 +13,20 @@ async function main(): Promise<void> {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-"));
fs.cpSync(fixture, workspace, { recursive: true });
// VS Code derives its IPC socket path from --user-data-dir. The default
// (`<projectRoot>/.vscode-test/user-data`) plus `/<version>-main.sock` can blow
// past macOS's ~103-char UNIX-socket limit (`listen EINVAL`) when the project
// lives at a long path (e.g. a git worktree). Pin the user-data dir to a SHORT
// root under /tmp so the socket path stays well under the limit. (os.tmpdir() on
// macOS is itself a long /var/folders/… path, so we use /tmp directly.)
const userDataRoot = process.platform === "win32" ? os.tmpdir() : "/tmp";
const userDataDir = fs.mkdtempSync(path.join(userDataRoot, "cwud-"));
try {
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [workspace, "--disable-extensions"],
launchArgs: [workspace, "--disable-extensions", "--user-data-dir", userDataDir],
extensionTestsEnv: { E2E_WORKSPACE: workspace },
});
@@ -26,10 +35,11 @@ async function main(): Promise<void> {
await runTests({
extensionDevelopmentPath,
extensionTestsPath: path.resolve(__dirname, "./suite-no-workspace/index"),
launchArgs: ["--disable-extensions"],
launchArgs: ["--disable-extensions", "--user-data-dir", userDataDir],
});
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
fs.rmSync(userDataDir, { recursive: true, force: true });
}
}
@@ -28,6 +28,7 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
"cowriting.reply",
"cowriting.resolveThread",
"cowriting.reopenThread",
"cowriting.edit",
"cowriting.editSelection",
"cowriting.applyAgentEdit",
"cowriting.proposeAgentEdit",
+21 -11
View File
@@ -16,15 +16,16 @@ async function getApi(): Promise<CowritingApi> {
// F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test.
// F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview
// now author-colors Claude's landed prose. This suite confirms a Claude-landed
// span renders as a cw-by-claude span in the on-state preview HTML. Owns its own
// markdown doc, disjoint from the other suites' fixtures.
suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-state (host E2E, no LLM)", () => {
// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. After the
// proposal is accepted the baseline advances (INV-18/F12) and the text becomes
// "unchanged" → renders plain. The class to check is cw-ins-claude (proposal block),
// NOT cw-by-claude (the old F9 authorship render). Owns its own markdown doc.
suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => {
const DOC_REL = "docs/f10claude.md";
const TARGET = "The sentence Claude will compose over.";
const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam.";
test("an accepted Claude edit author-colors as cw-by-claude in the on-state render; mode defaults to on", async () => {
test("a pending Claude proposal carries cw-ins-claude; accepted proposal leaves attribution data intact; mode defaults to on", async () => {
const abs = path.join(WS, DOC_REL);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8");
@@ -41,7 +42,7 @@ suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-st
assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open");
assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on");
// Claude composes via the seam (propose → accept)
// Claude proposes via the seam (F12 optimistic-apply; proposal stays PENDING)
const start = doc.getText().indexOf(TARGET);
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: key,
@@ -52,17 +53,26 @@ suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-st
sessionId: "e2e-f10",
turnId: "turn-f10",
});
assert.ok(id, "propose returns an id");
await settle();
// PENDING proposal: the preview renders the proposal block with cw-ins-claude.
// After accepting, the baseline advances (INV-18) → the text becomes "unchanged"
// and renders plain — so we check BEFORE accepting.
const pendingHtml = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(
pendingHtml.includes("cw-ins-claude"),
"pending Claude proposal block carries cw-ins-claude in the on-state render",
);
// accept via the seam — baseline advances (machine-landing, INV-18/F12)
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
// attribution recorded a Claude (agent) span (data layer intact)
const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent");
assert.ok(claudeSpan, "Claude span recorded by F3");
assert.ok(claudeSpan, "Claude span recorded by F3 (data layer intact after accept)");
const spans = api.attributionController.spansFor(doc);
assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview");
// the on-state render author-colors the landed Claude text as cw-by-claude
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.match(html, /<span class="cw-by-claude">/, "landed Claude prose is author-colored in the on-state render");
});
});
+65 -9
View File
@@ -74,7 +74,7 @@ suite("F10 interactive review (host E2E — preview is the single review surface
void doc;
});
test("typing produces an added/changed block and a cw-by-human span in the on-state render (PUC-2)", async () => {
test("typing produces an added/changed block and a cw-ins-human span in the on-state render (PUC-2)", async () => {
const { key } = await reopen(DOC_REL);
const api = await getApi();
const doc = byKey(key)!;
@@ -85,13 +85,13 @@ suite("F10 interactive review (host E2E — preview is the single review surface
const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind);
assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing");
// Attribution recorded the human span (data layer), and the on-state render
// author-colors that prose as cw-by-human.
// author-colors that prose as cw-ins-human (added blocks use cw-ins-{author} since Task 3/44ef0a2).
assert.ok(
api.attributionController.spansFor(doc).some((s) => s.author === "human"),
"a human span was recorded for the typed text",
);
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.match(html, /<span class="cw-by-human">/, "typed text is author-colored human in the on-state");
assert.ok(html.includes("cw-ins-human"), "typed text is author-colored human in the on-state (cw-ins-human for added blocks)");
});
test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => {
@@ -109,8 +109,9 @@ suite("F10 interactive review (host E2E — preview is the single review surface
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
const t2Idx = html.indexOf("second claude target");
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
// INV-10: proposing never touches the document.
assert.ok(doc.getText().includes(T1), "document unchanged by propose");
// F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer;
// the replacement text is now in the document, proposal is still pending.
assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer");
});
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
@@ -131,17 +132,18 @@ suite("F10 interactive review (host E2E — preview is the single review surface
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
});
test("reject → the proposal vanishes and the document is untouched (PUC-5)", async () => {
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => {
const { doc, key } = await reopen(DOC_REL);
const api = await getApi();
const before = doc.getText();
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
await settle();
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject");
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit.
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
await settle();
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
assert.strictEqual(doc.getText(), before, "document untouched by reject");
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state");
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
});
@@ -173,7 +175,7 @@ suite("F10 interactive review (host E2E — preview is the single review surface
// (INV-33). Assert the actual off-state body the controller posts has no cw-
// author/proposal marks — meaningful because the on-state above DID carry one.
const offBody = renderPlain(doc.getText());
assert.ok(!/cw-proposal|cw-by-claude|cw-by-human|cw-del/.test(offBody), "off-state render carries no cw- marks");
assert.ok(!/cw-proposal|cw-by-|cw-ins-|cw-del/.test(offBody), "off-state render carries no cw- marks");
// toggle back on → marks return.
api.trackChangesPreviewController.setMode(key, "on");
@@ -230,6 +232,60 @@ suite("F10 interactive review (host E2E — preview is the single review surface
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept");
void key;
});
test("author-colored track changes: cw-ins-human + cw-ins-claude appear; unchanged blocks are plain (Tasks 1-4)", async () => {
// Fresh doc with three blocks: one that stays unchanged, one for Claude to target, one for human typing.
const { doc, key } = await freshDoc(
"docs/f10authorcolors.md",
"# Author Colors\n\nUnchanged paragraph that stays.\n\nClaude target block.\n",
);
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Human types a new paragraph → an "added" block relative to the opened baseline.
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nHuman typed paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "human edit applied");
await settle();
// Propose a Claude change (stays PENDING — F12 optimistic-apply; NOT accepted).
// The proposal block renders with cw-ins-claude.
const target = "Claude target block.";
const targetStart = doc.getText().indexOf(target);
assert.ok(targetStart >= 0, "target text present after human edit");
await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: key,
start: targetStart,
end: targetStart + target.length,
newText: "Claude replacement block.",
model: "sonnet",
sessionId: "e2e-f10-colors",
turnId: "turn-colors-1",
});
await settle();
const html = api.trackChangesPreviewController.renderHtmlFor(key);
// The human-typed added block renders with cw-ins-human.
assert.ok(html.includes("cw-ins-human"), "human added block is colored cw-ins-human");
// The pending Claude proposal block renders with cw-ins-claude.
assert.ok(html.includes("cw-ins-claude"), "pending Claude proposal block carries cw-ins-claude");
// The unchanged block renders as cw-unchanged with no cw-ins-/cw-by- inside it
// (color = "changed since baseline"; unchanged text is never annotated).
const unchangedBlocks = [...html.matchAll(/<div class="cw-blk cw-unchanged"[^>]*>([\s\S]*?)<\/div>/g)];
assert.ok(unchangedBlocks.length > 0, "at least one unchanged block exists");
assert.ok(
unchangedBlocks.some((m) => m[1].includes("Unchanged paragraph that stays")),
"the unchanged paragraph is in a cw-unchanged block",
);
assert.ok(
unchangedBlocks.every((m) => !/cw-ins-|cw-by-/.test(m[1])),
"unchanged blocks carry no cw-ins- or cw-by- classes",
);
});
});
// ---- helpers reused across the order-dependent main-flow tests ----
+20 -11
View File
@@ -99,8 +99,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
"The quick RED fox jumps over the lazy CAT.",
"the block proposal carries the whole rewritten paragraph",
);
// INV-10: proposing never mutates the document.
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
// F12 (INV-48): EditorProposalController optimistically applies the proposed text,
// so after settle the buffer has the replacement. The proposal is still pending.
assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer");
void key;
});
@@ -129,7 +130,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.ok(view, "the proposal is live");
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range");
assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)");
// F12 (INV-48): the EditorProposalController optimistically applies, so the buffer
// now has the replacement. `view.replaced` still records the original target text.
assert.ok(doc.getText().includes("The REWRITTEN paragraph from Claude."), "F12 optimistic-apply: proposed text in buffer");
void key;
});
@@ -174,16 +177,22 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
});
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
// SLICE-3: the document-scoped command stays registered for #42 reuse (now behind
// the unified `cowriting.edit`). editDocument is hidden from the palette; the
// markdown-guarded user-facing palette command is `cowriting.edit`.
test("cowriting.editDocument stays registered; cowriting.edit is the markdown-guarded palette command", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.editDocument",
);
assert.ok(entry, "editDocument has a commandPalette entry");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
assert.ok(all.includes("cowriting.edit"), "unified edit command registered");
const palette = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")).contributes
.menus.commandPalette as Array<{ command: string; when?: string }>;
// editDocument is hidden (routed via cowriting.edit).
const docEntry = palette.find((m) => m.command === "cowriting.editDocument");
assert.strictEqual(docEntry?.when, "false", "editDocument hidden from the palette");
// cowriting.edit is the user-facing, markdown-guarded entry.
const editEntry = palette.find((m) => m.command === "cowriting.edit");
assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
+251
View File
@@ -0,0 +1,251 @@
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";
import { addProposal, setProposalApplied } from "../../../src/proposalModel";
import { buildFingerprint } from "../../../src/anchorer";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400));
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 controllers");
return api;
}
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
return { doc, key: uri.toString() };
}
suite("F12 inline diff — seam landBaseline (#64, INV-48)", () => {
test("applyAgentEdit with landBaseline:false applies text but does NOT advance the baseline", async () => {
const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n");
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText();
const start = doc.getText().indexOf("alpha");
const ok = await api.attributionController.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + "alpha".length)),
"ALPHA",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } },
{ landBaseline: false },
);
await settle();
assert.ok(ok, "edit applied");
assert.ok(doc.getText().includes("ALPHA"), "text in buffer");
assert.strictEqual(api.diffViewController.getBaseline(key)?.text ?? doc.getText(), before, "baseline unchanged");
});
});
suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
// Optimistic apply lands the text + re-anchors; the buffer becomes the accepted result.
test("optimisticApply puts the proposed text in the buffer and re-anchors", async () => {
const { doc } = await freshDoc("docs/f12-opt.md", "# T\n\nReplace alpha please.\n");
const api = await getApi();
const p = api.proposalController;
const fp = { text: "Replace alpha please.", before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Replace ALPHA please.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await api.proposalController.optimisticApply(doc, id!);
await settle();
assert.ok(doc.getText().includes("Replace ALPHA please."), "applied to buffer");
// re-anchored: the proposal still resolves against the mutated buffer
assert.strictEqual(p.listProposals(doc)[0].anchorStart !== null, true, "re-anchored, resolves");
assert.strictEqual(p.listProposals(doc)[0].original, "Replace alpha please.", "original stored");
});
test("finalizeInPlace clears the proposal and keeps the applied text (no double-apply)", async () => {
const { doc } = await freshDoc("docs/f12-fin.md", "# T\n\nKeep alpha now.\n");
const api = await getApi();
const p = api.proposalController;
const docPath = p.keyFor(doc);
const fp = { text: "Keep alpha now.", before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Keep ALPHA now.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
const ok = await p.finalizeInPlace(docPath, id!);
await settle();
assert.ok(ok, "finalized");
assert.strictEqual(doc.getText(), "# T\n\nKeep ALPHA now.\n", "applied text retained, no double-apply");
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
});
test("revertInPlace restores the original and clears the proposal", async () => {
const { doc } = await freshDoc("docs/f12-rev.md", "# T\n\nUndo alpha here.\n");
const api = await getApi();
const p = api.proposalController;
const docPath = p.keyFor(doc);
const fp = { text: "Undo alpha here.", before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Undo ALPHA here.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
const ok = await p.revertInPlace(docPath, id!);
await settle();
assert.ok(ok, "reverted");
assert.strictEqual(doc.getText(), "# T\n\nUndo alpha here.\n", "original restored");
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
});
test("rejectAll reverts every pending proposal", async () => {
const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const p = api.proposalController;
ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
await settle();
for (const id of ids) await p.optimisticApply(doc, id);
await settle();
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"), "both applied");
const { reverted } = await p.rejectAll(doc);
await settle();
assert.strictEqual(reverted, ids.length, "all reverted");
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document restored");
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
});
});
suite("F12 inline diff — INV-50 listProposals.replaced", () => {
test("listProposals reports the original as `replaced` after optimistic apply", async () => {
const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "x");
await settle(); await settle();
assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here.");
});
});
suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => {
const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
await settle(); await settle();
assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer");
const v = api.proposalController.listProposals(doc)[0];
assert.strictEqual(v.original, "The brown fox runs.", "original captured for the deletion hint/revert");
});
test("editing the inserted text then finalizing keeps the human edit", async () => {
const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle();
// human tweaks the inserted text
const at = doc.getText().indexOf("ALPHA");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 5)), "ALPHA!");
await vscode.workspace.applyEdit(we);
await settle();
// keyFor(doc) gives the repo-relative path that finalizeInPlace uses as its key;
// the `key` from freshDoc is the URI string, which would not match for in-workspace docs.
const docKey = api.proposalController.keyFor(doc);
await api.proposalController.finalizeInPlace(docKey, ids[0]);
await settle();
assert.ok(doc.getText().includes("ALPHA! word here."), "human edit preserved on accept");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "cleared");
});
});
suite("F12 inline diff — control parity (#64, INV-53)", () => {
test("reject from the webview reverts in place; rejectAll clears every proposal", async () => {
const { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle();
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
// reject ONE via the webview intent → that block reverts, the other stays applied
ctl.receiveMessage(key, { type: "reject", proposalId: ids[0] });
await settle(); await settle();
assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied");
// rejectAll via the command → all gone, document restored
ctl.receiveMessage(key, { type: "rejectAll" });
await settle(); await settle();
assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0);
});
test("cowriting.rejectAllProposals is registered + markdown-guarded", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.rejectAllProposals"));
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.rejectAllProposals",
);
assert.ok(entry && /editorLangId == markdown/.test(entry.when ?? ""), "palette-guarded on markdown");
});
});
// Reload-safety: a proposal that was optimistically applied in a PRIOR session
// persists with `original` set and its fp re-anchored to the applied text. A fresh
// controller (empty in-memory `applied` set) must NOT re-capture `original` from the
// already-applied buffer — doing so would clobber the true revert target and break
// Reject. (Regression for the final-review CRITICAL; INV-51/54.)
suite("F12 inline diff — reload-safety (#64, INV-51/54)", () => {
test("optimisticApply does not clobber a previously-persisted original", async () => {
const TRUE_ORIGINAL = "The original sentence here.";
const APPLIED = "The APPLIED sentence here.";
const { doc } = await freshDoc("docs/f12-reload.md", `# R\n\n${TRUE_ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const key = p.keyFor(doc);
// 1) The buffer holds the APPLIED text (as the saved-while-pending file would).
const at = doc.getText().indexOf(TRUE_ORIGINAL);
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + TRUE_ORIGINAL.length)), APPLIED);
assert.ok(await vscode.workspace.applyEdit(we), "apply the prior-session applied text");
await settle();
// 2) Record the proposal directly in the sidecar exactly as a prior session left
// it: fp anchored to the APPLIED text + `original` = the TRUE original. We do
// NOT call optimisticApply, so this controller's in-memory `applied` stays empty.
const appliedAt = doc.getText().indexOf(APPLIED);
const appliedFp = buildFingerprint(doc.getText(), { start: appliedAt, end: appliedAt + APPLIED.length });
let id = "";
api.sidecarRouter.update(key, (a) => {
id = addProposal(a, appliedFp, APPLIED, { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }).proposalId;
setProposalApplied(a, id, appliedFp, TRUE_ORIGINAL);
});
p.renderAll(doc);
await settle();
// 3) Re-entry as a reload would trigger (EditorProposalController re-applies on
// onDidChangeProposals because `applied` is empty). The guard must preserve original.
await p.optimisticApply(doc, id);
await settle();
const view = p.listProposals(doc).find((v) => v.id === id);
assert.ok(view, "proposal still present");
assert.strictEqual(view!.original, TRUE_ORIGINAL, "true original preserved, NOT clobbered with the applied text");
// 4) Reject restores the TRUE original (not the applied text).
assert.ok(await p.revertInPlace(key, id), "revert");
await settle();
assert.ok(doc.getText().includes(TRUE_ORIGINAL), "reject restored the true original");
assert.ok(!doc.getText().includes(APPLIED), "applied text removed on reject");
});
});
+37 -41
View File
@@ -31,43 +31,37 @@ function menu(id: string): Array<{ command: string; when?: string; group?: strin
}
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
// the editor TAB, selection-aware (selection → editSelection; no selection →
// editDocument), both markdown/authorable-gated, both routing through the single
// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we
// assert them directly; the tab-targeting behavior is exercised through the command.
// the editor TAB. The two split commands (editSelection / editDocument) were
// unified behind ONE user-facing command `cowriting.edit` that auto-routes by
// selection at runtime (routeEdit: selection → editSelection, none → editDocument)
// — so the menus carry a SINGLE selection-agnostic entry, both markdown/authorable
// -gated. The routing itself is unit-tested (routeEdit) and exercised through the
// command below; here we assert the declarative menu `when` clauses.
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
// PUC-1/2: editor BODY (editor/context) offers the single unified entry,
// markdown + authorable gated, NOT selection-gated (it routes both cases).
test("editor/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown+authorable", () => {
const m = menu("editor/context");
const sel = m.find((e) => e.command === "cowriting.editSelection");
const doc = m.find((e) => e.command === "cowriting.editDocument");
assert.ok(sel, "editSelection is in editor/context");
assert.ok(doc, "editDocument is in editor/context");
assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection");
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection");
assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown");
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
const edit = m.find((e) => e.command === "cowriting.edit");
assert.ok(edit, "cowriting.edit is in editor/context");
assert.match(edit!.when ?? "", /editorLangId == markdown/, "gated on markdown");
assert.match(edit!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "gated authorable");
assert.ok(!/editorHasSelection/.test(edit!.when ?? ""), "not selection-gated — one entry routes both cases");
// The old split pair is gone from the menu (the commands stay registered/hidden).
assert.ok(!m.some((e) => e.command === "cowriting.editSelection"), "old editSelection menu entry removed");
assert.ok(!m.some((e) => e.command === "cowriting.editDocument"), "old editDocument menu entry removed");
});
// PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair.
test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => {
// PUC-3: editor TAB (editor/title/context) carries the same single entry.
test("editor/title/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown-gated", () => {
const m = menu("editor/title/context");
const sel = m.find((e) => e.command === "cowriting.editSelection");
const doc = m.find((e) => e.command === "cowriting.editDocument");
assert.ok(sel, "editSelection is in editor/title/context");
assert.ok(doc, "editDocument is in editor/title/context");
assert.match(sel!.when ?? "", /editorHasSelection/, "tab editSelection shows only with a selection");
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "tab editSelection is not gated on NO selection");
assert.match(sel!.when ?? "", /resourceLangId == markdown/, "tab editSelection gated on markdown");
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "tab editDocument shows only without a selection");
assert.match(doc!.when ?? "", /resourceLangId == markdown/, "tab editDocument gated on markdown");
const edit = m.find((e) => e.command === "cowriting.edit");
assert.ok(edit, "cowriting.edit is in editor/title/context");
assert.match(edit!.when ?? "", /resourceLangId == markdown/, "tab entry gated on markdown");
assert.ok(
!m.some((e) => e.command === "cowriting.editSelection" || e.command === "cowriting.editDocument"),
"old split pair removed from the tab menu",
);
});
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
@@ -82,9 +76,9 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.window.showTextDocument(a.doc);
await settle();
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
const origInput = vscode.window.showInputBox;
(vscode.window as any).showInputBox = async () => "rewrite it";
// Stub the document instruction prompt (the webview can't run in CI) + the LLM turn.
const origPrompt = ctl.askEditInstruction;
ctl.askEditInstruction = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
model: "sonnet",
@@ -94,7 +88,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle();
} finally {
(vscode.window as any).showInputBox = origInput;
ctl.askEditInstruction = origPrompt;
}
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
@@ -104,8 +98,10 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
0,
"active doc A was NOT edited — editDocument honored the tab URI",
);
// INV-10: proposing never mutates the document.
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
// F12 (INV-48): EditorProposalController optimistically applies proposals into the
// buffer, so the proposed text is now in the tab doc B. Confirm one of the proposal
// texts is present (the tab doc was edited, not A).
assert.ok(b.doc.getText().includes("REWRITTEN"), "F12 optimistic-apply: proposed text in tab doc B");
});
// No URI arg (palette / keybinding) → fall back to the active editor.
@@ -116,8 +112,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.window.showTextDocument(a.doc);
await settle();
const origInput = vscode.window.showInputBox;
(vscode.window as any).showInputBox = async () => "rewrite it";
const origPrompt = ctl.askEditInstruction;
ctl.askEditInstruction = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
model: "sonnet",
@@ -127,7 +123,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument");
await settle();
} finally {
(vscode.window as any).showInputBox = origInput;
ctl.askEditInstruction = origPrompt;
}
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
});
+4 -1
View File
@@ -50,7 +50,10 @@ suite("#60 live turn progress (additive + cancel)", () => {
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
assert.ok(view, "the proposal is live");
assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)");
// F12 (INV-48): the EditorProposalController optimistically applies the proposed
// text into the buffer, so after settle the active-editor buffer shows the
// replacement. The proposal is still pending (not finalized) until Accept.
assert.ok(doc.getText().includes("New paragraph."), "F12 optimistic-apply: proposed text in buffer");
});
test("an aborted turn proposes nothing (INV-47)", async () => {
+39 -16
View File
@@ -63,13 +63,14 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
const TARGET = "The propose target sentence lives here.";
const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence.";
test("propose records + renders a pending proposal and does NOT touch the document (INV-10)", async () => {
test("propose records a pending proposal; F12 optimistically applies it (INV-48, reverses INV-10)", async () => {
const doc = await openDoc();
const api = await getApi();
const before = doc.getText();
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
await settle();
assert.strictEqual(doc.getText(), before, "document text unchanged (INV-10)");
// F12 (INV-48): EditorProposalController auto-applies the proposed text into the
// buffer, so after settle the buffer has the replacement text (INV-10 is reversed).
assert.ok(doc.getText().includes(REPLACEMENT), "F12 optimistic-apply: replacement in buffer after propose");
const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1);
assert.strictEqual(rendered[0].pending, true);
@@ -77,7 +78,8 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)");
const art = readSidecar();
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time");
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET);
// After optimistic apply, the fingerprint re-anchors to the replacement text.
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, REPLACEMENT);
});
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
@@ -97,15 +99,18 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
});
test("reject leaves the document untouched and removes the proposal (PUC-3)", async () => {
test("reject reverts the optimistically-applied text and removes the proposal (PUC-3, F12-INV-48)", async () => {
const doc = await openDoc();
const api = await getApi();
const before = doc.getText();
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
await settle();
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
// F12: optimistic apply has put "WOULD-BE replacement." in the buffer.
assert.ok(doc.getText().includes("WOULD-BE replacement."), "F12: optimistically applied");
// rejectByIdInPlace reverts the buffer to the original (PUC-3, INV-51).
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id), "rejectByIdInPlace removes + reverts");
await settle();
assert.strictEqual(doc.getText(), before, "document untouched");
assert.strictEqual(doc.getText(), before, "document restored to pre-propose state");
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
assert.strictEqual(readSidecar().proposals.length, 0);
});
@@ -114,25 +119,35 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
let doc = await openDoc();
const api = await getApi();
const anchor = "A stable closing paragraph.";
await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "turn-p3");
const appliedText = "A PROPOSED closing paragraph.";
await proposeViaCommand(doc, anchor, appliedText, "turn-p3");
await settle();
// F12: optimistic apply has put the proposed text in the buffer (anchor → appliedText).
assert.ok(doc.getText().includes(appliedText), "F12: optimistically applied before reload");
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
// doc.getText() now has appliedText; the reload prepends a line.
doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
await settle();
api.proposalController.renderAll(doc);
const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1, "proposal survived reload");
assert.strictEqual(rendered[0].pending, true, "still decidable");
const moved = doc.getText().indexOf(anchor);
assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move");
// After F12 re-anchor, the fingerprint tracks the appliedText, not the original anchor.
const moved = doc.getText().indexOf(appliedText);
assert.ok(moved >= 0, "applied text present in the reloaded document");
assert.strictEqual(rendered[0].range.start, moved, "re-anchored to appliedText after the move");
});
test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => {
let doc = await openDoc();
const api = await getApi();
const id = api.proposalController.getRendered(DOC_REL)[0].id;
const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph.");
doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled);
// After F12 optimistic apply the fingerprint tracks the APPLIED text ("A PROPOSED
// closing paragraph."), not the original. Mangle that text to make the proposal stale.
const preMangled = doc.getText();
const mangled = preMangled.replace("A PROPOSED closing paragraph.", "A reworded closing paragraph.");
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
doc = await externalWriteAndReload(uri, mangled);
await settle();
api.proposalController.renderAll(doc);
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
@@ -143,22 +158,30 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
// discard the husk so later tests see a clean sidecar
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
// Restore the file so subsequent tests can find their fixture targets:
// replace the optimistically-applied text back to the original fixture text.
const restored = preMangled.replace("A PROPOSED closing paragraph.", "A stable closing paragraph.");
doc = await externalWriteAndReload(uri, restored);
await settle();
});
test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => {
const doc = await openDoc();
const api = await getApi();
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5");
// Clean up any proposals left over from prior tests (F12: proposals persist in state).
await api.proposalController.rejectAll(doc);
await settle();
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2);
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
const id2 = await proposeViaCommand(doc, "A stable closing paragraph.", "A REPLACED closing paragraph.", "turn-p5");
await settle();
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2, "two new proposals");
// decide the SECOND first, then the first — order independence
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
await settle();
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
await settle();
assert.ok(doc.getText().includes("An ACCEPTED opening paragraph."));
assert.ok(doc.getText().includes("A REPLACED-AGAIN sentence."));
assert.ok(doc.getText().includes("A REPLACED closing paragraph."));
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
});
});
+5 -5
View File
@@ -41,15 +41,15 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "typed text is author-colored before the pin");
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "typed text is author-colored cw-ins-human before the pin");
// Pin the baseline → zero diff → the panel must be fully clean.
ctl.receiveMessage(key, { type: "pinBaseline" });
await settle();
const pinned = ctl.renderHtmlFor(key);
assert.ok(!pinned.includes("cw-by-human"), "no human authorship coloring after pin");
assert.ok(!pinned.includes("cw-by-claude"), "no Claude authorship coloring after pin");
assert.ok(!pinned.includes("cw-add") && !pinned.includes("cw-del"), "no change marks after pin");
assert.ok(!pinned.includes("cw-ins-human"), "no human insertion marks after pin");
assert.ok(!pinned.includes("cw-ins-claude"), "no Claude insertion marks after pin");
assert.ok(!pinned.includes("cw-ins-") && !pinned.includes("cw-del-"), "no insertion or deletion marks after pin");
assert.match(pinned, /data-src-start/, "blocks still carry data-src offsets (INV-36 mapping kept)");
assert.ok(pinned.includes("freshly typed human paragraph"), "the body text is still rendered, just plain");
@@ -58,6 +58,6 @@ suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n");
assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied");
await settle();
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "authorship coloring returns once the doc diverges from the pin");
assert.match(ctl.renderHtmlFor(key), /cw-ins-human/, "cw-ins-human authorship coloring returns once the doc diverges from the pin");
});
});
+3 -2
View File
@@ -78,8 +78,9 @@ suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)"
);
// And the on-state render must not color 'bravo' as human-authored.
// Added blocks use cw-ins-human (not cw-by-human) since Task 3/44ef0a2.
const html = api.trackChangesPreviewController.renderHtmlFor(key);
const bravoColoredHuman = /<span class="cw-by-human">[^<]*bravo/.test(html);
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-by-human in the preview");
const bravoColoredHuman = /cw-ins-human[^<]*bravo|<[^>]+class="[^"]*cw-ins-human[^"]*"[^>]*>[^<]*bravo/.test(html);
assert.ok(!bravoColoredHuman, "restored 'bravo' is not colored cw-ins-human in the preview");
});
});
+38
View File
@@ -52,6 +52,44 @@ describe("extractReplacement", () => {
});
});
describe("runEditTurn macOS automation prompt (#59)", () => {
// The claude-code provider spawns the bundled `claude` CLI, which on macOS
// performs IDE auto-connect when launched inside VS Code — sending Apple Events
// that raise the "control other applications" prompt. The extension only
// consumes the text result and never needs that connection, so we disable it.
it("spawns the agent with IDE auto-connect + auto-install disabled, preserving the rest of the environment", async () => {
vi.resetModules();
const cfgs: any[] = [];
vi.doMock("@cline/sdk", () => ({
Agent: class {
constructor(cfg: any) {
cfgs.push(cfg);
}
subscribe() {
return () => {};
}
abort() {}
async run() {
return { status: "completed", outputText: "X", runId: "r" };
}
},
}));
process.env.__WGL_TEST_SENTINEL__ = "keep-me";
await runEditTurn("do it", "old");
const cfg = cfgs[0];
// mK("0") === true in the claude binary → hard-disables IDE auto-connect,
// so no osascript / Apple Event fires.
expect(cfg.options?.env?.CLAUDE_CODE_AUTO_CONNECT_IDE).toBe("0");
// Belt-and-suspenders: skip the IDE-extension auto-install (deep-link path).
expect(cfg.options?.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL).toBe("1");
// The full process environment must survive (login under HOME, proxy/CA vars).
expect(cfg.options?.env?.__WGL_TEST_SENTINEL__).toBe("keep-me");
delete process.env.__WGL_TEST_SENTINEL__;
vi.doUnmock("@cline/sdk");
vi.resetModules();
});
});
describe("runEditTurn progress + cancel", () => {
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
vi.resetModules();
+15 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import assert from "node:assert";
import { describe, it, test, expect } from "vitest";
import {
SCHEMA_VERSION,
emptyArtifact,
@@ -179,6 +180,19 @@ describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => {
});
});
test("serializeArtifact round-trips Proposal.original", () => {
const a = emptyArtifact("doc.md");
a.anchors["a_1"] = { fingerprint: { text: "new", before: "", after: "", lineHint: 0 } };
a.proposals.push({
id: "pr_1", anchorId: "a_1", replacement: "new",
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s" } },
createdAt: "2026-06-26T00:00:00.000Z", original: "old", granularity: "block",
});
const json = serializeArtifact(a);
assert.match(json, /"original": "old"/);
assert.match(json, /"granularity": "block"/);
});
describe("attributions[] (F3 SLICE-1)", () => {
it("round-trips an attribution record through serialize → parse", () => {
const a = emptyArtifact("docs/x.md");
+16 -1
View File
@@ -9,7 +9,7 @@ import {
type Proposal,
} from "../src/model";
import { CoauthorStore } from "../src/store";
import { addProposal, proposalBody, removeProposal } from "../src/proposalModel";
import { addProposal, proposalBody, removeProposal, setProposalApplied } from "../src/proposalModel";
const agent = {
kind: "agent" as const,
@@ -104,4 +104,19 @@ describe("proposalModel helpers (spec §6.4)", () => {
expect(body).toContain("- old line two");
expect(body).toContain("+ new only line");
});
it("setProposalApplied stores original and re-anchors the fingerprint to the applied text", () => {
const a = emptyArtifact("docs/x.md");
const { proposalId, anchorId } = addProposal(
a,
{ text: "old", before: "", after: "", lineHint: 0 },
"new",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } },
{ granularity: "block" },
);
setProposalApplied(a, proposalId, { text: "new", before: "", after: "", lineHint: 0 }, "old");
const p = a.proposals.find((x) => x.id === proposalId)!;
expect(p.original).toBe("old");
expect(a.anchors[anchorId].fingerprint.text).toBe("new");
});
});
+214 -28
View File
@@ -1,5 +1,7 @@
import { describe, it, test, expect } from "vitest";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
import MarkdownIt from "markdown-it";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, type AuthorSpan } from "../src/trackChangesModel";
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => {
@@ -170,12 +172,11 @@ describe("colorByAuthor", () => {
});
// #33: author-coloring must be sentinel-safe around markdown emphasis. These
// exercise the REAL markdown-it renderer (via renderReview on an unchanged doc),
// since the failure is in markdown-it's inline parsing / element nesting.
// exercise colorByAuthor directly with the real markdown-it renderer (unchanged
// blocks now render plain in renderReview, so sentinel testing requires a direct call).
// CASE1 — a span boundary strictly inside a delimiter run must not split it.
test("#33 CASE1: a span boundary inside ** does not break emphasis parsing", () => {
const doc = "a**b**c";
const html = renderReview(doc, doc, [{ start: 0, end: 2, author: "human" }], []);
const html = colorByAuthor("a**b**c", 0, [{ start: 0, end: 2, author: "human" }], src => md.render(src));
expect(html).toContain("<strong>b</strong>"); // emphasis still renders
expect(html).not.toContain("**"); // no raw delimiters left
expect(html).not.toContain("<em></em>"); // no stray empty emphasis (the parse-break symptom)
@@ -184,16 +185,14 @@ describe("colorByAuthor", () => {
// CASE3 — a span boundary inside an emphasis run must color the text without
// misnesting span/element (the span is split at the element boundary).
test("#33 CASE3: a span boundary inside **bold** colors the text without misnesting", () => {
const doc = "**bold**";
const html = renderReview(doc, doc, [{ start: 0, end: 4, author: "human" }], []); // covers "**bo"
const html = colorByAuthor("**bold**", 0, [{ start: 0, end: 4, author: "human" }], src => md.render(src)); // covers "**bo"
expect(html).toContain("<strong>");
expect(html).toContain('<span class="cw-by-human">bo</span>ld</strong>'); // span INSIDE strong, closed before "ld"
expect(html).not.toContain('cw-by-human"><strong>'); // NOT the old misnest (span wrapping the <strong> open)
});
// CASE2 — a span covering a whole emphasis run stays correct (regression).
test("#33 CASE2: a span over the whole **bold** colors it correctly", () => {
const doc = "**bold**";
const html = renderReview(doc, doc, [{ start: 0, end: 8, author: "human" }], []);
const html = colorByAuthor("**bold**", 0, [{ start: 0, end: 8, author: "human" }], src => md.render(src));
expect(html).toContain("<strong>");
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
expect(html).toContain("bold");
@@ -209,7 +208,98 @@ describe("colorByAuthor", () => {
});
});
import { renderPlain } from "../src/trackChangesModel";
describe("authorAt", () => {
const spans: AuthorSpan[] = [
{ start: 0, end: 5, author: "human" },
{ start: 5, end: 10, author: "claude" },
];
test("returns the covering span's author (half-open)", () => {
expect(authorAt(0, spans)).toBe("human");
expect(authorAt(4, spans)).toBe("human");
expect(authorAt(5, spans)).toBe("claude");
expect(authorAt(9, spans)).toBe("claude");
});
test("returns null outside any span", () => {
expect(authorAt(10, spans)).toBeNull();
expect(authorAt(-1, spans)).toBeNull();
});
});
describe("wordDiffByAuthor", () => {
test("an inserted run is colored by the author covering its landed offset", () => {
// before "hello " ; after "hello brave " ; "brave " inserted at landed offset 6..12
const spans: AuthorSpan[] = [{ start: 6, end: 12, author: "claude" }];
const html = wordDiffByAuthor("hello ", "hello brave ", 0, spans);
expect(html).toContain('<ins class="cw-ins-claude">brave </ins>');
expect(html).toContain("hello ");
});
test("a paired deletion inherits the paired insertion's author (adjacency)", () => {
// "light" -> "dark", both in one cluster; "dark" is claude-inserted
const spans: AuthorSpan[] = [{ start: 0, end: 4, author: "claude" }];
const html = wordDiffByAuthor("light", "dark", 0, spans);
expect(html).toContain('<del class="cw-del-claude">light</del>');
expect(html).toContain('<ins class="cw-ins-claude">dark</ins>');
});
test("a standalone deletion (no paired insertion) is neutral", () => {
const html = wordDiffByAuthor("keep this word", "keep word", 0, []);
expect(html).toContain('<del class="cw-del-none">this </del>');
expect(html).not.toContain("cw-del-human");
expect(html).not.toContain("cw-del-claude");
});
test("emits SIBLING ins/del elements, never nested — CSS overlap rule in preview.css is a defensive forward guarantee", () => {
// The engine assigns the cluster's insertion author to all deletions in the same
// cluster (adjacency heuristic), so <del> and <ins> always carry the SAME author
// as siblings. Cross-author nesting (<del class="cw-del-X"><ins class="cw-ins-Y">)
// is never produced today. The CSS rule `.cw-del-claude .cw-ins-human { text-decoration: inherit }`
// guards against a future regression where such nesting appears.
const spans: AuthorSpan[] = [{ start: 0, end: 100, author: "human" }];
const html = wordDiffByAuthor("old text", "new text", 0, spans);
expect(html).toContain('<del class="cw-del-human">');
expect(html).toContain('<ins class="cw-ins-human">');
// Structural check: no <del> immediately wraps an <ins> (sibling, not nested).
// /<del[^>]*>[^<]*<ins/ would match a nested case but not a sibling case
// because [^<]* stops before the closing </del> tag.
expect(html).not.toMatch(/<del[^>]*>[^<]*<ins/);
});
});
import { renderPlain, wordEditHunks, decorationPlan } from "../src/trackChangesModel";
test("committed change: wordEditHunks(current, baseline) → start/end index current; replacement is baseline text", () => {
const baseline = "the light mode";
const current = "the dark mode";
const [h] = wordEditHunks(current, baseline);
expect(current.slice(h.start, h.end)).toContain("dark"); // applied (current) side
expect(h.replacement).toContain("light"); // baseline (deleted) side
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
expect(plan.insertions.length).toBeGreaterThan(0);
expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true);
});
test("committed change with NO shared prefix: current offsets + deleted text both correct", () => {
const baseline = "alpha tail";
const current = "beta tail";
const [h] = wordEditHunks(current, baseline);
expect(h.start).toBe(0);
expect(current.slice(h.start, h.end)).toContain("beta"); // applied
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
expect(plan.deletions.some((d) => d.text.includes("alpha"))).toBe(true); // deletion not lost
});
test("standalone committed deletion → plan has no insertions (controller routes del to neutral)", () => {
// current = "keep word" (baseline had "this " between "keep " and "word" — standalone deletion)
const [h] = wordEditHunks("keep word", "keep this word");
const plan = decorationPlan(h.start, h.replacement, "keep word".slice(h.start, h.end));
expect(plan.insertions.length).toBe(0);
expect(plan.deletions.some((d) => d.text.includes("this"))).toBe(true);
});
test("paired committed replace → plan has insertions (deletion inherits author, not neutral)", () => {
// current = "the dark mode", baseline = "the light mode" — replacement, so paired
const [h] = wordEditHunks("the dark mode", "the light mode");
const plan = decorationPlan(h.start, h.replacement, "the dark mode".slice(h.start, h.end));
expect(plan.insertions.length).toBeGreaterThan(0);
});
describe("renderPlain", () => {
test("renderPlain renders current buffer as plain markdown (no marks)", () => {
@@ -223,15 +313,17 @@ describe("renderPlain", () => {
import { renderReview, type ProposalView } from "../src/trackChangesModel";
describe("renderReview", () => {
test("renderReview: human addition since baseline renders green ins / cw-by-human", () => {
const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
expect(html).toMatch(/<ins>[^<]*world[^<]*<\/ins>|cw-by-human/);
test("renderReview: human addition since baseline renders author-colored ins", () => {
// " world" is one diff token starting at offset 5 (the space), so the author span
// must start at 5 or earlier to cover the token's start offset.
const html = renderReview("hello", "hello world", [{ start: 5, end: 11, author: "human" }], []);
expect(html).toContain("cw-ins-human");
});
test("renderReview: deletion since baseline renders struck del/cw-del", () => {
test("renderReview: a standalone deletion since baseline renders neutral struck del", () => {
const html = renderReview("hello world", "hello", [], []);
expect(html).toMatch(/<del>|cw-del/);
expect(html).toMatch(/cw-del-none|cw-removed/);
});
test("renderReview: a pending proposal renders a blue block with data-proposal-id and ✓/✗ actions", () => {
test("renderReview: a pending proposal renders a blue block with data-proposal-id and Accept/Reject actions", () => {
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
const html = renderReview("hello", "hello", [], proposals);
expect(html).toContain('class="cw-proposal"');
@@ -261,13 +353,15 @@ describe("renderReview", () => {
test("renderReview: author-colors the correct block when two paragraphs are identical", () => {
// Two identical paragraphs; baseline has only the first, so the SECOND is an
// added block authored by human. Its span must color THAT block, not the first.
// (Unchanged first block renders plain; added second block uses wordDiffByAuthor.)
const baseline = "Hello world";
const current = "Hello world\n\nHello world";
// second "Hello world" starts at offset 13; "world" at 19..24
const spans = [{ start: 19, end: 24, author: "human" as const }];
// second "Hello world" starts at offset 13 (past the "\n\n"); span covers the block.
// wordDiffByAuthor emits the whole block as one added token at offset 13.
const spans = [{ start: 13, end: 24, author: "human" as const }];
const html = renderReview(baseline, current, spans, []);
// exactly one cw-by-human span (the added second block's "world"), not zero, not on the first.
const count = (html.match(/cw-by-human/g) ?? []).length;
// exactly one cw-ins-human (the added second block), not zero, not on the first.
const count = (html.match(/cw-ins-human/g) ?? []).length;
expect(count).toBe(1);
});
@@ -346,22 +440,24 @@ describe("renderReview", () => {
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
expect(html).toContain("Person");
});
test("renderReview: zero diff WITHOUT a pin (e.g. machine-landing) keeps authorship coloring (INV-33)", () => {
// accepting a Claude edit advances the baseline (zero diff) but is NOT a pin —
// the landed author coloring must remain.
test("renderReview: zero diff (e.g. machine-landing accept) renders unchanged blocks plain (Task 2 behavior)", () => {
// Task 2: unchanged blocks always render plain — color means "changed since baseline".
// After accepting a Claude edit the text is the new baseline; no diff → no coloring.
const doc = "Human wrote this.\n\nClaude wrote that.";
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
const html = renderReview(doc, doc, spans, []); // no pinned flag
expect(html).toContain("cw-by-claude");
expect(html).not.toContain("cw-by-claude");
expect(html).toContain("Claude wrote that."); // text still present
});
test("renderReview: with REAL changes since a pin, author coloring returns", () => {
test("renderReview: with REAL changes since a pin, author coloring returns on added blocks", () => {
// a genuine added block is still author-colored even when pinned (only the
// zero-diff-after-pin state is clean).
// zero-diff-after-pin state is clean). Task 2: coloring is via cw-ins-*.
const baseline = "Hello world";
const current = "Hello world\n\nHello world";
const spans: AuthorSpan[] = [{ start: 19, end: 24, author: "human" }];
// span covers the added block from its start (offset 13) so the whole token is colored.
const spans: AuthorSpan[] = [{ start: 13, end: 24, author: "human" }];
const html = renderReview(baseline, current, spans, [], { pinned: true });
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
expect((html.match(/cw-ins-human/g) ?? []).length).toBe(1);
});
test("renderReview is deterministic with mixed anchored/unanchored proposals", () => {
@@ -375,6 +471,96 @@ describe("renderReview", () => {
const b = renderReview(doc, doc, [], proposals);
expect(a).toBe(b);
});
test("renderReview renders an applied proposal ONCE, not also as a landed diff (INV-50)", () => {
const baseline = "# T\n\nThe brown fox.\n";
const current = "# T\n\nThe red fox.\n"; // proposal already optimistically applied
const proposals: ProposalView[] = [{
id: "pr_1",
anchorStart: current.indexOf("The red fox."),
anchorEnd: current.indexOf("The red fox.") + "The red fox.".length,
replaced: "The brown fox.", // original
replacement: "The red fox.",
}];
const html = renderReview(baseline, current, [], proposals);
// exactly one proposal block
expect((html.match(/cw-proposal/g) ?? []).length).toBe(1);
// the applied paragraph is NOT also emitted as a word-merged changed block
expect(html).not.toContain("<del>brown</del>"); // no double-render of the change as a baseline diff
});
test("renderReview keeps author coloring aligned on an ADDED block AFTER a length-changing pending proposal (INV-49/50)", () => {
// block 1 has a pending proposal (MUCH LONGER than original); block 2 is a NEW
// paragraph with a Claude authorship span (in currentText coords). The toLanded
// mapping must shift the span's coords so the added block is correctly colored.
const baseline = "old.";
const current = "LONGER REPLACEMENT.\n\nAdded para.";
const proposals: ProposalView[] = [{
id: "pr_1",
anchorStart: 0,
anchorEnd: "LONGER REPLACEMENT.".length,
replaced: "old.",
replacement: "LONGER REPLACEMENT.",
original: "old.",
}];
// "Added" in currentText starts at: "LONGER REPLACEMENT.\n\n".length = 21
const aStart = current.indexOf("Added");
const authorSpans = [{ start: aStart, end: aStart + "Added".length, author: "claude" as const }];
const html = renderReview(baseline, current, authorSpans, proposals);
// "Added" in the landedText block should be claude-colored despite the coordinate shift.
expect(html).toContain("cw-ins-claude");
});
test("proposalBlockHtml renders Accept/Reject controls with dropdown carets (#64)", () => {
const html = renderReview("a\n", "b\n", [], [
{ id: "pr_1", anchorStart: 0, anchorEnd: 1, replaced: "a", replacement: "b" },
]);
expect(html).toMatch(/data-action="accept"/);
expect(html).toMatch(/data-action="reject"/);
expect(html).toMatch(/data-action="acceptAll"/);
expect(html).toMatch(/data-action="rejectAll"/);
expect(html).toMatch(/Accept/);
expect(html).toMatch(/Reject/);
});
test("renderReview: a changed prose block colors ins/del by author (claude replace)", () => {
// baseline "light" -> current "dark"; "dark" attributed to claude at 0..4
const html = renderReview("light", "dark", [{ start: 0, end: 4, author: "claude" }], []);
expect(html).toContain('cw-ins-claude');
expect(html).toContain('cw-del-claude'); // adjacency: struck "light" inherits claude
});
test("renderReview: an unchanged block renders plain (no author coloring)", () => {
const doc = "Alpha stays.";
const html = renderReview(doc, doc, [{ start: 0, end: 12, author: "human" }], []);
expect(html).not.toContain("cw-by-");
expect(html).not.toContain("cw-ins-");
});
test("renderReview: an added block is author-colored as an insertion", () => {
// baseline empty-ish -> add a new paragraph authored by human
const html = renderReview("Keep.", "Keep.\n\nFresh line.", [{ start: 6, end: 17, author: "human" }], []);
expect(html).toContain("cw-ins-human");
});
test("renderReview: an added heading block renders as a heading AND is author-colored as an insertion", () => {
// "Intro.\n\n" = 8 chars; "## New Section" = 14 chars → offset 8..22 in currentText.
// Before fix: wordDiffByAuthor wrapped raw in <ins> BEFORE markdown-it parsed it,
// so "## New Section" rendered as literal text (structural bug). After fix:
// colorByAuthor uses the sentinel technique — markdown-it parses the block first,
// so ## becomes a real <h2>, THEN sentinels are replaced with cw-ins-* spans.
const html = renderReview("Intro.", "Intro.\n\n## New Section", [{ start: 8, end: 22, author: "human" }], []);
expect(html).toMatch(/<h[12][^>]*>/); // real heading element, not literal "## "
expect(html).toContain("cw-ins-human"); // author-colored insertion
expect(html).not.toContain("&gt;&gt;"); // sanity: markers not escaped as text
});
test("renderReview: a proposal block colors its del/ins by author (claude default)", () => {
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
const html = renderReview("hello", "hello", [], proposals);
expect(html).toContain('cw-del-claude');
expect(html).toContain('cw-ins-claude');
});
});
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath";
import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath";
describe("isUnderRoot", () => {
const root = "/a/vscode-cowriting-plugin/sandbox";
@@ -58,3 +58,27 @@ describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)",
expect(msg).not.toMatch(/select some text/i);
});
});
describe("routeEdit (single Ask-Claude-to-Edit gesture)", () => {
const focused = { hasUri: false, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false };
it("non-empty selection in the focused editor → selection", () => {
expect(routeEdit(focused)).toBe("selection");
});
it("empty selection in the focused editor → document", () => {
expect(routeEdit({ ...focused, selectionEmpty: true })).toBe("document");
});
it("no active editor → document", () => {
expect(routeEdit({ ...focused, hasActiveEditor: false, selectionEmpty: true })).toBe("document");
});
it("tab right-click on a doc that ISN'T the focused editor → document (no selection to act on)", () => {
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false })).toBe(
"document",
);
});
it("tab right-click on the focused editor with a selection → selection", () => {
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: true, hasActiveEditor: true, selectionEmpty: false })).toBe(
"selection",
);
});
});