1 Commits

Author SHA1 Message Date
Ben Stull 5645b926a3 #40 (WIP, UNVERIFIED): restore exact author attribution on undo/redo
Follow-up to #38. #38 made undo/redo re-inserted text neutral (no false human
coloring); #40 restores its EXACT prior attribution so Claude's restored text is
blue again, the human's green.

Mechanism — text-keyed attribution snapshots: per-doc Map<documentText, spans>,
snapshotted after every forward edit + at load; on undo/redo, after the #38
geometry reconcile, if a snapshot's text equals the current buffer the spans are
restored exactly (offsets valid — identical text). Robust to VS Code undo
coalescing (only the resulting-text-matching event restores); far-back/evicted
states fall back to #38 neutral. History bounded (ATTR_HISTORY_MAX, oldest
evicted).

NOT MERGED / NOT VERIFIED END-TO-END: 222 unit + typecheck green, but the #40
host E2E (and the pre-existing #38 undoMarks E2E) depend on
executeCommand("undo"), which is broken in this test environment — the untouched
#38 test fails identically on clean main (the known undoMarks flake, now
deterministic). Clearing .vscode-test/user-data (the usual remedy) is
permission-blocked here. The operator should verify in a working E2E environment
before merging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:56:43 -07:00
48 changed files with 321 additions and 2766 deletions
-5
View File
@@ -89,11 +89,6 @@ 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; }
+3 -4
View File
@@ -94,14 +94,13 @@ acceptAllEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "acceptAll" });
});
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block.
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return;
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId;
const block = btn.closest<HTMLElement>(".cw-proposal");
const id = block?.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 });
}
+16 -92
View File
@@ -18,16 +18,6 @@
"onStartupFinished"
],
"contributes": {
"configuration": {
"title": "Cowriting",
"properties": {
"cowriting.liveProgress.revealOutput": {
"type": "boolean",
"default": true,
"description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams."
}
}
},
"commands": [
{
"command": "cowriting.showClineSdkInfo",
@@ -59,21 +49,6 @@
"title": "Apply Agent Edit (internal seam)",
"category": "Cowriting"
},
{
"command": "cowriting.edit",
"title": "Ask Claude to Edit",
"category": "Cowriting"
},
{
"command": "cowriting.askClaude.submit",
"title": "Ask Claude to Edit",
"category": "Cowriting"
},
{
"command": "cowriting.askClaude.cancel",
"title": "Cancel",
"category": "Cowriting"
},
{
"command": "cowriting.editSelection",
"title": "Ask Claude to Edit Selection",
@@ -113,21 +88,6 @@
"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": {
@@ -148,45 +108,17 @@
"command": "cowriting.rejectProposal",
"when": "false"
},
{
"command": "cowriting.askClaude.submit",
"when": "false"
},
{
"command": "cowriting.askClaude.cancel",
"when": "false"
},
{
"command": "cowriting.pinDiffBaseline",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.edit",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.editSelection",
"when": "false"
},
{
"command": "cowriting.editDocument",
"when": "false"
"when": "editorLangId == markdown"
},
{
"command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.rejectAllProposals",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.proposalAcceptMenu",
"when": "false"
},
{
"command": "cowriting.proposalRejectMenu",
"when": "false"
}
],
"editor/title": [
@@ -198,8 +130,13 @@
],
"editor/title/context": [
{
"command": "cowriting.edit",
"when": "resourceLangId == markdown",
"command": "cowriting.editSelection",
"when": "editorHasSelection && resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
"command": "cowriting.editDocument",
"when": "!editorHasSelection && resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
@@ -217,8 +154,13 @@
],
"editor/context": [
{
"command": "cowriting.edit",
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"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)",
"group": "1_cowriting@1"
},
{
@@ -232,11 +174,6 @@
"command": "cowriting.reply",
"group": "inline",
"when": "commentController == cowriting.threads"
},
{
"command": "cowriting.askClaude.submit",
"group": "inline",
"when": "commentController == cowriting.askClaude"
}
],
"comments/commentThread/title": [
@@ -249,11 +186,6 @@
"command": "cowriting.reopenThread",
"group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
},
{
"command": "cowriting.askClaude.cancel",
"group": "inline",
"when": "commentController == cowriting.askClaude"
}
]
},
@@ -261,14 +193,7 @@
{
"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"
}
]
},
@@ -277,8 +202,7 @@
"watch": "node esbuild.mjs --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean:e2e": "node -e \"require('fs').rmSync('out/test',{recursive:true,force:true})\"",
"pretest:e2e": "npm run build && npm run clean:e2e && tsc -p tsconfig.e2e.json",
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
"test:e2e": "node ./out/test/e2e/runTest.js",
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
"vscode:prepublish": "node esbuild.mjs"
+1 -9
View File
@@ -10,15 +10,7 @@ console.log(`instruction: ${instruction}`);
console.log(`text: ${text}`);
try {
const t0 = Date.now();
// #60: exercise the live-progress path against the real SDK — log each reduced
// snapshot so the smoke shows streaming/activity/tokens, not just the result.
const result = await runEditTurn(instruction, text, {
onProgress: (s) => {
const bits = [s.phase === "tool" ? `tool:${s.tool}` : s.phase, `${s.chars}c`];
if (s.tokens) bits.push(`${s.tokens}tok`);
console.log(` progress: ${bits.join(" ")}`);
},
});
const result = await runEditTurn(instruction, text);
console.log(`replacement: ${JSON.stringify(result.replacement)}`);
console.log(`model: ${result.model}`);
console.log(`sessionId: ${result.sessionId}`);
@@ -1,90 +0,0 @@
# Session 0048.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T08-45 (PST)
> Type: planning-and-executing
> End: 2026-06-13T08-58 (PST)
> Status: **FINALIZED.**
> Posture: autonomous (yolo)
## Launch prompt
```
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
```
## Plan
**#40 (task, P3)** — restore exact author attribution on undo/redo (follow-up to
#38). Anchor: leaf `task` → §4.3 R2 (no design gate). Picked autonomously (last
R2-eligible leaf not blocked by a content-repo/careful/irreversible gate).
#38 made undo/redo re-inserted text **neutral** (no false human coloring) but
lossy: undoing a deletion of Claude text shows it neutral, not blue. #40 restores
the **exact prior** attribution.
**Mechanism (engineering choice): text-keyed attribution snapshots.** Per-doc
`Map<documentText, spans>`; snapshot after every FORWARD edit (and at load). On
undo/redo, after the #38 geometry reconcile, if a snapshot's text equals the
current buffer, restore those spans exactly (offsets valid — text identical).
Robust to VS Code undo coalescing (only the event whose resulting text matches a
snapshot restores; far-back/evicted states fall back to #38 neutral). Bounded
history.
Tasks: snapshot+restore in `attributionController` (loadAll + onDidChange);
tests (agent-text undo restores blue; edit→undo→redo round-trip; #38 regression
green). No deploy pipeline (extension).
## Results
**#40 implemented but NOT shipped — verification-blocked.** On branch
`s40-undo-provenance` (pushed, unmerged); issue #40 **kept open**.
- `attributionController.ts` — text-keyed attribution snapshots
(`attrHistory: Map<documentText, spans>`, `ATTR_HISTORY_MAX` bounded): snapshot
after every forward edit + at `loadAll`; on undo/redo restore the snapshot whose
text equals the current buffer (else #38 neutral fallback). Robust to undo
coalescing.
- `test/e2e/suite/s40Provenance.test.ts` — agent-text-undo-restores-blue +
edit→undo→redo round-trip.
- **222 unit + typecheck green.**
**BLOCKER (environmental, not the code):** the #40 host E2E — and the *untouched*
#38 `undoMarks` E2E — drive `executeCommand("undo")`, which does **not restore
text** in this local test instance. Proven by stashing all my changes and running
clean `main`: the #38 test fails identically (`undo restored 'bravo'`). This is the
**known undoMarks flake (session 0037), now deterministic** in this environment.
The usual remedy — clearing `.vscode-test/user-data` — is **permission-blocked**
this session (`rm -rf` denied). Focusing the doc before `undo` (`showTextDocument`)
did not help. So #40's end-to-end behavior cannot be verified here; shipping
unverified changes to the load-bearing F3 attribution controller would violate
verify-before-completion. Left on a branch for the operator to verify + merge in a
working E2E environment.
**Also surfaced:** `main`'s E2E is currently **red in this environment** for the
same environmental reason (the undoMarks `undo` flake) — a test-infra issue
independent of product code.
## Session arc
1. Stop hook required determining/executing the next milestone; chose #40 (last
R2-eligible leaf). Claimed 0048, clean `main`.
2. Read #40 + #38 + the attribution change handler; chose text-keyed snapshots.
3. TDD: wrote #40 E2E + implemented snapshot/restore; 222 unit green.
4. E2E: 3 undo-driven tests failed at the `undo restored X` step. Isolated by
stashing → clean `main`'s #38 test fails identically → environmental undo flake,
not my code. Could not clear `.vscode-test` (permission-blocked).
5. Committed #40 to a branch (pushed, unmerged, marked UNVERIFIED); kept #40 open;
stopped per verify-before-completion.
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Empty if none._
- **Did not merge #40** (driver call). Alternative: merge on unit-green + reasoning
alone. Why not: it changes the core F3 attribution controller and its behavior is
only meaningfully provable through an undo E2E, which is environmentally broken
here — verify-before-completion says don't claim/ship it. Preserved on a branch
for operator verification instead. (Reversible: just merge once verified.)
@@ -1,17 +1,20 @@
# Session 0059.0 — Transcript
# Session 0048.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T06-01 (PST)
> Start: 2026-06-13T08-45 (PST)
> Type: planning-and-executing
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0059 for vscode-cowriting-plugin. The driver replaces this
> This file reserves session ID 0048 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0059.0-TRANSCRIPT-2026-06-26T06-01--<end>.md form at session end.
> SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
```
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
```
## Deferred decisions
@@ -1,61 +0,0 @@
# Session 0049.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T09-19 (PST)
> Type: planning-and-executing
> End: 2026-06-13T09-24 (PST)
> Status: **FINALIZED.**
> Posture: autonomous (yolo)
## Launch prompt
```
/goal plan-and-execute: pretest:e2e cleans stale compiled tests in out/test (stale *.test.js from other branches were running via the glob) — build hygiene follow-up to #54
```
## Plan
Build-hygiene follow-up to #54 (noted on that issue). `tsc -p tsconfig.e2e.json`
emits to `out/` but never removes outputs for test sources absent on the current
branch, so stale compiled `out/test/e2e/suite/*.test.js` from other branches get
run by the suite glob (`**/*.test.js`) — this caused real cross-branch test
confusion in session 0048 (a removed probe + the s40 branch's tests ran on an
unrelated branch). Fix: `pretest:e2e` cleans `out/test` before recompiling.
- `package.json` — add `clean:e2e` (node `fs.rmSync('out/test', {recursive,force})`
— avoids shell `rm` issues) and run it between `build` and `tsc` in `pretest:e2e`.
Clean ONLY `out/test` (NOT `out/`, which holds the just-built esbuild bundle).
- Verify: introduce a stale `out/test/.../zz.test.js`, run `pretest:e2e`, confirm
it's gone + the E2E suite is green.
Trivial, ungated, verifiable; test-infra only. No deploy pipeline (extension).
## Results
**Shipped to `main`** — PR #56 (merged). `package.json`: new `clean:e2e`
(`fs.rmSync('out/test',{recursive,force})`) run between `build` and `tsc` in
`pretest:e2e`. Cleans only `out/test` (never the esbuild bundle in `out/`).
Verified: a planted stale `out/test/.../zz.test.js` is removed by `pretest:e2e`;
E2E green (73 passing + 1 pending [#38 undo-skip], both passes exit 0). Test-infra
only; no product code.
Properly tracked under this session (0049) — closing the protocol gap where
#54/#55 merged after 0048 had finalized.
## Session arc
1. Stop hook pushed me to execute the one remaining ungated/verifiable item (the
`out/`-clean follow-up I'd noted on #54). Claimed 0049 to track it cleanly.
2. Confirmed `tsc` outDir → `out/test`; esbuild bundle → `out/extension.cjs`
(separate), so cleaning `out/test` is safe.
3. Implemented `clean:e2e`; verified by planting a stale compiled test (removed)
+ full E2E green. Shipped PR #56.
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Empty if none._
- _No low-confidence calls this session._ (Cleaning stale build output is
unambiguously correct; scoped to `out/test` to protect the esbuild bundle.)
@@ -1,76 +0,0 @@
# Session 0050.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T17-43 (PST)
> End: 2026-06-13T17-52 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — "When changing documents, we should decide what happens to the
review pane of the current document, and if the document switched to should get a
review experience or not"
## Pre-state
- Branch `main`, clean, pushed (origin/main). No in-flight sessions for the app.
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`); content
repo `vscode-cowriting-plugin-content` cloned, with ~18 prior uncommitted
capture drafts in `issues/`.
- Prior frontier exhausted (session 0047 note): remaining backlog items gated;
operator directing. This is a capture session, not a build.
## Arc
1. **Claimed** tracked-lite session ID **0050** (`claim-session-id.sh --type
capture`). Resolved app + content repo (`resolve-app.py`).
2. **Grounded** the ask by reading the code (Explore subagent over
`src/trackChangesPreview.ts`, `diffViewController.ts`, `proposalController.ts`):
confirmed the review pane is one-panel-per-doc keyed by URI
(`trackChangesPreview.ts:45`) with **no** `onDidChangeActiveTextEditor`
listener — so on document switch the pane **stays pinned** to its original doc
(incidental, not designed) and the switched-to doc gets no review until
`showTrackChangesPreview` is re-invoked. Per-doc review state (F6 baseline / F4
proposals / F3 attribution) auto-creates on demand.
3. **Sized** the single ask: genuine forks (follow / pin / close; auto-review /
on-demand) + needs design before build → `type/feature` (R3), `priority/P2`.
Captures the decision-to-be-made; option space goes in Solution notes,
non-binding.
4. **Drafted** `issues/review-pane-on-document-switch.md` in the content repo
(working tree only, INV-8), full §5 template. Scanned clean for secrets
(INV-3).
5. **Operator approved** filing as-is (feature, P2) via AskUserQuestion.
6. **Filed** → ensured labels (all pre-existing), then `capture-issues.sh` →
**#57** (https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/57).
## Cut state
- Session repo `vscode-cowriting-plugin`: clean `main`, nothing committed this
session (capture touches only the content repo working tree). No PRs.
- Content repo: new draft `issues/review-pane-on-document-switch.md` left
uncommitted for the Author to publish/discard (INV-8), alongside the prior
drafts already there. Not pushed by this skill.
- Issue **#57** filed and open on the tracker.
## Deferred decisions
_None — single clear ask, operator approved the framing and type before filing._
## Operator plate
- One feature filed: **#57** (review-pane behavior on document switch, P2).
- ~19 capture drafts now sit uncommitted in `vscode-cowriting-plugin-content` —
yours to commit/publish (or discard) when convenient.
- #57 needs a design before build (R3). No active plan frontier; operator to
direct next.
## Next session prompt
`/goal` — operator to direct. Candidate: brainstorm a Solution Design for **#57**
(review-pane behavior on document switch) — settle the two coupled rules (outgoing
pane fate; whether the switched-to doc auto-gets a review experience) before
building. Other open: OQ-2 (F11 graduation), #32 (scroll-sync, needs design),
#35 (repo rename), #40 (undo provenance, P3, branch `s40-undo-provenance`
unmerged), #54 (undo still broken in sandbox).
@@ -1,71 +0,0 @@
# Session 0051.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T17-49 (PST)
> End: 2026-06-13T17-58 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — two asks:
1. "Undo doesn't undo Claude's edits"
2. "When I make a change within a block that Claude has changed that I haven't
approved, it doesn't show my changes"
## Pre-state
- Branch `main`, clean, pushed. Immediately followed capture session 0050 (#57).
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`); content
repo cloned.
## Arc
1. **Claimed** tracked-lite session ID **0051**.
2. **Grounded** both asks in code (Explore subagent):
- Undo: Claude's edits are pending proposals; `proposalController.ts:303-310`
shifts anchors on edit but ignores `e.reason === Undo` — proposal not
reconciled on undo (accepted edits hit the doc and are undoable, but the
pending proposal lingers).
- Pending-block edits: a proposal freezes its "before" (`replaced: fp.text`,
`proposalController.ts:81-95`); the block renders from current text but the
proposal's frozen view (`trackChangesModel.ts:706-722`/`:769-805`) obscures
live human edits in that block.
3. **Checked duplication** against the undo cluster — #38 (wrong marks, closed),
#40 (attribution color, open), #54 (undo E2E infra, open). Both new asks are
distinct (undo failing to *reverse* the edit; live edits obscured by a pending
proposal). Verified `type/bug` exists on the tracker.
4. **Drafted** both as `type/bug` (P1) in the content repo working tree
(`issues/undo-does-not-undo-claude-edits.md`,
`issues/edits-in-pending-block-not-shown.md`), each framed as user-facing pain
with repro steps + code diagnosis demoted to parenthetical. Scanned clean for
secrets (INV-3).
5. **Operator triage:** before filing, operator said "ignore both of these
issues — you can delete the drafts." Both drafts **deleted**; nothing filed.
## Cut state
- **Nothing filed** (INV-4 — no-actionable outcome after operator triage).
- Both draft files removed from the content repo working tree.
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
## Deferred decisions
_None — operator explicitly directed dropping both asks._
## Operator plate
- No issues filed this session. The two undo/pending-block asks were
considered and intentionally dropped (recorded in memory
`session-0051-capture-no-actionable.md` so they aren't re-raised as new).
- Note: these remain *real* observed behaviors per the code reading — dropped on
operator triage, not because they were invalid. If they resurface, the prior
grounding is in the memory note.
## Next session prompt
`/goal` — operator to direct. From session 0050: candidate is brainstorming a
Solution Design for **#57** (review-pane behavior on document switch). Other
open: OQ-2 (F11 graduation), #32 (scroll-sync), #35 (repo rename), #40 (undo
provenance, P3, branch `s40-undo-provenance` unmerged), #54 (undo E2E infra).
@@ -1,73 +0,0 @@
# Session 0052.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-15T08-33 (PST)
> End: 2026-06-15T08-41 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — two asks (second arrived mid-session):
1. "the review window and markdown source file should have scroll location sync'd"
2. "the claude change recommendation block (which user will accept or decline)
should show the diff with the original"
## Pre-state
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57) and 0051
(no-actionable).
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
## Arc
**Ask 1 — scroll-sync (duplicate, nothing filed):**
1. Claimed tracked-lite session ID **0052**.
2. Recognized scroll-sync as a known F10 follow-up; fetched **#32** — exact match
(*"Scroll-sync the review preview with the source editor"*, open,
`type/feature`, `priority/P3`, filed session 0031).
3. Surfaced to operator; operator chose to **leave #32 at P3**. No new issue
(INV-4 duplicate).
**Ask 2 — proposal block diff vs original (filed #58):**
4. Grounded in code (Explore): the pending proposal block renders original +
proposed as **two separate full blocks** (`<del>` whole-before + `<ins>`
whole-after, `trackChangesModel.ts:715-716`) with **no word-level diff** — the
changed-block rendering already uses `wordMergedMarkdown`/`diffWords`
(`trackChangesModel.ts:431-438`) but `proposalBlockHtml()` doesn't.
`ProposalView` already carries `replaced`+`replacement`
(`proposalController.ts:81-95`).
5. Sized `type/story`, P2 (reuses existing word-diff helper; distinct from #31
placement / #47 granularity). Drafted
`issues/proposal-block-shows-diff-with-original.md` (working tree only, secrets
scanned).
6. Operator approved as-is → ensured labels → filed **#58**
(https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/58).
## Cut state
- **#58** filed (story, P2). Scroll-sync left as existing **#32** (P3) — nothing
new filed for it.
- Draft `issues/proposal-block-shows-diff-with-original.md` left uncommitted in
the content repo for the Author to publish (INV-8).
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
## Deferred decisions
_None — operator directly resolved the duplicate and approved the #58 framing._
## Operator plate
- One issue filed: **#58** (proposal block word-diff, P2).
- Scroll-sync remains **#32** (P3, open, unchanged).
- One uncommitted capture draft added to the content repo (joins the prior
drafts) — yours to publish or discard.
## Next session prompt
`/goal` — operator to direct. Standing candidate (from 0050): brainstorm a
Solution Design for **#57** (review-pane behavior on document switch). Open work:
#58 (proposal word-diff, P2, story), #32 (scroll-sync, P3), OQ-2 (F11
graduation), #35 (repo rename), #40 (undo provenance, P3, branch
`s40-undo-provenance` unmerged), #54 (undo E2E infra).
@@ -1,66 +0,0 @@
# Session 0053.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-15T10-23 (PST)
> End: 2026-06-15T10-29 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — "The plugin is asking for access to other applications but works
fine when I decline. It just shouldn't"
## Pre-state
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57), 0051
(no-actionable), 0052 (#58 + scroll-sync dup).
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
## Arc
1. Claimed tracked-lite session ID **0053**.
2. **Investigated** the macOS Automation/Apple-Events trigger (Explore): the
extension's own source has **no** AppleScript/`osascript`/automation calls; the
probable origin is **`@cline/sdk`/`@cline/core`** during agent activation
(likely local Claude Code hub-discovery), which falls back gracefully when
denied — matching "works fine when declined." Framed as a lead, not a settled
root cause.
3. **Sized** `type/bug` (declared defect: unwanted OS permission request).
Drafted `issues/spurious-automation-permission-prompt.md` (working tree only,
secrets scanned) with repro steps + expected/actual + the upstream caveat.
4. **Operator set priority P1** (trust/launch-optics over the harmless functional
impact); updated the WSJF line accordingly.
5. **Filed #59.** capture-issues.sh `--type` rejects `bug` (known #124 taxonomy
gap), so filed with `--type task` then swapped the label to `type/bug` via the
Gitea API (deleted type/task, added type/bug) — verified `['priority/P1',
'type/bug']` (INV-2 holds).
→ https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/59
## Cut state
- **#59** filed (bug, P1), label corrected to exactly `type/bug`.
- Draft `issues/spurious-automation-permission-prompt.md` left uncommitted in the
content repo for the Author to publish (INV-8).
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
## Deferred decisions
_None — operator set the priority directly and approved the framing._
## Operator plate
- One issue filed: **#59** (spurious macOS automation prompt, bug, **P1**).
- The cause is likely upstream in `@cline/sdk` — the fix may be a config/flag or
dependency update, not necessarily this repo's code.
- Reminder: bug capture needs the file-as-task-then-relabel workaround until
plugin #124 (capture can't type bugs) is fixed.
## Next session prompt
`/goal` — operator to direct. Standing candidate (from 0050): brainstorm a
Solution Design for **#57** (review-pane behavior on document switch). Open work:
**#59** (automation prompt, bug P1 — may be upstream), #58 (proposal word-diff,
story P2), #32 (scroll-sync, P3), OQ-2 (F11 graduation), #35 (repo rename), #40
(undo provenance, P3), #54 (undo E2E infra).
@@ -1,66 +0,0 @@
# Session 0054.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-15T10-42 (PST)
> End: 2026-06-15T10-48 (PST)
> Type: capture
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-capture` — "See the Claude output/progress in the 'asking Claude…' status"
## Pre-state
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57), 0051
(no-actionable), 0052 (#58 + scroll-sync dup), 0053 (#59).
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
## Arc
1. Claimed tracked-lite session ID **0054**.
2. **Grounded** (Explore): the "Cowriting: asking Claude…" status is an opaque
`withProgress` notification (`extension.ts:232-233`,
`trackChangesPreview.ts:232-233`) awaiting `agent.run()` as one black-box
promise (`liveTurn.ts:52`). Key enabler: `@cline/sdk` already emits streaming
events (`assistant-text-delta`, `tool-started/updated/finished`,
`usage-updated`) via `agent.subscribe()` / an `onEvent` hook, but the extension
constructs the Agent with **no hooks** (`liveTurn.ts:47-51`) and discards it.
3. **Sized** `type/feature` — implementable by subscribing to existing events, but
a real design fork on the surface (notification text vs. OutputChannel vs.
status bar vs. webview relay) and content (text/tool/usage/reasoning) → design
first (R3). Drafted `issues/show-live-claude-progress.md` (working tree only,
secrets scanned).
4. **Operator set priority** — chose "adjust priority" → **P1** (opaque wait hurts
every turn). Updated WSJF line.
5. **Filed #60** (`type/feature`, P1).
→ https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/60
## Cut state
- **#60** filed (feature, P1).
- Draft `issues/show-live-claude-progress.md` left uncommitted in the content repo
for the Author to publish (INV-8).
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
## Deferred decisions
_None — operator set the priority directly and approved the framing._
## Operator plate
- One issue filed: **#60** (live Claude progress, feature, **P1**).
- Implementation is largely "subscribe to SDK events already available + pick the
progress surface" — needs a small design first (R3).
- One uncommitted capture draft added to the content repo — yours to publish or
discard.
## Next session prompt
`/goal` — operator to direct. Capture run 00500054 filed #57, #58, #59, #60
(scroll-sync = existing #32). Two P1s now open (**#59** automation prompt,
**#60** live progress) plus standing #57 (review-pane on doc switch, design
candidate). Other open: #58 (proposal word-diff, P2), #32 (scroll-sync P3), OQ-2
(F11 graduation), #35 (repo rename), #40 (undo provenance P3), #54 (undo E2E
infra).
@@ -1,105 +0,0 @@
# Session 0055.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-22T23-19 (PST)
> End: 2026-06-26T04-23 (PST)
> Type: brainstorming
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-brainstorming highest priority ticket`
## Plan
Brainstorming session: explore the highest-priority open ticket and write its
Solution-Design spec. Highest-priority that fits a brainstorming session (spec
output; a feature needs a design per §4.3 R3) was **#60 — Show Claude's live
output/progress during the "asking Claude…" status** (P1 feature). #59 (P1 bug)
is coding-session work, not brainstorming.
Output: `specs/coauthoring-live-progress.md` (Solution Design), submitted graduated
at finalize to the content repo's `specs/` collection.
## Pre-state
- Clean, pushed `main` (`24e329e`). No in-flight sessions (next ID 0055).
- Two P1 tickets tied for highest priority: **#59** (P1 bug — spurious macOS
"control other applications" prompt) and **#60** (P1 feature — live turn
progress). Brainstorming produces a spec; a feature needs a design (§4.3 R3),
so #60 was the fit. #59 (bug) → a coding session.
- Highest INV in use across the content-repo specs: **INV-42**.
## Session arc (turn-by-turn)
1. **Gate + classify.** Launch prompt classified as **brainstorming**; ran
`wgl-brainstorming`.
2. **Claim (peek → gate → claim).** `claim-session-id.sh --dry-run` → no in-flight
sessions, next ID **0055**. Claimed `--type brainstorming` (placeholder at
`16cca30`). Verified clean pushed `main` baseline.
3. **Orient.** Surveyed the open tracker. Picked **#60** (P1 feature, design
needed) over #59 (P1 bug, coding-session work). Read #60 in full.
4. **Ground in code (read-only).** Read `liveTurn.ts` (`runEditTurn`
`agent.run()` as one opaque promise; module is deliberately vscode-free), both
call sites (`extension.ts:232` editSelection, `trackChangesPreview.ts:232`
preview `askClaude` — both wrap the turn in `withProgress("asking Claude…")`),
the injectable `editTurn` seam (trackChangesPreview.ts:56, for host-E2E
stubbing), and the `@cline/sdk` event API: `@cline/agents` `Agent` exposes
`subscribe(listener) → unsubscribe`, `run()`, `abort()`; `AgentRuntimeEvent`
(in `@cline/shared/dist/agent.d.ts`) carries `assistant-text-delta`
(+`accumulatedText`), `tool-started|updated|finished`, `usage-updated`,
lifecycle events. The enabler the issue cites is real.
5. **Brainstorm forks (superpowers:brainstorming).** Skipped the visual companion
(choices are conceptual/native-UI, not visual mockups). Presented three forks
via AskUserQuestion; operator picked all three recommendations:
- **Surface** → notification activity-line + token count **and** a shared
`"Cowriting"` OutputChannel streaming full assistant text. (Rejected: webview
relay can't cover the editSelection path; status-bar-only; OutputChannel-
primary.)
- **Content** → activity + token count (reasoning text not surfaced).
- **Cancellation** → `cancellable` notification → `agent.abort()`
"cancelled", proposes nothing (reflect + cancel button).
6. **Design presented + approved.** Three-unit architecture (pure
`turnProgress.ts` reducer → `runEditTurn` extended with `onProgress` +
`AbortSignal` → both call sites relay via a shared `liveProgressUi`); layering
rule keeps `liveTurn.ts`/`turnProgress.ts` vscode-free; INV-43..47. Two
sub-decisions (OutputChannel auto-reveal gated by a new setting; append-not-
clear) confirmed. Operator: "design looks right."
7. **Spec written + self-review.** `specs/coauthoring-live-progress.md` authored
in house Solution-Design format. Self-review: no placeholders, INV-43..47 (no
collision with ≤42; INV-8/21/39 are correct citations), internally consistent,
single-feature scope.
8. **Operator: "Implement it."** Per the pipeline, that's the brainstorm→coding
handoff (one SPEC = one plan = one execution, §4.3). Finalized this
brainstorming session (below); a fresh `wgl-planning-and-executing` session
plans + builds #60.
## Cut state (at finalize)
- **Spec graduated.** `submit-spec.sh --status graduated`
`vscode-cowriting-plugin-content` `specs/coauthoring-live-progress.md` at
`3bd1ae5`.
- **Code repo:** clean `main`; no code changes this session (brainstorming).
A local stray `specs/coauthoring-live-progress.md` remains untracked in the code
repo (an `rm` cleanup was permission-denied; harmless — canonical copy is in the
content repo).
- **Memory:** added `session-0055-live-progress-spec-graduated.md` + index line.
## Next-session prompt
```
/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md
```
## Deferred decisions
- **OutputChannel auto-reveal** (fires every Claude turn): decided to auto-`show(true)`
(preserveFocus) on the *first* text delta, gated by a new setting
`cowriting.liveProgress.revealOutput` (default `true`). Operator confirmed the
design including this.
- **OutputChannel history:** decided to *append* per-turn (with a header) rather
than clear, so it doubles as a debug log. Operator confirmed.
- **Stray local spec file:** `rm -rf specs/` was permission-denied; left the
untracked local copy in the code repo (cosmetic; content repo holds the
canonical copy).
@@ -1,106 +0,0 @@
# 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.
@@ -1,22 +0,0 @@
# Session 0057.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T04-35 (PST)
> Type: executing-plans
> Posture: yolo
> Claude-Session: 3d66a467-8026-472d-9693-52a37939d493
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0057 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0057.0-TRANSCRIPT-2026-06-26T04-35--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -1,85 +0,0 @@
# 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
```
-33
View File
@@ -142,38 +142,5 @@
},
"0048": {
"title": ""
},
"0049": {
"title": ""
},
"0050": {
"title": ""
},
"0051": {
"title": ""
},
"0052": {
"title": ""
},
"0053": {
"title": ""
},
"0054": {
"title": ""
},
"0055": {
"title": ""
},
"0056": {
"title": ""
},
"0057": {
"title": ""
},
"0058": {
"title": ""
},
"0059": {
"title": ""
}
}
+43 -16
View File
@@ -44,8 +44,19 @@ interface DocAttribution {
* orphans on reload).
*/
hadAttributions: boolean;
/**
* #40: text-keyed attribution snapshots for exact provenance restoration on
* undo/redo. Maps a document-text state → the live spans at that state (offsets
* valid for that exact text). Captured after every forward edit + at load;
* consulted on undo/redo to restore the matching state's spans. Bounded (oldest
* evicted) — a far-back/evicted state falls back to the #38 neutral reconcile.
*/
attrHistory: Map<string, LiveSpan[]>;
}
/** #40: cap on the per-doc attribution-snapshot history (oldest evicted). */
const ATTR_HISTORY_MAX = 200;
export class AttributionController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocAttribution>();
@@ -90,7 +101,7 @@ export class AttributionController implements vscode.Disposable {
private state(docPath: string): DocAttribution {
let s = this.docs.get(docPath);
if (!s) {
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false };
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false, attrHistory: new Map() };
this.docs.set(docPath, s);
}
return s;
@@ -125,6 +136,9 @@ export class AttributionController implements vscode.Disposable {
s.spans = coalesce(s.spans);
if (artifact.attributions.length > 0) s.hadAttributions = true;
}
// #40: seed the snapshot history with the loaded state so undoing back to it
// restores its exact attribution.
this.snapshotAttribution(s, document.getText());
this.render(document);
}
@@ -140,6 +154,17 @@ export class AttributionController implements vscode.Disposable {
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
/** #40: snapshot the current spans keyed by the document's current text. */
private snapshotAttribution(s: DocAttribution, text: string): void {
s.attrHistory.delete(text); // re-insert at the end (recency order)
s.attrHistory.set(text, s.spans.map((sp) => ({ ...sp })));
while (s.attrHistory.size > ATTR_HISTORY_MAX) {
const oldest = s.attrHistory.keys().next().value as string | undefined;
if (oldest === undefined) break;
s.attrHistory.delete(oldest);
}
}
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
const docPath = this.keyOf(e.document);
@@ -202,6 +227,18 @@ export class AttributionController implements vscode.Disposable {
);
}
}
// #40: exact provenance across history navigation. After a FORWARD edit,
// snapshot the new state's spans. On UNDO/REDO, the geometry reconcile above
// left re-inserted text neutral (#38); if a snapshot's text equals the current
// buffer, restore that state's spans exactly (offsets are valid — identical
// text) so Claude's restored text is blue again, the human's green. No match
// (far-back/evicted state) keeps the #38 neutral fallback.
if (isUndoRedo) {
const restored = s.attrHistory.get(e.document.getText());
if (restored) s.spans = restored.map((sp) => ({ ...sp }));
} else {
this.snapshotAttribution(s, e.document.getText());
}
if (s.spans.length > 0) s.hadAttributions = true;
this.render(e.document);
}
@@ -249,7 +286,7 @@ export class AttributionController implements vscode.Disposable {
range: vscode.Range,
newText: string,
provenance: Provenance,
opts?: { expectedVersion?: number; turnId?: string; landBaseline?: boolean },
opts?: { expectedVersion?: number; turnId?: string },
): Promise<boolean> {
if (!this.isTracked(document)) return false;
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
@@ -290,25 +327,15 @@ export class AttributionController implements vscode.Disposable {
"(host minimized differently?) — the edit may be mis-attributed (INV-9).",
);
}
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.
if (ok) {
// F6 (INV-18): a real machine landing — signal the baseline to advance so
// this text never shows as a change in the diff view. Fire regardless of
// attribution-match bookkeeping above; the landing happened either way.
this.applyEmitter.fire({ document });
}
return ok;
}
/**
* 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 {
-151
View File
@@ -1,151 +0,0 @@
/**
* 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). A document with no pending proposals shows nothing (INV-32's
* spirit when nothing is pending).
*/
import * as vscode from "vscode";
import type { ProposalController } from "./proposalController";
import { decorationPlan } from "./trackChangesModel";
import { isAuthorable } from "./workspacePath";
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
private readonly disposables: vscode.Disposable[] = [];
private readonly insertionDeco = vscode.window.createTextEditorDecorationType({
backgroundColor: new vscode.ThemeColor("diffEditor.insertedTextBackground"),
});
private readonly deletionDeco = vscode.window.createTextEditorDecorationType({
// a non-editable struck-red hint injected AFTER the insertion (INV-52)
after: { color: new vscode.ThemeColor("gitDecoration.deletedResourceForeground") },
textDecoration: "none",
});
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>>();
constructor(private readonly proposals: ProposalController) {
this.disposables.push(
this.insertionDeco, this.deletionDeco, this.lensEmitter,
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(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),
);
}
/** 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;
if (doc.languageId !== "markdown") {
editor.setDecorations(this.insertionDeco, []);
editor.setDecorations(this.deletionDeco, []);
return;
}
const key = this.proposals.keyFor(doc);
const insertions: vscode.Range[] = [];
const deletions: vscode.DecorationOptions[] = [];
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 ins of plan.insertions) {
insertions.push(new vscode.Range(doc.positionAt(ins.start), doc.positionAt(ins.end)));
}
for (const del of plan.deletions) {
deletions.push({
range: new vscode.Range(doc.positionAt(del.at), doc.positionAt(del.at)),
renderOptions: { after: { contentText: ` ${del.text} `, textDecoration: "line-through" } },
});
}
}
editor.setDecorations(this.insertionDeco, insertions);
editor.setDecorations(this.deletionDeco, deletions);
}
/** 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 d of this.disposables) d.dispose();
}
}
+14 -87
View File
@@ -11,10 +11,7 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi";
import { InlineAskController } from "./inlineAsk";
import { EditorProposalController } from "./editorProposalController";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
import { isAuthorable, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -26,25 +23,12 @@ export interface CowritingApi {
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi;
inlineAsk: InlineAskController;
editorProposalController: EditorProposalController;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
// --- POC command (Feature #2), unchanged ---
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
context.subscriptions.push(output);
// #60: shared live-progress UI (notification activity line + "Cowriting: Claude"
// OutputChannel) for both Ask-Claude entry points.
const liveProgressUi = new LiveProgressUi();
context.subscriptions.push(liveProgressUi);
// The inline "Ask Claude to Edit" prompt — rendered at the selection/cursor via
// the Comments API rather than the top-center QuickInput (see inlineAsk.ts).
// Shared by both Ask-Claude entry points (editSelection + the preview's askClaude).
const inlineAsk = new InlineAskController(context.subscriptions);
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
try {
@@ -120,16 +104,9 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
context.extensionUri,
attributionController,
proposalController,
liveProgressUi,
inlineAsk,
);
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);
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.
@@ -144,18 +121,6 @@ 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.
@@ -244,12 +209,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
return;
}
if (!editor) return; // unreachable once reason is null, but narrows the type
const document = editor.document;
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
// The instruction prompt opens INLINE at the selection (inlineAsk), not the
// top-center QuickInput — anchored to the text Claude will edit.
const instruction = await inlineAsk.prompt(document.uri, selection);
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 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.
@@ -260,28 +230,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const turnId = `turn-${Date.now().toString(36)}`;
try {
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
async () => {
const { runEditTurn } = await import("./liveTurn");
const ui = liveProgressUi.begin(instruction, progress, token);
let turn;
try {
turn = await runEditTurn(instruction, selectedText, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
if (token.isCancellationRequested) {
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
return;
}
throw err;
}
const turn = await runEditTurn(instruction, selectedText);
if (turn.replacement === "") {
void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
@@ -321,28 +273,6 @@ 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)) {
@@ -362,9 +292,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
diffViewController,
trackChangesPreviewController,
sidecarRouter,
liveProgressUi,
inlineAsk,
editorProposalController,
};
}
-71
View File
@@ -1,71 +0,0 @@
import * as vscode from "vscode";
/** Trim the reply text; an empty/whitespace-only instruction is "no instruction". */
export function normalizeInstruction(text: string | undefined): string | undefined {
const t = text?.trim();
return t ? t : undefined;
}
/**
* The "Ask Claude to Edit" prompt, rendered INLINE at the selection (or cursor)
* instead of the top-center QuickInput. VS Code's native pattern for "ask AI to
* edit this" is Inline Chat — an input anchored in the editor at the target. The
* stable-API equivalent for a third-party extension is the Comments API: a
* transient comment thread whose reply box renders right at the range. (The
* coauthoring THREADS feature already uses the Comments API via its own
* controller — this is a SEPARATE, dedicated `cowriting.askClaude` controller so
* the two never collide.)
*
* One prompt is live at a time; opening a new one cancels the previous. The
* thread carries no comments — it is purely the inline input — and is disposed
* as soon as the user submits or cancels.
*/
export class InlineAskController {
private readonly controller: vscode.CommentController;
private pending?: { resolve: (v: string | undefined) => void; thread: vscode.CommentThread };
constructor(disposables: vscode.Disposable[]) {
this.controller = vscode.comments.createCommentController("cowriting.askClaude", "Ask Claude to Edit");
// The reply box's prompt + placeholder (Comments API options).
this.controller.options = {
prompt: "Ask Claude to Edit",
placeHolder: "e.g. tighten this paragraph",
};
disposables.push(
this.controller,
vscode.commands.registerCommand("cowriting.askClaude.submit", (r: vscode.CommentReply) =>
this.finish(normalizeInstruction(r?.text)),
),
vscode.commands.registerCommand("cowriting.askClaude.cancel", () => this.finish(undefined)),
// The controller itself disposes the live thread on extension teardown.
new vscode.Disposable(() => this.finish(undefined)),
);
}
/**
* Open the inline input anchored at `range` in `uri`'s editor and resolve with
* the typed instruction (or `undefined` if cancelled / left empty). Replaces
* any prompt already open.
*/
prompt(uri: vscode.Uri, range: vscode.Range): Promise<string | undefined> {
// A fresh prompt supersedes any prior one (resolve it as cancelled).
this.finish(undefined);
return new Promise<string | undefined>((resolve) => {
const thread = this.controller.createCommentThread(uri, range, []);
thread.label = "Ask Claude to Edit";
thread.canReply = true;
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
thread.contextValue = "askClaude";
this.pending = { resolve, thread };
});
}
/** Resolve the live prompt (if any) and tear its thread down. */
private finish(value: string | undefined): void {
const p = this.pending;
this.pending = undefined;
if (!p) return;
p.thread.dispose();
p.resolve(value);
}
}
-65
View File
@@ -1,65 +0,0 @@
/**
* liveProgressUi.ts — host-side relay of TurnProgress snapshots to VS Code UI
* (#60, spec coauthoring-live-progress.md §3.4). The ONLY surfaces are the
* existing withProgress notification (an activity line) and a dedicated
* OutputChannel streaming the full assistant text — no new network/webview
* surface (INV-45). vscode-only; all pure logic lives in turnProgress.ts.
*/
import * as vscode from "vscode";
import { formatProgressLine, type TurnProgressSnapshot } from "./turnProgress";
const CHANNEL_NAME = "Cowriting: Claude";
export interface TurnUi {
/** Pass to runEditTurn's opts.onProgress. */
onProgress: (snapshot: TurnProgressSnapshot) => void;
/** Pass to runEditTurn's opts.signal — fired when the user cancels the notification. */
signal: AbortSignal;
}
export class LiveProgressUi {
readonly channel: vscode.OutputChannel;
constructor() {
this.channel = vscode.window.createOutputChannel(CHANNEL_NAME);
}
/**
* Begin one turn's UI. Writes the per-turn header to the channel and returns
* the onProgress relay + an AbortSignal linked to the notification's cancel
* token. The channel APPENDS (it doubles as a debug log of recent turns, spec
* §3.5); it auto-reveals (without stealing focus) on the first streamed text,
* gated by `cowriting.liveProgress.revealOutput`.
*/
begin(
instruction: string,
progress: vscode.Progress<{ message?: string }>,
token: vscode.CancellationToken,
): TurnUi {
const controller = new AbortController();
token.onCancellationRequested(() => controller.abort());
this.channel.appendLine(`── asking: ${instruction} ──`);
let revealed = false;
const reveal = (): void => {
if (revealed) return;
revealed = true;
const cfg = vscode.workspace.getConfiguration("cowriting");
if (cfg.get<boolean>("liveProgress.revealOutput", true)) this.channel.show(true);
};
const onProgress = (s: TurnProgressSnapshot): void => {
progress.report({ message: formatProgressLine(s) });
if (s.textDelta) {
this.channel.append(s.textDelta);
reveal();
}
};
return { onProgress, signal: controller.signal };
}
dispose(): void {
this.channel.dispose();
}
}
+11 -61
View File
@@ -9,9 +9,6 @@
* never bundled (esbuild keeps it external).
*/
import type { TurnProgressSnapshot } from "./turnProgress";
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
export interface EditTurnResult {
replacement: string;
model: string;
@@ -19,19 +16,6 @@ export interface EditTurnResult {
sessionId: string;
}
/**
* Options for runEditTurn. Both new fields are purely additive observability /
* control over the existing turn (INV-44): `onProgress` streams reduced progress
* snapshots out; `signal` cancels the turn in. Neither touches the result path,
* and neither pulls `vscode` into this module (INV-43 — `AbortSignal` is a web
* standard, the progress snapshot is a domain type).
*/
export interface RunEditTurnOptions {
modelId?: string;
onProgress?: (snapshot: TurnProgressSnapshot) => void;
signal?: AbortSignal;
}
const SYSTEM_PROMPT = [
"You are a precise text editor embedded in VS Code.",
"You will be given a piece of text and an instruction.",
@@ -56,7 +40,7 @@ export function extractReplacement(outputText: string, selectedText: string): st
export async function runEditTurn(
instruction: string,
selectedText: string,
opts?: RunEditTurnOptions,
opts?: { modelId?: string },
): Promise<EditTurnResult> {
const sdk = await import("@cline/sdk");
const modelId = opts?.modelId ?? "sonnet";
@@ -65,50 +49,16 @@ export async function runEditTurn(
modelId,
systemPrompt: SYSTEM_PROMPT,
});
// Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
// in via the AbortSignal (INV-47). agent.subscribe returns its unsubscribe fn.
let state = createTurnProgressState();
const unsubscribe = opts?.onProgress
? agent.subscribe((event) => {
const next = reduceTurnProgress(state, event);
state = next.state;
// Observability must never affect the result (INV-44): a throwing relay
// is swallowed, not allowed to propagate into the SDK and fail the turn.
if (next.snapshot) {
try {
opts.onProgress!(next.snapshot);
} catch {
/* progress is best-effort */
}
}
})
: undefined;
const onAbort = () => agent.abort();
opts?.signal?.addEventListener("abort", onAbort);
try {
// A signal already aborted before the turn starts can't be honored by
// agent.abort() (the SDK's AbortController isn't created until run()), so
// short-circuit to the same aborted outcome the call site reflects (INV-47).
if (opts?.signal?.aborted) {
throw new Error("claude-code turn aborted: cancelled before start");
}
const result = await agent.run(
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
const result = await agent.run(
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
);
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
// (@cline/shared agent.d.ts) — "completed" is the success status.
if (result.status !== "completed") {
throw new Error(
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
"(is Claude Code installed and signed in?)",
);
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
// (@cline/shared agent.d.ts) — "completed" is the success status. An aborted
// turn (user cancel) falls into this throw; the call site reflects "cancelled".
if (result.status !== "completed") {
throw new Error(
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
"(is Claude Code installed and signed in?)",
);
}
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
} finally {
unsubscribe?.();
opts?.signal?.removeEventListener("abort", onAbort);
}
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
}
-10
View File
@@ -97,14 +97,6 @@ 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 {
@@ -260,8 +252,6 @@ 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,
),
+4 -191
View File
@@ -13,8 +13,8 @@
import * as vscode from "vscode";
import { SidecarRouter, docIdentity } from "./sidecarRouter";
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
import { resolve, shift, buildFingerprint, type OffsetRange } from "./anchorer";
import { addProposal, removeProposal, setProposalApplied } from "./proposalModel";
import { resolve, shift, type OffsetRange } from "./anchorer";
import { addProposal, removeProposal } from "./proposalModel";
import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
@@ -39,8 +39,6 @@ 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 {
@@ -91,9 +89,8 @@ export class ProposalController implements vscode.Disposable {
id: p.id,
anchorStart: resolved === "orphaned" ? null : resolved.start,
anchorEnd: resolved === "orphaned" ? null : resolved.end,
replaced: p.original ?? fp?.text ?? "",
replaced: fp?.text ?? "",
replacement: p.replacement,
original: p.original,
};
});
}
@@ -107,7 +104,6 @@ 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);
}
@@ -141,33 +137,8 @@ export class ProposalController implements vscode.Disposable {
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
/** Accept by id — F12: finalize the already-applied text in place (INV-51). */
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
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;
}
@@ -210,12 +181,6 @@ 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);
@@ -292,158 +257,6 @@ export class ProposalController implements vscode.Disposable {
return true;
}
/** True once this proposal's text is in the buffer (optimistic apply ran). */
isApplied(docPath: string, proposalId: string): boolean {
return this.docs.get(docPath)?.applied.has(proposalId) ?? false;
}
/**
* F12/#64 (INV-48): optimistically apply a pending proposal INTO the buffer so
* the editor shows the would-be-accepted result (editable). Reuses the F4
* word-precise seam (block → per-word hunks, INV-40; single → whole range) but
* with `landBaseline:false` (the change stays pending). Then re-anchors the
* proposal to the applied text and stores the original (`setProposalApplied`), so
* `resolve()` finds it in the mutated buffer and revert/decorate key off it.
* Idempotent: a no-op if already applied.
*/
async optimisticApply(document: vscode.TextDocument, proposalId: string): Promise<boolean> {
if (!this.isTracked(document) || this.guard.isReadOnly(this.keyOf(document))) return false;
const docPath = this.keyOf(document);
const state = this.ensureState(document);
if (state.applied.has(proposalId)) return true;
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const proposal = state.artifact.proposals.find((p) => p.id === proposalId);
const fp = proposal ? state.artifact.anchors[proposal.anchorId]?.fingerprint : undefined;
if (!proposal || !fp) return false;
// Reload-safety (INV-51/54): a proposal that already carries `original` was
// optimistically applied in a PRIOR session — the buffer holds the applied text
// and `fp` points at it, but this (fresh) controller's in-memory `applied` set is
// empty. Re-applying would recapture `original` from the already-applied buffer
// (= the replacement) and CLOBBER the true revert target, breaking Reject. Mark it
// applied in memory and stop — `original` is captured exactly once, on first apply.
if (proposal.original !== undefined) {
state.applied.add(proposalId);
this.renderAll(document);
return true;
}
const resolved = resolve(document.getText(), fp);
if (resolved === "orphaned") return false;
const original = document.getText(
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
);
const ok =
proposal.granularity === "block"
? await this.applyBlockOptimistic(document, resolved, proposal)
: await this.attribution.applyAgentEdit(
document,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
proposal.replacement,
proposal.author,
{ expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false },
);
if (!ok) return false;
state.applied.add(proposalId);
// Re-anchor to the applied text now in the buffer (its start is unchanged; its
// end shifts by the net length delta of the replacement).
const appliedStart = resolved.start;
const appliedEnd = appliedStart + proposal.replacement.length;
const appliedFp = buildFingerprint(document.getText(), { start: appliedStart, end: appliedEnd });
this.store.update(docPath, (a) => setProposalApplied(a, proposalId, appliedFp, original));
this.renderAll(document);
return true;
}
/** Block optimistic apply: the INV-40 per-word hunks, but landBaseline:false. */
private async applyBlockOptimistic(
document: vscode.TextDocument,
resolved: OffsetRange,
proposal: Proposal,
): Promise<boolean> {
const blockText = document.getText(
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
);
const subHunks = wordEditHunks(blockText, proposal.replacement);
if (subHunks.length === 0) return true;
for (const h of [...subHunks].sort((a, b) => b.start - a.start)) {
const range = new vscode.Range(
document.positionAt(resolved.start + h.start),
document.positionAt(resolved.start + h.end),
);
const ok = await this.attribution.applyAgentEdit(document, range, h.replacement, proposal.author, {
expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false,
});
if (!ok) return false;
}
return true;
}
/**
* F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is
* already in the buffer, so this only advances the F6 baseline (machine-landing,
* via `attribution.signalLanded`) and clears the proposal. No re-application.
*/
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
this.attribution.signalLanded(document);
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
this.renderAll(document);
return true;
}
/**
* F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live
* applied span with the stored `original`, then clear it. Reverts the whole block
* regardless of any in-place edits the human made to the inserted text.
*/
async revertInPlace(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved !== "orphaned" && hit.proposal.original !== undefined) {
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
}
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
this.renderAll(document);
return true;
}
/**
* F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in
* DESCENDING anchor order (so an earlier revert never shifts a later one's
* offsets), symmetric with #46's accept-all. Returns the reverted count.
*/
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number }> {
if (!this.isTracked(document)) return { reverted: 0 };
const docPath = this.keyOf(document);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const text = document.getText();
const ordered = state.artifact.proposals
.map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const r = fp ? resolve(text, fp) : "orphaned";
return { id: p.id, start: r === "orphaned" ? -1 : r.start };
})
.sort((a, b) => b.start - a.start);
let reverted = 0;
for (const it of ordered) if (await this.revertInPlace(docPath, it.id)) reverted++;
return { reverted };
}
private reject(state: DocState, proposal: Proposal): void {
if (this.guard.isReadOnly(state.docPath)) return;
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
-19
View File
@@ -37,25 +37,6 @@ 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**";
+7 -102
View File
@@ -263,42 +263,6 @@ 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);
/**
@@ -737,8 +701,6 @@ 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;
}
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
@@ -754,14 +716,8 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
const actions =
`<span class="cw-actions">` +
`<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>` +
`<button class="cw-accept" data-action="accept">✓</button>` +
`<button class="cw-reject" data-action="reject">✗</button>` +
`</span>`;
return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
}
@@ -791,26 +747,6 @@ 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,
@@ -819,18 +755,8 @@ export function renderReview(
opts: RenderOptions = {},
): string {
const render = opts.render ?? defaultRender;
// 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);
const ranges = splitBlocksWithRanges(currentText);
const ops = diffBlocks(baselineText, currentText);
// #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
@@ -840,28 +766,7 @@ export function renderReview(
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically.
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
// 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
// Associate each resolved proposal with the current-side 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.
@@ -873,7 +778,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(toLanded(p.anchorStart));
const j = p.anchorStart === null ? -1 : blockOf(p.anchorStart);
if (j < 0) {
trailing.push(p);
continue;
@@ -890,7 +795,7 @@ export function renderReview(
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, landedSpans, render) : render(raw);
blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
+22 -79
View File
@@ -13,19 +13,13 @@ 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, landedTextOf, type BlockOp } from "./trackChangesModel";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
import type { InlineAskController } from "./inlineAsk";
import type { EditTurnResult } from "./liveTurn";
/**
* F11: a host edit turn (selection/document text + instruction → rewrite).
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
* the arg is optional so existing test stubs that ignore it stay valid.
*/
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
type EditTurn = (instruction: string, text: string) => Promise<EditTurnResult>;
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
@@ -44,8 +38,7 @@ type ToolbarMsg =
| { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number }
| { type: "acceptAll" }
| { type: "rejectAll" };
| { type: "acceptAll" };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
@@ -60,9 +53,9 @@ export class TrackChangesPreviewController implements vscode.Disposable {
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/
private editTurn: EditTurn = async (instruction, text, opts) => {
private editTurn: EditTurn = async (instruction, text) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts);
return runEditTurn(instruction, text);
};
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
@@ -75,8 +68,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi,
private readonly inlineAsk: InlineAskController,
) {
this.disposables.push(
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
@@ -191,7 +182,8 @@ export class TrackChangesPreviewController implements vscode.Disposable {
.acceptById(this.proposals.keyFor(document), m.proposalId)
.then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) {
void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document));
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
this.refresh(document);
} else if (m?.type === "pinBaseline") {
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
this.diffView.pin(document);
@@ -202,8 +194,6 @@ 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);
}
}
@@ -224,66 +214,24 @@ 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"}.`,
);
}
}
/**
* Where the inline Ask-Claude input anchors: a range edit pins to its selection;
* a whole-document edit pins to the editor's cursor line if this document is the
* active editor, else to the document start.
*/
private editTargetAnchor(document: vscode.TextDocument, target: EditTarget): vscode.Range {
if (target.kind === "range") {
return new vscode.Range(document.positionAt(target.start), document.positionAt(target.end));
}
const active = vscode.window.activeTextEditor;
if (active && active.document.uri.toString() === document.uri.toString()) {
const line = active.selection.active.line;
return new vscode.Range(line, 0, line, 0);
}
return new vscode.Range(0, 0, 0, 0);
}
/**
* 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> {
// The instruction prompt opens INLINE (inlineAsk) at the target: the selected
// range for a range edit, or the editor's cursor line / the document start for
// a whole-document edit — never the top-center QuickInput.
const anchor = this.editTargetAnchor(document, target);
const instruction = await this.inlineAsk.prompt(document.uri, anchor);
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",
});
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const ui = this.liveProgressUi.begin(instruction, progress, token);
try {
return await this.runEditAndPropose(document, target, instruction, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
if (token.isCancellationRequested) return [] as string[];
throw err;
}
},
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
() => this.runEditAndPropose(document, target, instruction),
);
if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
@@ -310,7 +258,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
document: vscode.TextDocument,
target: EditTarget,
instruction: string,
opts?: RunEditTurnOptions,
): Promise<string[]> {
const full = document.getText();
// One turnId per gesture — the document case's N hunk-proposals all share it,
@@ -320,13 +267,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
if (target.kind === "range") {
const selected = full.slice(target.start, target.end);
const turn = await this.editTurn(instruction, selected, opts);
const turn = await this.editTurn(instruction, selected);
if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full, opts);
const turn = await this.editTurn(instruction, full);
const ids: string[] = [];
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) —
@@ -382,13 +329,9 @@ 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: landedOps.filter((o) => o.kind === "added").length,
removed: landedOps.filter((o) => o.kind === "removed").length,
added: ops.filter((o) => o.kind === "added").length,
removed: ops.filter((o) => o.kind === "removed").length,
proposals: proposals.length,
};
void panel.webview.postMessage({
-122
View File
@@ -1,122 +0,0 @@
/**
* turnProgress.ts — pure reduction of @cline/sdk Agent runtime events into a
* small UI-facing progress snapshot (#60, spec coauthoring-live-progress.md §3.2).
*
* INV-43: vscode-free. INV-46: a pure function — no vscode, no SDK runtime
* dependency (`AgentRuntimeEvent` is imported TYPE-only, so it is erased at
* compile and never pulls the ESM SDK into the bundle). All event→state logic
* lives here so it is unit-tested in isolation; the UI call sites only format and
* relay snapshots.
*/
import type { AgentRuntimeEvent } from "@cline/shared";
export type TurnPhase = "thinking" | "writing" | "tool";
export interface TurnProgressSnapshot {
phase: TurnPhase;
/** present iff phase === "tool" — the running tool's name. */
tool?: string;
/** accumulated assistant-text length so far. */
chars: number;
/** running total tokens (input+output); undefined until the first usage event. */
tokens?: number;
/** the new assistant-text chunk since the last snapshot (for the OutputChannel). */
textDelta?: string;
}
export interface TurnProgressState {
phase: TurnPhase;
chars: number;
tokens?: number;
/** stack of tool names currently running (depth-tracked for overlap). */
activeTools: string[];
/** true once any assistant text has streamed (tool-finish then reverts to writing). */
sawText: boolean;
}
export function createTurnProgressState(): TurnProgressState {
return { phase: "thinking", chars: 0, tokens: undefined, activeTools: [], sawText: false };
}
function restingPhase(state: TurnProgressState): TurnPhase {
if (state.activeTools.length) return "tool";
return state.sawText ? "writing" : "thinking";
}
function toSnapshot(state: TurnProgressState, textDelta?: string): TurnProgressSnapshot {
return {
phase: state.phase,
tool: state.phase === "tool" ? state.activeTools[state.activeTools.length - 1] : undefined,
chars: state.chars,
tokens: state.tokens,
textDelta,
};
}
/**
* Fold one SDK event into the state, returning the next state and the snapshot to
* emit (snapshot undefined for events that don't change the surface).
*/
export function reduceTurnProgress(
state: TurnProgressState,
event: AgentRuntimeEvent,
): { state: TurnProgressState; snapshot?: TurnProgressSnapshot } {
switch (event.type) {
case "run-started":
case "turn-started": {
const next: TurnProgressState = { ...state, phase: restingPhase(state) };
return { state: next, snapshot: toSnapshot(next) };
}
case "assistant-text-delta": {
const next: TurnProgressState = {
...state,
phase: state.activeTools.length ? "tool" : "writing",
chars: event.accumulatedText.length,
sawText: true,
};
return { state: next, snapshot: toSnapshot(next, event.text) };
}
case "assistant-reasoning-delta": {
// Reasoning TEXT is not surfaced (operator fork); collapse to motion only.
const next: TurnProgressState = { ...state, phase: state.activeTools.length ? "tool" : "thinking" };
return { state: next, snapshot: toSnapshot(next) };
}
case "tool-started": {
const activeTools = [...state.activeTools, event.toolCall.toolName];
const next: TurnProgressState = { ...state, phase: "tool", activeTools };
return { state: next, snapshot: toSnapshot(next) };
}
case "tool-updated": {
const next: TurnProgressState = { ...state, phase: "tool" };
return { state: next, snapshot: toSnapshot(next) };
}
case "tool-finished": {
const name = event.toolCall.toolName;
const idx = state.activeTools.lastIndexOf(name);
const activeTools = idx >= 0 ? state.activeTools.filter((_, i) => i !== idx) : state.activeTools.slice(0, -1);
const next: TurnProgressState = { ...state, activeTools, phase: "thinking" };
next.phase = restingPhase(next);
return { state: next, snapshot: toSnapshot(next) };
}
case "usage-updated": {
const tokens = event.usage.inputTokens + event.usage.outputTokens || undefined;
const next: TurnProgressState = { ...state, tokens };
return { state: next, snapshot: toSnapshot(next) };
}
default:
return { state };
}
}
/** Render the notification activity line from a snapshot (pure; spec §2.1). */
export function formatProgressLine(s: TurnProgressSnapshot): string {
let head: string;
if (s.phase === "tool") head = `running ${s.tool ?? "tool"}`;
else if (s.phase === "writing") head = `writing… (${s.chars} chars)`;
else head = "thinking…";
return s.tokens ? `${head} · ${formatTokens(s.tokens)} tokens` : head;
}
export function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
-26
View File
@@ -60,29 +60,3 @@ export function selectionRejection(ctx: SelectionContext): string | null {
}
return null;
}
export interface EditRouteContext {
/** Was the command invoked on a specific resource (a tab right-click)? */
hasUri: boolean;
/** Does that resource match the focused editor's document? */
uriMatchesActiveEditor: boolean;
/** Is there a focused text editor at all? */
hasActiveEditor: boolean;
/** Is the focused editor's selection empty (no highlight)? */
selectionEmpty: boolean;
}
/**
* Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the
* selection or whole-document flow. One conceptual command, two destinations:
*
* - A tab right-click on a document that ISN'T the focused editor has no
* selection to act on → edit the whole document.
* - Otherwise a non-empty selection in the focused editor → edit the selection;
* an empty selection (or no editor) → edit the whole document.
*/
export function routeEdit(ctx: EditRouteContext): "selection" | "document" {
if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document";
if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection";
return "document";
}
-27
View File
@@ -1,27 +0,0 @@
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);
});
});
+2 -12
View File
@@ -13,20 +13,11 @@ 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", "--user-data-dir", userDataDir],
launchArgs: [workspace, "--disable-extensions"],
extensionTestsEnv: { E2E_WORKSPACE: workspace },
});
@@ -35,11 +26,10 @@ async function main(): Promise<void> {
await runTests({
extensionDevelopmentPath,
extensionTestsPath: path.resolve(__dirname, "./suite-no-workspace/index"),
launchArgs: ["--disable-extensions", "--user-data-dir", userDataDir],
launchArgs: ["--disable-extensions"],
});
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
fs.rmSync(userDataDir, { recursive: true, force: true });
}
}
@@ -28,10 +28,7 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
"cowriting.reply",
"cowriting.resolveThread",
"cowriting.reopenThread",
"cowriting.edit",
"cowriting.editSelection",
"cowriting.askClaude.submit",
"cowriting.askClaude.cancel",
"cowriting.applyAgentEdit",
"cowriting.proposeAgentEdit",
]) {
+5 -7
View File
@@ -109,9 +109,8 @@ 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)");
// 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");
// INV-10: proposing never touches the document.
assert.ok(doc.getText().includes(T1), "document unchanged by propose");
});
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
@@ -132,18 +131,17 @@ 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 reverted (PUC-5)", async () => {
test("reject → the proposal vanishes and the document is untouched (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");
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit.
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject");
await settle();
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state");
assert.strictEqual(doc.getText(), before, "document untouched by reject");
const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
});
+11 -20
View File
@@ -99,9 +99,8 @@ 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",
);
// 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");
// INV-10: proposing never mutates the document.
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
void key;
});
@@ -130,9 +129,7 @@ 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");
// 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");
assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)");
void key;
});
@@ -177,22 +174,16 @@ 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 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 () => {
// 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 () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
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");
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");
});
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
-251
View File
@@ -1,251 +0,0 @@
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");
});
});
+40 -36
View File
@@ -31,37 +31,43 @@ 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. 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.
// 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.
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
// 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", () => {
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
const m = menu("editor/context");
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");
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");
});
// 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", () => {
// 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", () => {
const m = menu("editor/title/context");
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",
);
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");
});
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
@@ -77,8 +83,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await settle();
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
const origPrompt = api.inlineAsk.prompt;
(api.inlineAsk as any).prompt = async () => "rewrite it";
const origInput = vscode.window.showInputBox;
(vscode.window as any).showInputBox = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
model: "sonnet",
@@ -88,7 +94,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle();
} finally {
(api.inlineAsk as any).prompt = origPrompt;
(vscode.window as any).showInputBox = origInput;
}
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
@@ -98,10 +104,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
0,
"active doc A was NOT edited — editDocument honored the tab URI",
);
// 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");
// INV-10: proposing never mutates the document.
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
});
// No URI arg (palette / keybinding) → fall back to the active editor.
@@ -112,8 +116,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.window.showTextDocument(a.doc);
await settle();
const origPrompt = api.inlineAsk.prompt;
(api.inlineAsk as any).prompt = async () => "rewrite it";
const origInput = vscode.window.showInputBox;
(vscode.window as any).showInputBox = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
model: "sonnet",
@@ -123,7 +127,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument");
await settle();
} finally {
(api.inlineAsk as any).prompt = origPrompt;
(vscode.window as any).showInputBox = origInput;
}
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
});
-86
View File
@@ -1,86 +0,0 @@
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 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, "exports preview controller");
return api;
}
async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument> {
const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body, "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc);
await settle();
return doc;
}
// #60 host E2E (no LLM): live progress is purely additive observability. The
// harness can't read notification subtitles, so it asserts the CONTRACT —
// progress events don't change the proposals (INV-44), and an aborted turn
// proposes nothing (INV-47). The live notification/OutputChannel UI itself is
// covered by the turnProgress unit tests + the manual smoke.
suite("#60 live turn progress (additive + cancel)", () => {
test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns
// the same rewrite — proposals must be unaffected by progress events.
ctl.setEditTurnForTest(async (_i, _text, opts) => {
opts?.onProgress?.({ phase: "writing", chars: 5 });
opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." });
return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" };
});
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
await settle();
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");
// 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 () => {
const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// The stub throws ONLY when the aborted signal reached it — so if opts.signal
// failed to thread through runEditAndPropose, the stub would instead return a
// rewrite and create a proposal, failing this test. That proves propagation.
ctl.setEditTurnForTest(async (_i, _text, opts) => {
if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" };
});
const ac = new AbortController();
ac.abort();
let ids: string[] = [];
try {
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
} catch {
ids = [];
}
await settle();
assert.strictEqual(ids.length, 0, "aborted turn must create no proposals");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no pending proposals after abort");
});
});
+15 -38
View File
@@ -63,14 +63,13 @@ 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 a pending proposal; F12 optimistically applies it (INV-48, reverses INV-10)", async () => {
test("propose records + renders a pending proposal and does NOT touch the document (INV-10)", async () => {
const doc = await openDoc();
const api = await getApi();
const before = doc.getText();
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
await settle();
// 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");
assert.strictEqual(doc.getText(), before, "document text unchanged (INV-10)");
const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1);
assert.strictEqual(rendered[0].pending, true);
@@ -78,8 +77,7 @@ 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");
// After optimistic apply, the fingerprint re-anchors to the replacement text.
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, REPLACEMENT);
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET);
});
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
@@ -99,18 +97,15 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
});
test("reject reverts the optimistically-applied text and removes the proposal (PUC-3, F12-INV-48)", async () => {
test("reject leaves the document untouched and removes the proposal (PUC-3)", 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();
// 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");
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
await settle();
assert.strictEqual(doc.getText(), before, "document restored to pre-propose state");
assert.strictEqual(doc.getText(), before, "document untouched");
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
assert.strictEqual(readSidecar().proposals.length, 0);
});
@@ -119,35 +114,25 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
let doc = await openDoc();
const api = await getApi();
const anchor = "A stable closing paragraph.";
const appliedText = "A PROPOSED closing paragraph.";
await proposeViaCommand(doc, anchor, appliedText, "turn-p3");
await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "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");
// 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");
const moved = doc.getText().indexOf(anchor);
assert.strictEqual(rendered[0].range.start, moved, "re-anchored 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;
// 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);
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);
await settle();
api.proposalController.renderAll(doc);
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
@@ -158,30 +143,22 @@ 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();
// Clean up any proposals left over from prior tests (F12: proposals persist in state).
await api.proposalController.rejectAll(doc);
await settle();
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");
const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5");
await settle();
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2, "two new proposals");
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2);
// 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 closing paragraph."));
assert.ok(doc.getText().includes("A REPLACED-AGAIN sentence."));
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
});
});
+113
View File
@@ -0,0 +1,113 @@
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400));
const AGENT = { kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-s40" } };
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.attributionController, "exports attribution");
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() };
}
// #40 (follow-up to #38): on undo/redo, restore each re-inserted char's EXACT
// prior author attribution rather than leaving it neutral (#38). Driven mid-edit
// (buffer dirty) so the attribution branch runs, not the disk-sync one.
suite("F3 #40 — undo/redo restores exact author attribution (host E2E, no LLM)", () => {
test("undo of a deletion of AGENT text restores its agent span (not neutral, not human)", async () => {
const { doc } = await freshDoc("docs/s40agent.md", "Human start. xxxx end.\n");
const api = await getApi();
const ctl = api.attributionController;
// Make "xxxx" agent-authored via the seam → an agent span over "ROBOT".
const t = "xxxx";
const start = doc.getText().indexOf(t);
const ok = await ctl.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + t.length)),
"ROBOT",
AGENT,
{ turnId: "turn-s40" },
);
assert.strictEqual(ok, true, "seam edit applies");
await settle();
const key = api.proposalController.keyFor(doc);
assert.ok(
ctl.getSpans(key).some((s) => s.authorKind === "agent" && doc.getText().slice(s.range.start, s.range.end).includes("ROBOT")),
"ROBOT is agent-attributed after the seam edit",
);
// Human deletes "ROBOT" (buffer stays dirty: it already diverged from disk).
const rs = doc.getText().indexOf("ROBOT");
const del = new vscode.WorkspaceEdit();
del.delete(doc.uri, new vscode.Range(doc.positionAt(rs), doc.positionAt(rs + "ROBOT".length)));
assert.ok(await vscode.workspace.applyEdit(del), "delete applied");
await settle();
assert.ok(!doc.getText().includes("ROBOT"), "ROBOT deleted");
// Undo the deletion → ROBOT re-inserted. #40: its AGENT span is restored.
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("undo");
await settle();
assert.ok(doc.getText().includes("ROBOT"), "undo restored ROBOT");
assert.ok(doc.isDirty, "buffer still dirty → attribution branch ran");
const r2 = doc.getText().indexOf("ROBOT");
const spans = ctl.getSpans(key);
const over = spans.filter((s) => s.range.start < r2 + 5 && s.range.end > r2);
assert.ok(over.length > 0, "restored ROBOT carries a span");
assert.ok(over.every((s) => s.authorKind === "agent"), `restored ROBOT is agent-attributed, got ${JSON.stringify(over)}`);
});
test("edit → undo → redo round-trips attribution to identical state", async () => {
const { doc } = await freshDoc("docs/s40roundtrip.md", "Base alpha. yyyy omega.\n");
const api = await getApi();
const ctl = api.attributionController;
const key = api.proposalController.keyFor(doc);
const start = doc.getText().indexOf("yyyy");
await ctl.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + 4)),
"BLUEWORD",
AGENT,
{ turnId: "turn-s40b" },
);
await settle();
const norm = (k: string) =>
ctl
.getSpans(k)
.map((s) => ({ a: s.authorKind, t: doc.getText().slice(s.range.start, s.range.end) }))
.sort((x, y) => (x.t < y.t ? -1 : 1));
const afterAgent = JSON.stringify(norm(key));
assert.ok(afterAgent.includes("BLUEWORD") && afterAgent.includes("agent"), "agent span present after the edit");
// Delete BLUEWORD, then undo (restore) then redo (re-delete) then undo again.
const bs = doc.getText().indexOf("BLUEWORD");
const del = new vscode.WorkspaceEdit();
del.delete(doc.uri, new vscode.Range(doc.positionAt(bs), doc.positionAt(bs + "BLUEWORD".length)));
await vscode.workspace.applyEdit(del);
await settle();
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand("undo"); // restore BLUEWORD
await settle();
assert.ok(doc.getText().includes("BLUEWORD"), "undo restored BLUEWORD");
assert.strictEqual(JSON.stringify(norm(key)), afterAgent, "attribution after undo matches the pre-deletion state exactly");
});
});
-41
View File
@@ -1,41 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
/**
* #54: `vscode.commands.executeCommand("undo")` is non-functional in some headless
* `.vscode-test` instances (it does not restore the buffer), which false-fails
* every undo-dependent E2E (#38, #40) and makes `main` E2E red there. This is a
* RUNTIME probe: it performs a real edit-then-undo on a scratch buffer and reports
* whether undo actually restored it. Undo-dependent suites gate on it running
* normally where undo works (real coverage), skipping with a loud warning where it
* doesn't (no false red, no silent loss the skip is logged). Memoized per run.
*/
let cached: boolean | undefined;
export async function undoWorks(): Promise<boolean> {
if (cached !== undefined) return cached;
const abs = path.join(process.env.E2E_WORKSPACE!, "docs/.undo-probe.md");
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, "undo probe baseline\n", "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc);
await new Promise((r) => setTimeout(r, 250));
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "PROBE-EDIT-MARKER");
await vscode.workspace.applyEdit(edit);
await new Promise((r) => setTimeout(r, 250));
if (!doc.getText().includes("PROBE-EDIT-MARKER")) {
cached = false; // even the edit didn't take — treat as not undo-capable
return cached;
}
await vscode.commands.executeCommand("undo");
await new Promise((r) => setTimeout(r, 250));
cached = !doc.getText().includes("PROBE-EDIT-MARKER"); // undo removed the marker → undo works
return cached;
}
/** Loud, single-line reason logged when an undo suite skips (no silent loss — #54). */
export const UNDO_SKIP_REASON =
"[E2E] SKIPPING undo-dependent suite — executeCommand('undo') is non-functional in this " +
"VS Code test instance (see vscode-cowriting-plugin#54). These tests run where undo works.";
-9
View File
@@ -3,7 +3,6 @@ import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400));
@@ -35,14 +34,6 @@ suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)"
const DOC_REL = "docs/undo38.md";
const BASE = "Alpha bravo charlie.\n";
// #54: skip (loudly) where executeCommand("undo") is non-functional; run where it works.
suiteSetup(async function () {
if (!(await undoWorks())) {
console.warn(UNDO_SKIP_REASON);
this.skip();
}
});
test("undo of a deletion of baseline text leaves it unattributed (not human)", async () => {
const { doc, key } = await freshDoc(DOC_REL, BASE);
const api = await getApi();
+2 -59
View File
@@ -1,32 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { extractReplacement, runEditTurn } from "../src/liveTurn";
// A fake Agent that replays a scripted event list to subscribers, supports abort,
// and resolves agent.run() with a completed (or aborted) result.
const runs = { count: 0 };
function fakeSdk(events: any[]) {
return {
Agent: class {
private listeners: ((e: any) => void)[] = [];
constructor(_cfg: unknown) {}
subscribe(fn: (e: any) => void) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((l) => l !== fn);
};
}
// The real SDK's abort() is a no-op before run() creates its AbortController,
// so this fake does NOT cooperate with a pre-abort — proving runEditTurn's own
// short-circuit, not the fake's leniency.
abort() {}
async run(_input: string) {
runs.count += 1;
for (const e of events) for (const l of this.listeners) l(e);
return { status: "completed", outputText: "EDITED", runId: "r1" };
}
},
};
}
import { describe, it, expect } from "vitest";
import { extractReplacement } from "../src/liveTurn";
describe("extractReplacement", () => {
it("returns plain text untouched", () => {
@@ -51,33 +24,3 @@ describe("extractReplacement", () => {
expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain");
});
});
describe("runEditTurn progress + cancel", () => {
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
vi.resetModules();
vi.doMock("@cline/sdk", () =>
fakeSdk([
{ type: "assistant-text-delta", text: "ED", accumulatedText: "ED" },
{ type: "usage-updated", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 } },
]),
);
const seen: string[] = [];
const turn = await runEditTurn("do it", "old", { onProgress: (s) => seen.push(s.phase) });
expect(turn.replacement).toBe("EDITED");
expect(seen).toContain("writing");
vi.doUnmock("@cline/sdk");
vi.resetModules();
});
it("a pre-aborted AbortSignal short-circuits before run() and throws (INV-47)", async () => {
vi.resetModules();
vi.doMock("@cline/sdk", () => fakeSdk([]));
runs.count = 0;
const ac = new AbortController();
ac.abort();
await expect(runEditTurn("do it", "old", { signal: ac.signal })).rejects.toThrow(/aborted/);
expect(runs.count).toBe(0); // the turn never ran — no proposal could be produced
vi.doUnmock("@cline/sdk");
vi.resetModules();
});
});
+1 -15
View File
@@ -1,5 +1,4 @@
import assert from "node:assert";
import { describe, it, test, expect } from "vitest";
import { describe, it, expect } from "vitest";
import {
SCHEMA_VERSION,
emptyArtifact,
@@ -180,19 +179,6 @@ 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");
+1 -16
View File
@@ -9,7 +9,7 @@ import {
type Proposal,
} from "../src/model";
import { CoauthorStore } from "../src/store";
import { addProposal, proposalBody, removeProposal, setProposalApplied } from "../src/proposalModel";
import { addProposal, proposalBody, removeProposal } from "../src/proposalModel";
const agent = {
kind: "agent" as const,
@@ -104,19 +104,4 @@ 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");
});
});
+1 -50
View File
@@ -231,7 +231,7 @@ describe("renderReview", () => {
const html = renderReview("hello world", "hello", [], []);
expect(html).toMatch(/<del>|cw-del/);
});
test("renderReview: a pending proposal renders a blue block with data-proposal-id and Accept/Reject actions", () => {
test("renderReview: a pending proposal renders a blue block with data-proposal-id and ✓/✗ 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"');
@@ -375,55 +375,6 @@ 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 a block AFTER a length-changing pending proposal (INV-49/50)", () => {
// block 1 has a pending proposal whose applied text is MUCH LONGER than its
// original; block 2 carries a Claude authorship span (in currentText coords).
const baseline = "one short.\n\ntwo stable here.\n";
const current = "one MUCH LONGER REPLACED TEXT.\n\ntwo stable here.\n";
const proposals: ProposalView[] = [{
id: "pr_1",
anchorStart: current.indexOf("one MUCH LONGER REPLACED TEXT."),
anchorEnd: current.indexOf("one MUCH LONGER REPLACED TEXT.") + "one MUCH LONGER REPLACED TEXT.".length,
replaced: "one short.",
replacement: "one MUCH LONGER REPLACED TEXT.",
original: "one short.",
}];
const sStart = current.indexOf("stable");
const authorSpans = [{ start: sStart, end: sStart + "stable".length, author: "claude" as const }];
const html = renderReview(baseline, current, authorSpans, proposals);
// the Claude coloring wraps "stable" exactly — not shifted by the proposal's length delta.
expect(html).toMatch(/<span class="cw-by-claude">stable<\/span>/);
});
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/);
});
});
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
-102
View File
@@ -1,102 +0,0 @@
import { describe, it, expect } from "vitest";
import {
createTurnProgressState,
reduceTurnProgress,
formatProgressLine,
formatTokens,
type TurnProgressSnapshot,
} from "../src/turnProgress";
// Minimal event factories — structurally match the @cline/sdk AgentRuntimeEvent
// members the reducer reads. `as any` because we only supply the fields used.
const ev = (e: any) => e as any;
const textDelta = (text: string, accumulatedText: string) =>
ev({ type: "assistant-text-delta", text, accumulatedText });
const toolStarted = (toolName: string) => ev({ type: "tool-started", toolCall: { toolName } });
const toolFinished = (toolName: string) => ev({ type: "tool-finished", toolCall: { toolName } });
const usage = (inputTokens: number, outputTokens: number) =>
ev({ type: "usage-updated", usage: { inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0 } });
// Drive a sequence of events, returning every emitted snapshot.
function run(events: any[]): TurnProgressSnapshot[] {
let state = createTurnProgressState();
const out: TurnProgressSnapshot[] = [];
for (const e of events) {
const r = reduceTurnProgress(state, e);
state = r.state;
if (r.snapshot) out.push(r.snapshot);
}
return out;
}
describe("reduceTurnProgress", () => {
it("starts in thinking", () => {
const s = run([ev({ type: "run-started" })]);
expect(s.at(-1)!.phase).toBe("thinking");
expect(s.at(-1)!.chars).toBe(0);
expect(s.at(-1)!.tokens).toBeUndefined();
});
it("text deltas move to writing and accumulate chars + carry the delta", () => {
const s = run([textDelta("Hel", "Hel"), textDelta("lo", "Hello")]);
expect(s.map((x) => x.phase)).toEqual(["writing", "writing"]);
expect(s.at(-1)!.chars).toBe(5);
expect(s.map((x) => x.textDelta)).toEqual(["Hel", "lo"]);
});
it("usage sets a running token total (input+output)", () => {
const s = run([textDelta("Hi", "Hi"), usage(1000, 234)]);
expect(s.at(-1)!.tokens).toBe(1234);
});
it("tool start shows the tool name; tool finish reverts to writing once text was seen", () => {
const s = run([textDelta("x", "x"), toolStarted("read_file"), toolFinished("read_file")]);
expect(s[1].phase).toBe("tool");
expect(s[1].tool).toBe("read_file");
expect(s.at(-1)!.phase).toBe("writing");
});
it("tool finish reverts to thinking when no text was seen", () => {
const s = run([toolStarted("grep"), toolFinished("grep")]);
expect(s.at(-1)!.phase).toBe("thinking");
});
it("overlapping tools resolve in order", () => {
const s = run([toolStarted("a"), toolStarted("b"), toolFinished("b"), toolFinished("a")]);
expect(s.map((x) => x.phase)).toEqual(["tool", "tool", "tool", "thinking"]);
expect(s[1].tool).toBe("b");
expect(s[2].tool).toBe("a");
});
it("reasoning deltas stay thinking and surface no text", () => {
const s = run([ev({ type: "assistant-reasoning-delta", text: "secret", accumulatedText: "secret" })]);
expect(s.at(-1)!.phase).toBe("thinking");
expect(s.at(-1)!.textDelta).toBeUndefined();
});
it("ignores lifecycle/finish events (no snapshot)", () => {
const s = run([ev({ type: "turn-finished" }), ev({ type: "run-finished" })]);
expect(s).toEqual([]);
});
});
describe("formatProgressLine / formatTokens", () => {
it("thinking", () => {
expect(formatProgressLine({ phase: "thinking", chars: 0 })).toBe("thinking…");
});
it("writing with chars", () => {
expect(formatProgressLine({ phase: "writing", chars: 412 })).toBe("writing… (412 chars)");
});
it("writing with chars + tokens", () => {
expect(formatProgressLine({ phase: "writing", chars: 412, tokens: 1234 })).toBe(
"writing… (412 chars) · 1.2k tokens",
);
});
it("tool with name", () => {
expect(formatProgressLine({ phase: "tool", tool: "read_file", chars: 0 })).toBe("running read_file…");
});
it("formats token magnitudes", () => {
expect(formatTokens(950)).toBe("950");
expect(formatTokens(1234)).toBe("1.2k");
});
});
+1 -25
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath";
import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath";
describe("isUnderRoot", () => {
const root = "/a/vscode-cowriting-plugin/sandbox";
@@ -58,27 +58,3 @@ 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",
);
});
});