Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9050c3e08 | |||
| b3c476e4d9 | |||
| 15e6f02bdf | |||
| c3cecdfaa2 | |||
| 02fbe9c77e | |||
| ae19df78fb | |||
| 7b59c324d5 | |||
| dcdd2ffc1c | |||
| 1d284d2868 | |||
| 05ca497900 | |||
| c579f67a43 | |||
| 5d1000096a | |||
| 2955e8d929 | |||
| 44ef0a21db | |||
| 08557827da | |||
| 0ad040711f | |||
| 94b1a9b0c2 | |||
| 126d71fa06 | |||
| e671c4bf03 | |||
| 5d83f290f5 | |||
| e38fe1b8ca | |||
| 844a2789fd | |||
| 7b98249286 | |||
| d2ef9457c4 | |||
| 7e42d115c0 | |||
| 56cc9eb6ad | |||
| 2069029d73 | |||
| a99430c247 | |||
| 9432300e3c | |||
| a42e5f145d | |||
| 26628c0dbe | |||
| 644885c6ec |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 1–2).**
|
||||
- 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)
|
||||
```
|
||||
+4
-4
@@ -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
|
||||
|
||||
@@ -166,5 +166,20 @@
|
||||
},
|
||||
"0056": {
|
||||
"title": ""
|
||||
},
|
||||
"0057": {
|
||||
"title": ""
|
||||
},
|
||||
"0058": {
|
||||
"title": ""
|
||||
},
|
||||
"0059": {
|
||||
"title": ""
|
||||
},
|
||||
"0060": {
|
||||
"title": ""
|
||||
},
|
||||
"0061": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>`;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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(">>"); // 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";
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user