49 Commits

Author SHA1 Message Date
BenStullsBets a3ea6c65ca fix(#60): pre-aborted signal short-circuits before run; guard onProgress relay
Review findings (Medium+Low): a signal already aborted before runEditTurn is
called could not be honored by agent.abort() (the SDK AbortController isn't
created until run()), so the turn ran to completion — now it throws before
run() (INV-47). And a throwing onProgress relay is swallowed so observability
can never fail the turn (INV-44). Strengthened the unit + E2E to prove both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:49:02 -07:00
BenStullsBets 21bbf6b114 test(#60): smoke-live-turn logs live progress snapshots
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:38:33 -07:00
BenStullsBets 0743cf9a8a test(#60): host E2E — progress is additive; abort proposes nothing (INV-44/47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:37:52 -07:00
BenStullsBets 946625e899 feat(#60): wire live progress + cancel into both Ask-Claude call sites
editSelection (extension.ts) and preview askClaude (trackChangesPreview.ts)
both run the turn cancellable, relay TurnProgress via the shared LiveProgressUi,
and thread onProgress/signal through runEditAndPropose. The EditTurn seam widens
to accept opts (back-compat: existing stubs ignore it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:35:41 -07:00
BenStullsBets 20e13bba4d feat(#60): liveProgressUi host relay + revealOutput setting (INV-45)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:34:10 -07:00
BenStullsBets d2405a2ca8 feat(#60): runEditTurn streams progress + accepts AbortSignal (INV-43/44/47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:33:40 -07:00
BenStullsBets 5d9f7ddaaf feat(#60): pure turn-progress reducer (INV-46)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:32:02 -07:00
BenStullsBets 98b33ff53b claim vscode-cowriting-plugin session 0056 (placeholder) + sessions.json entry 2026-06-26 04:24:43 -07:00
BenStullsBets c5e464fbeb add sessions/0055/SESSION-0055.0-TRANSCRIPT-2026-06-22T23-19--2026-06-26T04-23.md + replace placeholder/variant SESSION-0055.0-TRANSCRIPT-2026-06-22T23-19--INPROGRESS.md 2026-06-26 04:24:19 -07:00
Ben Stull 16cca30d39 claim vscode-cowriting-plugin session 0055 (placeholder) + sessions.json entry 2026-06-22 23:19:57 -07:00
Ben Stull 9b6a15a43c add sessions/0054/SESSION-0054.0-TRANSCRIPT-2026-06-15T10-42--2026-06-15T10-48.md + replace placeholder/variant SESSION-0054.0-TRANSCRIPT-2026-06-15T10-42--INPROGRESS.md 2026-06-22 22:59:20 -07:00
Ben Stull edba577586 claim vscode-cowriting-plugin session 0054 (placeholder) + sessions.json entry 2026-06-15 10:43:00 -07:00
Ben Stull ceca17aa40 add sessions/0053/SESSION-0053.0-TRANSCRIPT-2026-06-15T10-23--2026-06-15T10-29.md + replace placeholder/variant SESSION-0053.0-TRANSCRIPT-2026-06-15T10-23--INPROGRESS.md 2026-06-15 10:42:23 -07:00
Ben Stull 528c76d23b claim vscode-cowriting-plugin session 0053 (placeholder) + sessions.json entry 2026-06-15 10:23:27 -07:00
Ben Stull b9517e0f68 add sessions/0052/SESSION-0052.0-TRANSCRIPT-2026-06-15T08-33--2026-06-15T08-41.md + replace placeholder/variant SESSION-0052.0-TRANSCRIPT-2026-06-15T08-33--INPROGRESS.md 2026-06-15 10:22:35 -07:00
Ben Stull 5a02e793dd claim vscode-cowriting-plugin session 0052 (placeholder) + sessions.json entry 2026-06-15 08:33:55 -07:00
Ben Stull faf0810a6c add sessions/0051/SESSION-0051.0-TRANSCRIPT-2026-06-13T17-49--2026-06-13T17-58.md + replace placeholder/variant SESSION-0051.0-TRANSCRIPT-2026-06-13T17-49--INPROGRESS.md 2026-06-15 03:28:01 -07:00
Ben Stull d597c1c362 claim vscode-cowriting-plugin session 0051 (placeholder) + sessions.json entry 2026-06-13 17:49:33 -07:00
Ben Stull 790d88c827 add sessions/0050/SESSION-0050.0-TRANSCRIPT-2026-06-13T17-43--2026-06-13T17-52.md + replace placeholder/variant SESSION-0050.0-TRANSCRIPT-2026-06-13T17-43--INPROGRESS.md 2026-06-13 17:48:52 -07:00
Ben Stull 37953cfcad claim vscode-cowriting-plugin session 0050 (placeholder) + sessions.json entry 2026-06-13 17:43:55 -07:00
Ben Stull 24e329e25d add sessions/0049/SESSION-0049.0-TRANSCRIPT-2026-06-13T09-19--2026-06-13T09-24.md + replace placeholder/variant SESSION-0049.0-TRANSCRIPT-2026-06-13T09-19--INPROGRESS.md 2026-06-13 09:22:49 -07:00
benstull 96a689aedf Merge pull request '#54 follow-up: pretest:e2e cleans out/test (stop stale cross-branch compiled tests running)' (#56) from s54-pretest-clean into main 2026-06-13 16:21:57 +00:00
Ben Stull 911ed21671 #54 follow-up: pretest:e2e cleans out/test before recompiling
`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 were picked up and run by the
suite glob (`**/*.test.js`) — this caused real cross-branch test confusion in
session 0048 (a deleted probe + another branch's tests ran on an unrelated branch).

Add a `clean:e2e` script (`fs.rmSync('out/test', {recursive, force})` via node, so
no shell `rm` dependency) and run it between `build` and `tsc` in `pretest:e2e`.
Cleans ONLY `out/test` — never `out/`, which holds the just-built esbuild bundle
(`out/extension.cjs`, `out/media`).

Verified: planting a stale `out/test/.../zz.test.js` then running `pretest:e2e`
removes it, and the E2E suite stays green (73 passing + 1 pending, both passes
exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:21:31 -07:00
Ben Stull 2ca0fc8c51 claim vscode-cowriting-plugin session 0049 (placeholder) + sessions.json entry 2026-06-13 09:19:26 -07:00
benstull 3d9270ecc4 Merge pull request '#54: resilient undo E2E (preflight skip-guard) — fixes main E2E red' (#55) from s54-undo-e2e-resilience into main 2026-06-13 16:17:02 +00:00
Ben Stull e53f0c30ad #54: make undo-dependent E2E resilient to environments where undo is broken
`executeCommand("undo")` is non-functional in some headless `.vscode-test`
instances (it does not restore the buffer — see #54's diagnosis), which
false-fails every undo-dependent host E2E and turns `main`'s E2E red there even
though the product code is fine.

Add a RUNTIME preflight probe (`undoCapable.ts`): edit a scratch buffer, undo,
and report whether the buffer was actually restored (memoized per run). The #38
undoMarks suite gates on it via `suiteSetup` — running normally where undo works
(full coverage preserved, real regressions still caught) and skipping with a LOUD
console warning where it doesn't (no false red, no silent loss — the skip is
logged and shows as `pending`).

This fixes the "main E2E red" symptom. The underlying headless-undo limitation is
documented in #54; #40's undo-behavior coverage runs wherever undo works.

Test-infra only — no product code changed. Verified: with undo broken locally the
#38 suite skips (1 pending) and both E2E passes exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:16:40 -07:00
Ben Stull ad6cbe10c7 add sessions/0048/SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--2026-06-13T08-58.md + replace placeholder/variant SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--INPROGRESS.md 2026-06-13 08:58:11 -07:00
Ben Stull 64c7d7bad2 claim vscode-cowriting-plugin session 0048 (placeholder) + sessions.json entry 2026-06-13 08:45:48 -07:00
Ben Stull ab00cbefcc add sessions/0047/SESSION-0047.0-TRANSCRIPT-2026-06-13T08-34--2026-06-13T08-44.md + replace placeholder/variant SESSION-0047.0-TRANSCRIPT-2026-06-13T08-34--INPROGRESS.md 2026-06-13 08:43:50 -07:00
benstull c67749a53c Merge pull request '#33: harden author-coloring PUA sentinels against intra-emphasis markdown' (#53) from s33-sentinel-hardening into main 2026-06-13 15:42:04 +00:00
Ben Stull ba7623f813 #33: harden author-coloring PUA sentinels against intra-emphasis markdown
The F9/F10 author-coloring technique injects paired PUA sentinels into prose
source at span offsets, renders, then maps sentinels → cw-by-* spans. Two failure
modes when a span boundary met emphasis markup (characterized in session 0032):
- CASE1: a boundary strictly inside a delimiter run (a**b**c, between the two *)
  split `**`, breaking markdown-it's delimiter pairing (stray <em></em>, raw **).
- CASE3: a boundary inside an emphasis run (**bold** span covering **bo) rendered
  the emphasis but MISNESTED span/element (<strong>bo</span>ld</strong>).

Token-aware fix (both, per the issue #33 comment):
- injectSentinels: clamp any sentinel offset that lands strictly inside a
  delimiter run (* _ ~ `) to the run's start, so a sentinel never splits a run
  (CASE1). Delimiters are invisible once rendered, so this only shifts the colored
  boundary across markup. Drop spans that clamp to empty.
- sentinelsToSpans: replace the naive global split/join with a walker over the
  rendered HTML that emits the cw-by-* span only around TEXT runs — closing it
  before any <tag> and reopening after — so a span is always well-nested within
  inline elements (one span segment per text run, CASE3). Tags are copied
  verbatim with any stray sentinel stripped (no Private-Use-Area char leaks).

Pure, vscode-free, deterministic (INV-33). No regression: existing colorByAuthor /
renderReview cases stay green; the common no-emphasis case is byte-identical.

222 unit + 74/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:41:31 -07:00
Ben Stull 9e9fcb8057 claim vscode-cowriting-plugin session 0047 (placeholder) + sessions.json entry 2026-06-13 08:34:03 -07:00
Ben Stull 1381eab11e add sessions/0046/SESSION-0046.0-TRANSCRIPT-2026-06-13T08-19--2026-06-13T08-31.md + replace placeholder/variant SESSION-0046.0-TRANSCRIPT-2026-06-13T08-19--INPROGRESS.md 2026-06-13 08:32:25 -07:00
benstull 54846da1ea Merge pull request '#48: pinning the baseline leaves the review panel fully un-annotated' (#52) from s48-pin-clean-panel into main 2026-06-13 15:30:55 +00:00
Ben Stull fdd743490d #48: pinning the baseline leaves the review panel fully un-annotated
A pinned baseline with no changes since should read as a clean starting point,
but the F10 on-render still author-colored every block (colorByAuthor), painting
the whole document green/blue right after a pin. Now, when the baseline reason is
"pinned" and there are zero changes since (every diffBlocks op unchanged), the
on-render is fully clean — no change marks and no authorship coloring — while the
data-src block mapping (INV-36) and any pending proposals (review actions, not
annotations) are kept.

Scoped to the PIN specifically, not all zero-diff: a baseline advanced by a
machine-landing (accept) is also zero-diff but keeps its authorship coloring so
accepted Claude text stays blue (F10 INV-33). renderReview takes a `pinned`
option; the controller passes baseline.reason === "pinned" from both refresh and
the renderHtmlFor test seam.

- trackChangesModel.ts: renderReview gains the `pinned` RenderOption; when pinned
  + zero-diff, blocks render plain (no colorByAuthor).
- trackChangesPreview.ts: pass { pinned } through refresh + renderHtmlFor.
- unit: pinned+zero-diff → no cw-by-*; pinned+zero-diff still shows proposals;
  zero-diff WITHOUT pin (machine-landing) keeps coloring (INV-33); real changes
  after a pin re-color.
- s48PinClean host E2E: type → colored; pin → clean; edit → annotations return.

218 unit + 74/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:28:52 -07:00
Ben Stull 4584e06679 claim vscode-cowriting-plugin session 0046 (placeholder) + sessions.json entry 2026-06-13 08:19:43 -07:00
Ben Stull ef07acfdc1 add sessions/0045/SESSION-0045.0-TRANSCRIPT-2026-06-13T08-04--2026-06-13T08-17.md + replace placeholder/variant SESSION-0045.0-TRANSCRIPT-2026-06-13T08-04--INPROGRESS.md 2026-06-13 08:18:17 -07:00
benstull 83c4a80d8b Merge pull request '#46 (SLICE-3, accept): Accept all pending proposals in one gesture' (#51) from f12-slice3-accept-all into main 2026-06-13 15:16:18 +00:00
Ben Stull c94b9ccfe7 #46 (SLICE-3, accept): Accept all pending proposals in one gesture
Document-edit flow SLICE-3 — completes reach→review→accept
(specs/coauthoring-document-edit-flow.md §7.2, INV-42). A single "Accept all"
gesture applies every pending proposal on the current document through the
existing F4 acceptById seam (block proposals take the INV-40 word-precise path
automatically), in descending anchor order so an earlier accept never invalidates
a later one, skipping (never force-applying) proposals that can't anchor and
reporting applied-vs-skipped. Batched application of the existing accept path — no
new mechanism; the webview posts intent only (INV-35). No confirmation dialog
(undo restores).

- proposalController.ts: acceptAllProposals(document) → {applied, skipped}
  (descending order, orphan-skip); accept/acceptById gain a silent opt so the
  batch suppresses N per-proposal orphan warnings in favour of one report.
- trackChangesPreview.ts: ToolbarMsg += {type:"acceptAll"}; handleWebviewMessage
  routes it to a public acceptAll(document) that batches + reports.
- extension.ts + package.json: cowriting.acceptAllProposals command (active doc,
  markdown-gated palette entry) for the non-webview path.
- media/preview.ts + shellHtml: "✓✓ Accept all" toolbar button, posting the
  intent, shown only with ≥2 pending proposals (authorable, on-state).
- trackChangesModel.ts: diffToBlockHunks now emits one block-aligned hunk per
  CHANGED block even when changed blocks are ADJACENT (treats changed blocks as
  1:1 anchors alongside unchanged ones; gap-spans only cover add/remove runs
  between anchors) — fixes adjacent changed blocks collapsing into one proposal.
- f12Accept host E2E (apply-all reconstructs; orphan skip + report; single
  proposal; command registered/gated); MANUAL-SMOKE-F12 §3.

214 unit + 73/5 host E2E green. Completes the document-edit-flow cluster
(#42 reach + #47 review + #46 accept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:15:55 -07:00
Ben Stull 6fd7555183 claim vscode-cowriting-plugin session 0045 (placeholder) + sessions.json entry 2026-06-13 08:05:04 -07:00
Ben Stull f7788c5585 add sessions/0044/SESSION-0044.0-TRANSCRIPT-2026-06-13T07-40--2026-06-13T08-04.md + replace placeholder/variant SESSION-0044.0-TRANSCRIPT-2026-06-13T07-40--INPROGRESS.md 2026-06-13 08:04:35 -07:00
Ben Stull b69d0f7d15 update sessions/0044/SESSION-0044.0-TRANSCRIPT-2026-06-13T07-40--INPROGRESS.md 2026-06-13 08:03:06 -07:00
benstull dc38b55c57 Merge pull request '#47 (SLICE-2, review): per-block document proposals + word-precise accept' (#50) from f12-slice2-block-proposals into main 2026-06-13 15:01:55 +00:00
Ben Stull 2f6008ba2b #47 (SLICE-2, review): per-block document proposals + word-precise accept
Document-edit flow SLICE-2 (specs/coauthoring-document-edit-flow.md §7.2,
INV-39/40/41 — P1 "too much to review"). A whole-document rewrite now proposes
ONE F4 proposal per CHANGED BLOCK (the unit a human reviews), but accepting a
block reconciles attribution at WORD granularity (the unit F3 records). Block =
decision unit; word = attribution unit. Supersedes INV-37's per-word cut for
document edits; selection edits unchanged.

- trackChangesModel.ts: new pure diffToBlockHunks(current, rewritten) — block-key
  alignment (reusing diffArrays/diffBlocks keying): an isolated changed block →
  one block-aligned hunk → the rewritten block raw (a code/mermaid fence is one
  atomic whole-fence hunk, INV-23); insert/delete runs → one gap-span hunk over
  the inter-anchor region (separators included) so reconstruction stays exact; a
  zero-width gap-span is anchored (INV-41). Also split diffToHunks into the raw,
  un-anchored wordEditHunks + the anchoring wrapper (the anchoring could grow an
  insertion to overlap an adjacent hunk, corrupting a batch apply — a latent bug
  that only surfaced once hunks are applied as a batch).
- trackChangesPreview.ts: runEditAndPropose document branch uses diffToBlockHunks
  and tags each proposal granularity:"block".
- model.ts / proposalModel.ts: additive optional Proposal.granularity
  ("block"|"single"; absent ⇒ single, back-compat — no migration).
- proposalController.ts: accept of a block proposal runs an intra-block word
  sub-diff (wordEditHunks, disjoint) and applies one applyAgentEdit per changed
  run, descending offset — only the words Claude changed land Claude-attributed;
  unchanged spans keep prior authorship (INV-40).
- Tests: diffToBlockHunks unit (reconstruction + fence atomic + add/remove);
  f12Review host E2E (M blocks→M proposals, unchanged→none, fence atomic, INV-40
  attribution, INV-41 insertion accept); updated the f11 document-path E2E to
  per-block (INV-39 supersedes INV-37); MANUAL-SMOKE-F12 §2.

Seam note: pendingEdits.matchEvent resolves one registration per change event, so
INV-40's per-run attribution is sequential applyAgentEdit calls (N undo steps),
not one multi-replace WorkspaceEdit — see transcript Deferred decisions.

214 unit + 69/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:01:16 -07:00
Ben Stull 6e944ab4cc claim vscode-cowriting-plugin session 0044 (placeholder) + sessions.json entry 2026-06-13 07:40:13 -07:00
Ben Stull 2175efb5e5 add sessions/0043/SESSION-0043.0-TRANSCRIPT-2026-06-13T07-18--2026-06-13T07-38.md + replace placeholder/variant SESSION-0043.0-TRANSCRIPT-2026-06-13T07-18--INPROGRESS.md 2026-06-13 07:39:13 -07:00
Ben Stull 5dc7d19419 update sessions/0043/SESSION-0043.0-TRANSCRIPT-2026-06-13T07-18--INPROGRESS.md 2026-06-13 07:29:04 -07:00
benstull e804c46ba0 Merge pull request '#42 (SLICE-1, reach): selection-aware Ask-Claude from editor body + tab' (#49) from f12-slice1-ask-claude-menus into main 2026-06-13 14:27:09 +00:00
Ben Stull 9c3770d26a #42 (SLICE-1, reach): selection-aware Ask-Claude from editor body + tab
Document-edit flow SLICE-1 (specs/coauthoring-document-edit-flow.md §7.2,
INV-38): make "Ask Claude to Edit" reachable from the editor body AND the
editor tab, selection-aware — a selection routes to editSelection, no
selection to editDocument — both gated to markdown/authorable docs, both
flowing through the single runEditAndPropose path (no divergent edit code).

- package.json: add editSelection + editDocument to editor/context (selection-
  aware, markdown + file/untitled) and editor/title/context (selection-aware,
  resourceLangId == markdown). Markdown-gate the existing editor/context
  editSelection entry to match (its command handler is unchanged; the palette
  still reaches any authorable doc — see transcript Deferred decisions).
- trackChangesPreview.ts: cowriting.editDocument accepts the clicked tab's
  resource Uri (editor/title/context), targeting THAT document (opening it if
  needed) and falling back to the active editor when invoked with no arg —
  mirroring showTrackChangesPreview's #41 clicked-doc resolution.
- E2E (test/e2e/suite/f12Reach.test.ts): menu entries present, selection-aware,
  markdown-gated; editDocument(uri) targets the tab doc not the active editor;
  no-arg falls back to the active editor.
- docs/MANUAL-SMOKE-F12.md: SLICE-1 reach smoke steps.

208 unit + 65/5 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 07:26:14 -07:00
39 changed files with 2943 additions and 84 deletions
+96
View File
@@ -0,0 +1,96 @@
# Manual smoke — F12 document-edit flow
Covers the document-edit-flow cluster (`specs/coauthoring-document-edit-flow.md`,
#42 · #47 · #46). This file is filled in slice by slice.
## SLICE-1 — #42 (reach): selection-aware Ask-Claude from body + tab (INV-38)
Run the extension (F5) on a markdown document under the sandbox workspace.
1. **Body, with selection (PUC-2).** Select a paragraph, right-click the editor
**body**. Expect **Ask Claude to Edit Selection** in the menu (and **not**
"Edit Document"). Pick it → instruct → submit; a single proposal lands over
the selection (existing F11 behavior, unchanged).
2. **Body, no selection (PUC-1).** Clear the selection (click once), right-click
the editor **body**. Expect **Ask Claude to Edit Document** (and **not** "Edit
Selection"). Pick it → instruct → submit; the whole-document rewrite surfaces
as F4 proposal(s) in the preview.
3. **Tab, with selection (PUC-3).** With a selection active, right-click the
editor **tab**. Expect **Ask Claude to Edit Selection**, acting on that tab's
document.
4. **Tab, no selection (PUC-3).** With no selection, right-click the editor
**tab**. Expect **Ask Claude to Edit Document**, acting on **that tab's**
document — even if a *different* editor is the active one. Open two markdown
tabs A and B, make A active, right-click B's tab → Edit Document → the
proposals land on **B**, not A.
5. **Markdown-gated.** Open a non-markdown file (e.g. `.txt`). Right-click body or
tab: neither **Ask Claude to Edit Selection** nor **Edit Document** appears.
6. **Single edit path.** Both entries route through the same `runEditAndPropose`
path — there is no second edit code path (INV-38). Nothing is written to the
document or sidecar by merely invoking the menu (INV-10/20/35) until you accept.
### Pass criteria
The body and tab menus show exactly one Ask-Claude edit entry, matching the live
selection state (selection → Edit Selection; none → Edit Document); the tab
gesture targets the clicked tab's document, not the active editor; both are absent
on non-markdown docs; no console errors.
## SLICE-2 — #47 (review): per-block proposals + word-precise attribution (INV-39/40/41)
On a markdown doc with several paragraphs, **Ask Claude to Edit Document** with a
light copy-edit instruction (e.g. "tighten the prose, fix typos").
1. **One proposal per changed block (INV-39).** Claude's pass surfaces as **one
✓/✗ block per changed paragraph/header/bullet**, not a flurry of word-level
blocks. A paragraph with several word edits is a **single** proposal; the
word-level `<ins>`/`<del>` still shows *inside* it. Untouched paragraphs show no
proposal.
2. **Changed fence is atomic (INV-23).** If Claude edits a code/mermaid fence, it
is **one** whole-fence proposal.
3. **Accept attributes only the changed words (INV-40).** Accept a block proposal,
then toggle the preview to **Authorship**/colors (or re-open in the on-state):
only the words Claude actually changed are Claude-colored; the unchanged words
in that block keep their prior author. The block is the decision unit; the word
is the attribution unit.
4. **Inserted block accepts cleanly (INV-41).** If Claude adds a new paragraph,
its proposal **accepts** (it is anchored to an adjacent block, never a
born-orphaned/zero-width proposal).
5. **Undo.** Accepting a block is currently **N undo steps** (one per changed run
inside the block) — `Ctrl+Z` repeatedly restores it. (Spec deferred note:
single-undo-step grouping is a possible follow-up.)
6. **Selection edits unchanged.** Edit *Selection* still produces exactly one
proposal over the selection (no block fan-out).
### Pass criteria
A document edit yields one in-context proposal per changed block (fences atomic);
accepting a block lands the whole block but Claude-attributes only the words it
changed; inserted blocks accept; selection edits are unaffected; no console errors.
## SLICE-3 — #46 (accept): Accept all (INV-42)
On a markdown doc, **Ask Claude to Edit Document** with a pass that changes
**several** blocks, so the preview shows **≥ 2** pending proposals.
1. **Button appears at ≥2 (PUC-6).** The preview toolbar shows **✓✓ Accept all**
only when there are **2 or more** pending proposals (and the doc is authorable,
annotations on). With 01 pending it is hidden.
2. **One gesture applies all.** Click **Accept all**: every pending proposal lands
(Claude-attributed per INV-40), the ✓/✗ blocks clear, and a status message
reports how many were accepted. No confirmation dialog.
3. **Undo restores.** `Ctrl+Z` walks back the applied edits (consistent with
single accept; a block accept is itself N steps — see SLICE-2).
4. **Orphan-skip + report.** If one proposal's target text was changed by hand
first (so it can't anchor), Accept all applies the rest and the report says
`… , N skipped (target text changed — undo or reject)`; the orphaned proposal
stays pending, its text untouched (never force-applied).
5. **Command path.** With no preview panel open, the command palette **Cowriting:
Accept All Claude Proposals** (markdown-gated) applies all proposals on the
active doc with the same report.
### Pass criteria
Accept all is offered only at ≥2 pending; one click applies every resolvable
proposal and reports the tally; orphans are skipped (not mangled) and remain
pending; the palette command works on the active doc; no console errors.
+9
View File
@@ -30,6 +30,7 @@ const legend = document.getElementById("cw-legend")!;
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null;
const acceptAllEl = document.getElementById("cw-acceptall") as HTMLButtonElement | null;
// F10: the annotations on/off toggle.
annotationsEl?.addEventListener("change", () => {
@@ -88,6 +89,11 @@ askEl?.addEventListener("click", () => {
}
});
// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only).
acceptAllEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "acceptAll" });
});
// 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");
@@ -132,6 +138,9 @@ window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
if (askEl) askEl.disabled = !authorable;
const on = msg.mode === "on";
if (annotationsEl) annotationsEl.checked = on;
// #46: Accept all shows only with ≥2 pending proposals, on an authorable doc,
// in the annotated (on) state — a single proposal is just a ✓ in place.
if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2;
// Off-state is a clean preview: hide the review chrome.
header.hidden = !on;
summary.hidden = !on;
+38 -3
View File
@@ -18,6 +18,16 @@
"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",
@@ -83,6 +93,11 @@
"command": "cowriting.editDocument",
"title": "Ask Claude to Edit Document",
"category": "Cowriting"
},
{
"command": "cowriting.acceptAllProposals",
"title": "Accept All Claude Proposals",
"category": "Cowriting"
}
],
"menus": {
@@ -110,6 +125,10 @@
{
"command": "cowriting.editDocument",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown"
}
],
"editor/title": [
@@ -120,10 +139,20 @@
}
],
"editor/title/context": [
{
"command": "cowriting.editSelection",
"when": "editorHasSelection && resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
"command": "cowriting.editDocument",
"when": "!editorHasSelection && resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
"command": "cowriting.showTrackChangesPreview",
"when": "resourceLangId == markdown",
"group": "1_cowriting"
"group": "1_cowriting@3"
}
],
"explorer/context": [
@@ -136,7 +165,12 @@
"editor/context": [
{
"command": "cowriting.editSelection",
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
"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"
},
{
@@ -178,7 +212,8 @@
"watch": "node esbuild.mjs --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
"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",
"test:e2e": "node ./out/test/e2e/runTest.js",
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
"vscode:prepublish": "node esbuild.mjs"
+9 -1
View File
@@ -10,7 +10,15 @@ console.log(`instruction: ${instruction}`);
console.log(`text: ${text}`);
try {
const t0 = Date.now();
const result = await runEditTurn(instruction, text);
// #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(" ")}`);
},
});
console.log(`replacement: ${JSON.stringify(result.replacement)}`);
console.log(`model: ${result.model}`);
console.log(`sessionId: ${result.sessionId}`);
@@ -0,0 +1,92 @@
# Session 0043.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T07-18 (PST)
> Type: planning-and-executing
> End: 2026-06-13T07-38 (PST)
> Status: **FINALIZED.**
> Posture: autonomous (yolo)
## Launch prompt
`/goal plan-and-execute #42 (SLICE-1 of the document-edit flow — Ask-Claude
entry-point menu wiring), per specs/coauthoring-document-edit-flow.md`
## Plan
**SLICE-1 — #42 (reach)** of the document-edit flow Solution Design
(`specs/coauthoring-document-edit-flow.md`, §7.2). Anchor: Feature #42
(`type/feature`); design graduated (session 0041) → §4.3 R3 satisfied.
Goal (INV-38): "Ask Claude to Edit" reachable from the editor **body** and the
editor **tab**, selection-aware (selection → `editSelection`; no selection →
`editDocument`), both gated to markdown/authorable docs, both routing through the
single `runEditAndPropose` path. Smallest increment — command already exists.
Tasks:
1. `package.json` — add `editSelection` + `editDocument` to `editor/context`
(selection-aware `when`, markdown+authorable) and `editor/title/context`
(selection-aware, `resourceLangId == markdown`); keep titles.
2. `trackChangesPreview.ts``cowriting.editDocument` accepts an optional tab
`uri` arg (mirror `showTrackChangesPreview`'s #41 resolution): open/resolve the
clicked doc, else fall back to the active editor.
3. E2E (`test/e2e/suite/`) — menu entries present + selection-aware + markdown-gated;
`editDocument(uri)` resolves the tab doc and produces a document-scoped proposal.
No model change, no new persisted artifact. No deploy pipeline (VS Code extension).
## Results
**SLICE-1 (#42, reach) shipped to `main`** — PR
[#49](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/49)
(merged), issue #42 closed.
- `package.json``editSelection` + `editDocument` added to `editor/context`
(selection-aware, `editorLangId == markdown` + `file`/`untitled`) and
`editor/title/context` (selection-aware, `resourceLangId == markdown`);
existing `editor/context` `editSelection` entry markdown-gated to match INV-38.
- `src/trackChangesPreview.ts``cowriting.editDocument` now accepts the clicked
tab's resource `Uri` (opens it if needed), falling back to the active editor;
mirrors `showTrackChangesPreview`'s #41 resolution.
- `test/e2e/suite/f12Reach.test.ts` — 4 new host E2E (menu wiring declarative +
tab-URI targeting + no-arg fallback).
- `docs/MANUAL-SMOKE-F12.md` — SLICE-1 reach smoke steps.
Verification: `tsc --noEmit` clean; **208 unit** green; **65/5 host E2E** green
(main suite up from 61 → 65 with the 4 new tests).
**Next:** SLICE-2 — #47 (review, **P1**): document edits propose per changed
block (`diffToBlockHunks`, INV-39 supersedes INV-37; word-precise intra-block
attribution INV-40; block-insertion anchoring INV-41). Then SLICE-3 — #46
(accept-all, INV-42).
## Session arc
1. **Gate + claim.** Classified the `/goal` as planning-and-executing; claimed
session 0043 (peek showed nothing in flight). Baseline: local `main` was 4
behind `origin/main` → fast-forwarded clean.
2. **Anchor gate (§4.3 R3).** #42 is `type/feature`; its design is the graduated
combined Solution Design `specs/coauthoring-document-edit-flow.md` (session
0041) → R3 satisfied, proceed.
3. **Read the spec**, scoped SLICE-1 (reach). Explored the code: `package.json`
menus, `editDocument`/`editSelection` handlers, the F11 E2E harness (`setEditTurnForTest`).
4. **TDD.** Wrote `f12Reach.test.ts` first (red), then wired the menus + tab-URI
resolution (green).
5. **Verified** (tsc clean, 208 unit, 65/5 E2E), self-reviewed the diff, shipped
via PR #49 (merged), closed #42, updated memory, checkpoint-published the
transcript.
## 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._
- **Markdown-gating `editSelection`'s `editor/context` entry** (driver call,
low-confidence). Spec §5/INV-38 say both Ask-Claude entries are markdown-gated,
but F8 made the `editSelection` *command* work on any authorable doc and its
current right-click entry has no `editorLangId == markdown` gate. Followed the
spec: gated the **menu** entries to markdown while leaving the command handlers'
behavior intact (palette still works on any authorable file). Removes the
body right-click Ask-Claude-Edit-Selection on non-markdown files — acceptable
since the proposal review surface (F10 preview) is markdown-only.
@@ -0,0 +1,127 @@
# Session 0044.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T07-40 (PST)
> Type: planning-and-executing
> End: 2026-06-13T08-04 (PST)
> Status: **FINALIZED.**
> Posture: autonomous (yolo)
## Launch prompt
```
/goal plan-and-execute #47 (SLICE-2 of the document-edit flow — document edits propose per changed block: diffToBlockHunks INV-39 supersedes INV-37, word-precise intra-block attribution INV-40, block-insertion anchoring INV-41), per specs/coauthoring-document-edit-flow.md
```
## Plan
**SLICE-2 — #47 (review, P1)** of the document-edit flow
(`specs/coauthoring-document-edit-flow.md` §7.2). Anchor: Feature #47
(`type/feature`); covered by the graduated combined Solution Design → §4.3 R3.
Document rewrites propose **one F4 proposal per changed block** (the unit a human
reviews), but each accept reconciles attribution at **word** granularity (the unit
F3 records). INV-39 supersedes INV-37 for document edits; INV-40 word-precise
intra-block attribution; INV-41 block-insertion anchoring. Selection edits
unchanged.
Tasks:
1. `model.ts` — add optional `granularity?: "block" | "single"` to `Proposal`
(additive, back-compat: absent ⇒ `"single"`). INV-39/§6.3.
2. `proposalModel.ts``addProposal` opts gains `granularity`, threaded onto the
proposal (omitted when absent).
3. `trackChangesModel.ts` — new pure `diffToBlockHunks(currentText,
rewrittenText): EditHunk[]`: split both sides into the existing block units
(`splitBlocksWithRanges` for source ranges + `splitBlocks` keys), diff blocks
(reuse `diffArrays` keying like `diffBlocks`), emit ONE `EditHunk` per
changed/added/removed block — changed → `[block.start,end)`→rewrite; fences
atomic (whole-fence hunk); inserted → anchored to adjacent boundary (INV-41,
reuse the `anchorInsertion` idea); unchanged → none. Same `EditHunk` shape as
`diffToHunks` (which is RETAINED as the intra-block sub-diff engine).
4. `trackChangesPreview.ts` — `runEditAndPropose` **document branch** uses
`diffToBlockHunks` and tags each minted proposal `granularity:"block"`.
5. `proposalController.ts` — `accept`: when `granularity === "block"`, re-resolve
the block text, `diffToHunks(blockText, replacement)` → per-run word sub-diff,
apply **one `applyAgentEdit` per changed run, descending offset** so only the
runs Claude changed land Claude-attributed; unchanged spans keep prior author
(INV-40). Non-block keeps the single `applyAgentEdit`.
6. Tests: unit (`diffToBlockHunks` + INV-40 controller) + host E2E (#47 cases in
§6.8); update the existing f11 document-path E2E (now per-block, supersedes
INV-37 word-level expectation); `docs/MANUAL-SMOKE-F12.md` §2.
**Design note (seam constraint):** `pendingEdits.matchEvent` resolves ONE
registration per change event, so INV-40's per-run attribution is implemented as
**sequential** `applyAgentEdit` calls (descending offset), not a single
multi-replace `WorkspaceEdit`. The spec's "one undo-grouped edit" wording is thus
approximated as N undo steps per block accept — see Deferred decisions.
No new persisted artifact; no deploy pipeline (VS Code extension).
## Results
**SLICE-2 (#47, review, P1) shipped to `main`** — PR
[#50](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/50)
(merged), issue #47 closed.
- `trackChangesModel.ts` — new pure `diffToBlockHunks` (block-key alignment via
`diffArrays`/`diffBlocks` keying; isolated changed block → block-aligned hunk →
rewritten raw; fences atomic INV-23; insert/delete → exact gap-span hunk;
zero-width gap-span anchored INV-41). Split `diffToHunks` into raw
`wordEditHunks` + anchoring wrapper (fixes a latent overlap bug under batch apply).
- `trackChangesPreview.ts` — document branch uses `diffToBlockHunks`, tags
`granularity:"block"`.
- `model.ts`/`proposalModel.ts` — additive optional `Proposal.granularity`.
- `proposalController.ts``acceptBlock`: intra-block word sub-diff
(`wordEditHunks`) → one `applyAgentEdit` per changed run, descending offset
(INV-40 word-precise attribution; unchanged spans keep prior author).
- Tests: `diffToBlockHunks` unit (reconstruction/fence/add-remove); `f12Review`
host E2E (M→M, unchanged→none, fence atomic, INV-40, INV-41); updated f11
document-path E2E to per-block; `MANUAL-SMOKE-F12.md` §2.
Verification: `tsc --noEmit` clean; **214 unit** green; **69/5 host E2E** green
(main suite 65 → 69 net with the new F12 review tests).
**Next:** SLICE-3 — #46 (accept-all, INV-42): `acceptAllProposals` (controller,
descending-order, orphan-skip + report) + `cowriting.acceptAllProposals` command
+ preview toolbar **Accept all** button (`{type:"acceptAll"}` intent, shown ≥2
pending).
## Session arc
1. **Claim.** Resumed via `/goal next` (after session 0043 shipped #42); claimed
session 0044 for #47. Clean `main` baseline.
2. **Anchor gate.** #47 (`type/feature`) covered by the graduated combined design
→ §4.3 R3 satisfied.
3. **Deep code read.** `splitBlocks*`/`diffBlocks`/`diffToHunks`/`EditHunk`,
`runEditAndPropose`, `proposalController.accept`/`applyAgentEdit`, and crucially
`pendingEdits.matchEvent` (one registration per change event → drove the INV-40
sequential-apply decision).
4. **TDD `diffToBlockHunks`.** First attempt (coarsen word-hunks) failed a
reconstruction case — discovered `diffToHunks` can emit OVERLAPPING hunks
(anchorInsertion over-absorbs). Switched to block-key alignment with
replacement-from-rewritten-raw → green.
5. **Model + wiring + INV-40 accept.** Added `granularity`; document branch →
`diffToBlockHunks`; `acceptBlock` intra-block sub-diff. The overlap bug then
resurfaced in `acceptBlock` (batch apply) → split `diffToHunks` into raw
`wordEditHunks` (disjoint) + anchoring wrapper; `acceptBlock` uses raw → green.
6. **Verified + shipped** (214 unit, 69/5 E2E), self-reviewed, PR #50 merged,
#47 closed, memory + transcript updated.
## 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._
- **INV-40 undo granularity** (driver call). Spec §6.2/§6.7 say accepting a block
proposal should be "one undo-grouped WorkspaceEdit." But the F3 seam's
`pendingEdits.matchEvent` matches exactly ONE registration per change event, so a
single `WorkspaceEdit` with N independent run-replaces can't carry per-run
attribution (the multi-hunk event fails to match any single registration → falls
through to human). Implemented INV-40 as **sequential per-run `applyAgentEdit`
calls, descending offset** — correct word-precise attribution, reuses the seam
with zero attribution-core risk, but a block accept is N undo steps rather than
one. Single-undo-step would require extending `matchEvent`/`onDidChange` to
consume multiple registrations per event (riskier F3 change) — left as a possible
follow-up.
@@ -0,0 +1,98 @@
# Session 0045.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T08-04 (PST)
> Type: planning-and-executing
> End: 2026-06-13T08-17 (PST)
> Status: **FINALIZED.**
> Posture: autonomous (yolo)
## Launch prompt
```
/goal plan-and-execute #46 (SLICE-3 of the document-edit flow — accept-all, INV-42), per specs/coauthoring-document-edit-flow.md
```
## Plan
**SLICE-3 — #46 (accept)** of the document-edit flow
(`specs/coauthoring-document-edit-flow.md` §7.2, INV-42). Final slice — completes
reach→review→**accept**. Anchor: Feature #46 (`type/feature`); covered by the
graduated combined design → §4.3 R3.
A single **Accept all** gesture applies every pending proposal on the current
document through the existing F4 `acceptById` seam (block proposals take the INV-40
path automatically), in a re-anchor-safe (descending) order, **skipping** orphans
and **reporting** applied-vs-skipped. Batched application of the existing path — no
new accept mechanism; the webview posts intent only (INV-35).
Tasks:
1. `proposalController.ts` — `acceptAllProposals(document): Promise<{applied,
skipped}>`: snapshot pending, sort descending by resolved anchor start,
`acceptById` each **silently** (no per-item orphan warning), tally
applied/skipped (orphans counted skipped). Add a `silent` opt to the accept
path so the batch suppresses per-proposal warnings.
2. `extension.ts` — `cowriting.acceptAllProposals` command (active doc) → reports
applied-vs-skipped via a status message.
3. `trackChangesPreview.ts` — `ToolbarMsg += {type:"acceptAll"}`;
`handleWebviewMessage` routes it → `proposals.acceptAllProposals(document)` +
report.
4. `media/preview.ts` + `shellHtml` — an **Accept all** toolbar button shown when
`summary.proposals >= 2` (and authorable), posting `{type:"acceptAll"}`.
5. `package.json` — register the `cowriting.acceptAllProposals` command (palette,
markdown-gated).
6. Tests: host E2E (N pending → all applied + cleared; orphan skipped + reported;
button hidden < 2 pending); `MANUAL-SMOKE-F12.md` §3.
No new persisted artifact; no deploy pipeline (VS Code extension).
## Results
**SLICE-3 (#46, accept) shipped to `main`** — PR
[#51](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/51)
(merged), issue #46 closed. **Completes the document-edit-flow cluster
(#42 reach + #47 review + #46 accept).**
- `proposalController.ts` — `acceptAllProposals(document)` → `{applied, skipped}`
(descending order, orphan-skip); `accept`/`acceptById` `silent` opt for the batch.
- `trackChangesPreview.ts` — `ToolbarMsg += {type:"acceptAll"}` → public
`acceptAll(document)` (batch + report).
- `extension.ts`/`package.json` — `cowriting.acceptAllProposals` command
(active doc, markdown-gated palette).
- `media/preview.ts` + shell — "✓✓ Accept all" toolbar button (intent; shown
≥2 pending, authorable, on-state).
- `trackChangesModel.ts` — `diffToBlockHunks` fix: emit one block-aligned hunk
per changed block **even when adjacent** (changed blocks are 1:1 anchors;
gap-spans only cover add/remove runs) — caught by the accept-all E2E (3 adjacent
changed blocks were collapsing to 1 proposal).
- `f12Accept.test.ts` E2E + `MANUAL-SMOKE-F12.md` §3.
Verification: `tsc --noEmit` clean; **214 unit** green; **73/5 host E2E** green
(main suite 69 → 73 with the new accept-all tests).
**Next:** the cluster is complete; no in-flight next step. Open backlog includes
#48 (pin → fully clean review panel, story P2) and the OQ-2 F11 (#43) spec
graduation (hygiene). A natural hand-back point for operator direction.
## Session arc
1. **Claim.** Continued the rolling `next` goal after 0044 shipped #47; claimed
session 0045 for #46. Clean `main`.
2. **Plan + implement.** Controller `acceptAllProposals` (+ `silent` accept opt);
`acceptAll` intent route + public method; command + package.json; "Accept all"
toolbar button (≥2-pending gating in the sealed webview).
3. **E2E caught a real bug.** The accept-all "3 adjacent changed blocks" case
returned 1 proposal, not 3 — `diffToBlockHunks` was merging adjacent changed
blocks into one gap-span run. Fixed by treating `changed` blocks as 1:1 anchors
(each → its own block-aligned hunk; gap-spans only span add/remove runs).
4. **Verified + shipped** (214 unit, 73/5 E2E), self-reviewed, PR #51 merged,
#46 closed → **cluster complete**; memory + transcript updated.
## 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._ (The `diffToBlockHunks` adjacency
behavior was a bug caught by the accept-all E2E, not a judgment call.)
@@ -0,0 +1,95 @@
# Session 0046.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T08-19 (PST)
> Type: planning-and-executing
> End: 2026-06-13T08-31 (PST)
> Status: **FINALIZED.**
> Posture: autonomous (yolo)
## Launch prompt
```
/goal plan-and-execute #48 (pinning the baseline leaves the review panel fully un-annotated — zero-diff → no F3 authorship colors on unchanged blocks; proposals still show)
```
## Plan
**#48 (story, P2)** — pinning the baseline leaves the review panel fully
un-annotated. Anchor: leaf `story` → §4.3 R2 (no design gate). Picked
autonomously as the next backlog item after the document-edit-flow cluster shipped
(0043/0044/0045).
When `diffBlocks(baseline, current)` yields all-`unchanged` ops (zero diff — the
state right after a pin), the F10 on-render still author-colors every block
(`colorByAuthor`), so the panel looks painted instead of clean. Fix: in that
zero-diff case render clean (no author coloring, no change marks) while keeping
`data-src` mapping (INV-36) and still injecting pending proposals (proposals are
actions, not annotations). Narrow edge case of INV-33 — with-changes render
unchanged; off-state unchanged; broader "authorship never colors unchanged" NOT
in scope.
Tasks:
1. `trackChangesModel.ts` `renderReview` — detect `ops.every(unchanged)`; in that
case use a plain `render` (skip `colorByAuthor`) for blocks; proposal injection
loop unchanged.
2. Unit: baseline==current + author spans → no `cw-by-claude`/`cw-by-human` (and
no `cw-add`/`cw-del`) on the body; with a pending proposal → the `cw-proposal`
block still renders.
3. Host E2E: open preview, diverge + author-color, pin → panel clean (no
green/blue); edit again → annotations return.
4. Content repo: one-line INV-33 clarification (zero-diff → clean on-render) in
`specs/coauthoring-interactive-review.md`.
No new persisted artifact; no deploy pipeline (VS Code extension).
## Results
**#48 (story, P2) shipped to `main`** — PR
[#52](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/52)
(merged), issue #48 closed. Picked autonomously after the document-edit-flow
cluster (0043/0044/0045).
- `trackChangesModel.ts``renderReview` gains a `pinned` `RenderOption`; when
pinned + zero-diff, blocks render plain (skip `colorByAuthor`).
- `trackChangesPreview.ts` — pass `{ pinned: baseline?.reason === "pinned" }`
from `refresh` + `renderHtmlFor`.
- Unit (4 new) + `s48PinClean` host E2E.
**Scoped to the pin specifically** (not all zero-diff): a baseline advanced by a
**machine-landing** (accept) is also zero-diff but keeps its authorship coloring
(F10 INV-33) — the F10 authorship E2E caught a pure-zero-diff rule would regress
accepted-Claude-text coloring, so the clean render is gated on `reason ===
"pinned"`.
Verification: `tsc --noEmit` clean; **218 unit** green; **74/5 host E2E** green.
## Session arc
1. **Pick + claim.** Stop hook required determining/executing the next milestone
autonomously (rolling `next`); chose #48 (leaf story, no design gate) and
claimed 0046. Clean `main`.
2. **Read the issue** (detailed, gave the solution shape) + `renderReview`.
3. **TDD.** Implemented zero-diff-clean; full suite caught the F10 authorship
regression (accept advances baseline → zero-diff → coloring was being cleared);
re-scoped to `reason === "pinned"` via a `pinned` RenderOption → all green.
4. **Shipped** (PR #52), closed #48; spec clarification written but left in the
content-repo working tree (see Deferred decisions).
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Empty if none._
- **INV-33 clarification not pushed to the content repo** (loose end, not a
judgment call). Wrote the zero-diff-after-pin clarification into
`specs/coauthoring-interactive-review.md`, but the **content repo has
pre-existing uncommitted state that isn't this session's**: a modified
`coauthoring-diff-view.md` and ~18 untracked capture-session draft files
(`issues/*.md`, `specs/coauthoring-document-edit-flow.md`), and local `main` is
**6 behind origin**. A clean rebase would require deleting the operator's
untracked drafts (irreversible — a STOP gate), so I **soft-reset** my spec
commit into the working tree rather than force it. The clarification sits as an
uncommitted modification alongside the operator's other content-repo drafts,
for the operator to reconcile/push. The #48 **code** shipped normally.
@@ -0,0 +1,86 @@
# Session 0047.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T08-34 (PST)
> Type: planning-and-executing
> End: 2026-06-13T08-44 (PST)
> Status: **FINALIZED.**
> Posture: autonomous (yolo)
## Launch prompt
```
/goal plan-and-execute #33 (intra-emphasis sentinel hardening — token-aware fix for the 2 characterized failure modes)
```
## Plan
**#33 (task, P3)** — harden the F9/F10 author-coloring PUA sentinels against
intra-emphasis markdown. Anchor: leaf `task` → §4.3 R2 (no design gate). Picked
autonomously (next clean plan-and-executable backlog item; doesn't touch the
content repo). Approach = the token-aware fix recommended in the issue #33 comment.
Two characterized failure modes (issue #33 comment, session 0032):
- **CASE1** — a span boundary lands strictly inside a delimiter run (`a**b**c`,
boundary between the two `*`): the injected sentinel splits `**` → markdown parse
breaks (stray `<em></em>`, raw `**` left).
- **CASE3** — a span boundary inside an emphasis run (`**bold**`, span covers
`**bo`): emphasis renders but the author `<span>` and `<strong>` **misnest**
(`<strong>bo</span>ld</strong>`).
Fix (both needed):
1. `injectSentinels`**clamp** any sentinel offset that lands strictly inside a
markdown delimiter run (`* _ ~ \``) to the run's start, so a sentinel never
splits a delimiter (fixes CASE1). Skip now-zero-width spans.
2. `sentinelsToSpans` — replace the naive split/join with a **token-aware walker**
over the rendered HTML: emit `cw-by-*` spans only around TEXT runs, closing the
span before any `<tag>` and reopening after, so a span never crosses an element
boundary (fixes CASE3 — one span segment per text run). Strip any stray sentinel
left inside a tag (no PUA leakage).
Pure/vscode-free/deterministic (INV-33). Non-goals: link/attribute sentinel cases,
visual language, attribution model.
Tasks: unit tests reproducing CASE1 + CASE3 (+ regression on existing
colorByAuthor/renderReview cases) → implement clamp + walker → green; spec already
notes the hardening (§1.7/§9). No deploy pipeline (extension).
## Results
**#33 (task, P3) shipped to `main`** — PR
[#53](https://git.benstull.org/benstull/vscode-cowriting-plugin/pulls/53)
(merged), issue #33 closed. Picked autonomously (next clean plan-and-executable
leaf; doesn't touch the content repo).
- `trackChangesModel.ts` `injectSentinels` — clamp any sentinel offset landing
strictly inside a delimiter run (`* _ ~ \``) to the run's start (fixes CASE1
parse-break); drop spans that clamp to empty.
- `trackChangesModel.ts` `sentinelsToSpans` — token-aware walker emitting the
`cw-by-*` span only around text runs, split at every `<tag>` boundary (fixes
CASE3 misnest); strays stripped (no PUA leak).
- 4 new unit tests (CASE1/CASE3/CASE2-regression/no-leak), real markdown-it via
`renderReview`.
Verification: `tsc --noEmit` clean; **222 unit** green; **74/5 host E2E** green;
the F10/authorship E2E (`cw-by-claude`/`cw-by-human`) still pass (common case
byte-identical).
## Session arc
1. Stop hook required autonomously determining/executing the next milestone;
chose #33 (leaf task, no content-repo dependency). Claimed 0047, clean `main`.
2. Read issue #33 + its investigation comment (2 failure modes, recommended
token-aware approach) + session 0032 characterization + the sentinel code.
3. TDD: CASE1/CASE3 reproduced red → implemented clamp + walker → green; full
suite + E2E green; self-reviewed (one pure module).
4. Shipped (PR #53), closed #33.
## 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._ (Approach was the one recommended in the
issue #33 comment; the fix is a pure-module robustness change with full test
coverage and no regression.)
@@ -0,0 +1,90 @@
# 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.)
@@ -0,0 +1,61 @@
# 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.)
@@ -0,0 +1,76 @@
# 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).
@@ -0,0 +1,71 @@
# 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).
@@ -0,0 +1,73 @@
# 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).
@@ -0,0 +1,66 @@
# 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).
@@ -0,0 +1,66 @@
# 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).
@@ -0,0 +1,105 @@
# 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,13 +1,13 @@
# Session 0043.0 — Transcript
# Session 0056.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-13T07-18 (PST)
> Start: 2026-06-26T04-24 (PST)
> Type: planning-and-executing
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0043 for vscode-cowriting-plugin. The driver replaces this
> This file reserves session ID 0056 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0043.0-TRANSCRIPT-2026-06-13T07-18--<end>.md form at session end.
> SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--<end>.md form at session end.
## Launch prompt
+39
View File
@@ -127,5 +127,44 @@
},
"0043": {
"title": ""
},
"0044": {
"title": ""
},
"0045": {
"title": ""
},
"0046": {
"title": ""
},
"0047": {
"title": ""
},
"0048": {
"title": ""
},
"0049": {
"title": ""
},
"0050": {
"title": ""
},
"0051": {
"title": ""
},
"0052": {
"title": ""
},
"0053": {
"title": ""
},
"0054": {
"title": ""
},
"0055": {
"title": ""
},
"0056": {
"title": ""
}
}
+44 -3
View File
@@ -11,6 +11,7 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi";
import { isAuthorable, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -23,12 +24,18 @@ export interface CowritingApi {
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi;
}
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);
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
try {
@@ -104,9 +111,24 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
context.extensionUri,
attributionController,
proposalController,
liveProgressUi,
);
context.subscriptions.push(trackChangesPreviewController);
// #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.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document;
if (!doc || doc.languageId !== "markdown") {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals.");
return;
}
await trackChangesPreviewController.acceptAll(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.
@@ -216,10 +238,28 @@ 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…" },
async () => {
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const { runEditTurn } = await import("./liveTurn");
const turn = await runEditTurn(instruction, selectedText);
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;
}
if (turn.replacement === "") {
void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
@@ -278,6 +318,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
diffViewController,
trackChangesPreviewController,
sidecarRouter,
liveProgressUi,
};
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 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();
}
}
+61 -11
View File
@@ -9,6 +9,9 @@
* never bundled (esbuild keeps it external).
*/
import type { TurnProgressSnapshot } from "./turnProgress";
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
export interface EditTurnResult {
replacement: string;
model: string;
@@ -16,6 +19,19 @@ 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.",
@@ -40,7 +56,7 @@ export function extractReplacement(outputText: string, selectedText: string): st
export async function runEditTurn(
instruction: string,
selectedText: string,
opts?: { modelId?: string },
opts?: RunEditTurnOptions,
): Promise<EditTurnResult> {
const sdk = await import("@cline/sdk");
const modelId = opts?.modelId ?? "sonnet";
@@ -49,16 +65,50 @@ export async function runEditTurn(
modelId,
systemPrompt: SYSTEM_PROMPT,
});
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?)",
// 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>`,
);
// 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 };
}
+7
View File
@@ -90,6 +90,13 @@ export interface Proposal {
turnId?: string;
/** what the human asked for (review context). */
instruction?: string;
/**
* F12/#47 (INV-39/40): the review-decision unit this proposal represents.
* `"block"` ⇒ the anchor spans a whole document block and accept reconciles
* attribution per WORD inside it (INV-40); `"single"` (or absent, for
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
*/
granularity?: "block" | "single";
}
export interface Artifact {
+93 -17
View File
@@ -18,7 +18,7 @@ import { addProposal, removeProposal } from "./proposalModel";
import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
import type { ProposalView } from "./trackChangesModel";
import { wordEditHunks, type ProposalView } from "./trackChangesModel";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedProposal {
@@ -122,7 +122,7 @@ export class ProposalController implements vscode.Disposable {
fp: Fingerprint,
replacement: string,
author: Provenance,
opts?: { turnId?: string; instruction?: string },
opts?: { turnId?: string; instruction?: string; granularity?: "block" | "single" },
): Promise<string | undefined> {
if (!this.isTracked(document)) return undefined;
if (this.guard.isReadOnly(this.keyOf(document))) return undefined;
@@ -138,9 +138,40 @@ export class ProposalController implements vscode.Disposable {
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
async acceptById(docPath: string, proposalId: string): Promise<boolean> {
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
return hit ? this.accept(hit.state, hit.proposal) : false;
return hit ? this.accept(hit.state, hit.proposal, opts) : false;
}
/**
* #46 (INV-42): accept EVERY pending proposal on a document in one gesture — a
* batched application of the existing `acceptById` seam, not a new mechanism.
* Block proposals take the INV-40 word-precise path automatically. Applied in
* DESCENDING anchor order so an earlier accept never invalidates a later one's
* offsets; proposals whose anchor can't resolve are SKIPPED (never force-applied)
* and counted. Returns the applied-vs-skipped tally for the caller to report.
*/
async acceptAllProposals(document: vscode.TextDocument): Promise<{ applied: number; skipped: number }> {
if (!this.isTracked(document)) return { applied: 0, skipped: 0 };
const state = this.ensureState(document);
state.artifact = this.store.load(state.docPath) ?? emptyArtifact(state.docPath);
const text = document.getText();
const items = state.artifact.proposals.map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const resolved = fp ? resolve(text, fp) : "orphaned";
return { id: p.id, start: resolved === "orphaned" ? null : resolved.start };
});
const resolvable = items
.filter((i): i is { id: string; start: number } => i.start !== null)
.sort((a, b) => b.start - a.start);
let applied = 0;
let skipped = items.length - resolvable.length; // orphans, skipped up front
for (const it of resolvable) {
// silent: one batch report stands in for N per-proposal warnings.
if (await this.acceptById(state.docPath, it.id, { silent: true })) applied++;
else skipped++;
}
return { applied, skipped };
}
/** Reject by proposal id (test-facing twin of the thread-menu gesture). */
rejectById(docPath: string, proposalId: string): boolean {
@@ -150,7 +181,7 @@ export class ProposalController implements vscode.Disposable {
return true;
}
private async accept(state: DocState, proposal: Proposal): Promise<boolean> {
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);
if (!document) return false;
@@ -158,22 +189,32 @@ export class ProposalController implements vscode.Disposable {
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved === "orphaned") {
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).",
);
// #46: accept-all suppresses per-proposal warnings (one batch report instead).
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).",
);
this.renderAll(document);
return false;
}
const range = new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end));
// No awaits between resolve and the seam call: document.version is current.
const ok = await this.attribution.applyAgentEdit(document, range, proposal.replacement, proposal.author, {
expectedVersion: document.version,
turnId: proposal.turnId,
});
// #47 (INV-40): a BLOCK proposal applies the whole block but attributes only
// the words Claude actually changed; a single proposal applies its whole range.
const ok =
proposal.granularity === "block"
? await this.acceptBlock(document, resolved, proposal)
: await this.attribution.applyAgentEdit(
document,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
proposal.replacement,
proposal.author,
// No awaits between resolve and the seam call: document.version is current.
{ expectedVersion: document.version, turnId: proposal.turnId },
);
if (!ok) {
void vscode.window.showWarningMessage(
"Cowriting: the editor rejected the accept — the proposal is still pending.",
);
if (!opts?.silent)
void vscode.window.showWarningMessage(
"Cowriting: the editor rejected the accept — the proposal is still pending.",
);
return false;
}
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
@@ -181,6 +222,41 @@ export class ProposalController implements vscode.Disposable {
return true;
}
/**
* #47 (INV-40): accept a BLOCK proposal — apply Claude's whole block, but
* attribute ONLY the runs Claude actually changed. The block is the DECISION
* unit; the word is the ATTRIBUTION unit. An intra-block word sub-diff
* (`wordEditHunks`, the raw engine INV-37 used, repurposed — un-anchored so the
* runs stay disjoint for a batch apply) yields the changed runs; each lands
* through the F4 seam (Claude-attributed), applied last-position-first so an
* earlier run's offsets stay valid under the later ones. Unchanged spans within
* the block are never touched, so their prior authorship stands. (Each run is
* one seam edit / one undo step — see the spec's deferred note on undo grouping.)
*/
private async acceptBlock(
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; // block already equals the proposal — nothing to attribute
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,
});
if (!ok) return false;
}
return true;
}
private reject(state: DocState, proposal: Proposal): void {
if (this.guard.isReadOnly(state.docPath)) return;
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
+2 -1
View File
@@ -12,7 +12,7 @@ export function addProposal(
fp: Fingerprint,
replacement: string,
author: Provenance,
opts?: { turnId?: string; instruction?: string },
opts?: { turnId?: string; instruction?: string; granularity?: "block" | "single" },
): { proposalId: string; anchorId: string } {
const anchorId = newId("a");
const proposalId = newId("pr");
@@ -25,6 +25,7 @@ export function addProposal(
createdAt: new Date().toISOString(),
...(opts?.turnId !== undefined ? { turnId: opts.turnId } : {}),
...(opts?.instruction !== undefined ? { instruction: opts.instruction } : {}),
...(opts?.granularity !== undefined ? { granularity: opts.granularity } : {}),
});
return { proposalId, anchorId };
}
+228 -11
View File
@@ -223,11 +223,27 @@ export interface EditHunk {
* never part of another hunk.
*/
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
return wordEditHunks(currentText, rewrittenText).map((h) =>
h.start === h.end ? anchorInsertion(h, currentText) : h,
);
}
/**
* The raw word-level diff underlying `diffToHunks`: disjoint, ordered EditHunks
* that exactly partition the change (a pure insertion stays ZERO-WIDTH at its
* offset — not anchored). `diffToHunks` adds anchoring on top so each hunk's F4
* fingerprint resolves; callers that apply hunks directly through the seam
* (INV-40's intra-block accept) want THESE raw, non-overlapping hunks instead —
* anchoring can grow an insertion to absorb a token an adjacent hunk also edits,
* which corrupts a batch apply. Pure, vscode-free, deterministic. Applying these
* right→left reconstructs `rewrittenText` exactly.
*/
export function wordEditHunks(currentText: string, rewrittenText: string): EditHunk[] {
const hunks: EditHunk[] = [];
let offset = 0;
let open: EditHunk | null = null;
const flush = () => {
if (open) hunks.push(open.start === open.end ? anchorInsertion(open, currentText) : open);
if (open) hunks.push(open);
open = null;
};
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
@@ -272,6 +288,115 @@ function anchorInsertion(hunk: EditHunk, text: string): EditHunk {
return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement };
}
type BlockAlignKind = "unchanged" | "changed" | "removed" | "added";
interface BlockAlignOp {
kind: BlockAlignKind;
ci?: number; // index into the current blocks
ni?: number; // index into the rewritten blocks
}
/**
* Align two block sequences by normalized key (jsdiff `diffArrays`), pairing an
* adjacent removed-then-added run element-wise into `changed` ops (the surplus
* staying `removed` / `added`) — the same pairing `diffBlocks` uses, but yielding
* index ops so the caller can read each side's source range. Pure.
*/
function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignOp[] {
const changes = diffArrays(
cur.map((b) => b.key),
next.map((b) => b.key),
);
const ops: BlockAlignOp[] = [];
let bi = 0; // current index
let ci = 0; // rewritten index
for (let n = 0; n < changes.length; n++) {
const ch = changes[n];
const count = ch.count ?? ch.value.length;
if (!ch.added && !ch.removed) {
for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", ci: bi++, ni: ci++ });
continue;
}
if (ch.removed) {
const nx = changes[n + 1];
const addCount = nx?.added ? (nx.count ?? nx.value.length) : 0;
const paired = Math.min(count, addCount);
for (let k = 0; k < paired; k++) ops.push({ kind: "changed", ci: bi++, ni: ci++ });
for (let k = paired; k < count; k++) ops.push({ kind: "removed", ci: bi++ });
for (let k = paired; k < addCount; k++) ops.push({ kind: "added", ni: ci++ });
if (nx?.added) n++;
continue;
}
for (let k = 0; k < count; k++) ops.push({ kind: "added", ni: ci++ });
}
return ops;
}
/**
* #47 (INV-39, §6.4): diff a whole-document rewrite into ONE EditHunk per CHANGED
* BLOCK — the unit a human reviews — rather than per word (which INV-37/`diffToHunks`
* did and this supersedes for document edits). Built by aligning both sides into
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
* as `diffBlocks`:
* - a CHANGED block (1:1 aligned, even when adjacent to other changed blocks) → a
* block-aligned hunk `[block.start, block.end)` → the rewritten block's raw
* text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23). So
* a copy-edit pass touching N consecutive paragraphs yields N proposals.
* - a run of block INSERTIONS / DELETIONS → one gap-span hunk covering the source
* between the bounding aligned blocks (unchanged OR changed — both are 1:1
* anchors) → the matching rewritten span (separators included), so
* reconstruction stays exact. A zero-width gap-span (insert at a seamless
* boundary) is anchored to an adjacent token (INV-41) so its F4 fingerprint
* resolves and it accepts.
* - unchanged blocks (same key AND same raw) → no hunk.
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
* is RETAINED as the intra-block sub-diff engine for word-precise accept
* attribution — INV-40). Applying all hunks right→left reconstructs `rewrittenText`
* exactly.
*/
export function diffToBlockHunks(currentText: string, rewrittenText: string): EditHunk[] {
const cur = splitBlocksWithRanges(currentText);
const next = splitBlocksWithRanges(rewrittenText);
const ops = alignBlocks(cur, next);
// Same key but different raw (whitespace/case) is still a real content change.
for (const op of ops) {
if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed";
}
// `changed` and `unchanged` are 1:1 anchors (both sides' offsets are known);
// `added`/`removed` have no counterpart and must be spanned together.
const isAnchor = (op: BlockAlignOp) => op.kind === "unchanged" || op.kind === "changed";
const hunks: EditHunk[] = [];
let i = 0;
while (i < ops.length) {
const op = ops[i];
if (op.kind === "unchanged") {
i++;
continue;
}
if (op.kind === "changed") {
const c = cur[op.ci!];
hunks.push({ start: c.start, end: c.end, replacement: next[op.ni!].raw });
i++;
continue;
}
// A maximal run of added/removed blocks → one gap-span hunk over the source
// between the bounding anchors (or the document edges), replaced with the
// matching rewritten span, so the separators reconstruct exactly.
let j = i;
while (j < ops.length && !isAnchor(ops[j])) j++;
const prev = i > 0 ? ops[i - 1] : null; // an anchor by construction
const after = j < ops.length ? ops[j] : null; // an anchor (or null at EOF)
const curStart = prev ? cur[prev.ci!].end : 0;
const curEnd = after ? cur[after.ci!].start : currentText.length;
const newStart = prev ? next[prev.ni!].end : 0;
const newEnd = after ? next[after.ni!].start : rewrittenText.length;
const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) };
hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk);
i = j;
}
hunks.sort((a, b) => a.start - b.start);
return hunks;
}
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
// other fences fall through to markdown-it's default (escaped <pre><code>).
@@ -316,6 +441,14 @@ function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
export interface RenderOptions {
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
render?: (src: string) => string;
/**
* #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes
* since that pin, the on-render is fully clean — no authorship coloring — so a
* pin reads as "this is my clean starting point". Distinct from a baseline
* advanced by a machine-landing (accept), which keeps its authorship coloring
* (F10 INV-33). Only consulted by `renderReview`.
*/
pinned?: boolean;
}
function defaultRender(src: string): string {
@@ -412,13 +545,33 @@ function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } |
: { cls: "cw-by-human", label: "You" };
}
// Markdown emphasis / code delimiters whose RUNS must never be split by an
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
// markdown-it's delimiter pairing (#33 CASE1).
const isDelimChar = (c: string): boolean => c === "*" || c === "_" || c === "~" || c === "`";
/**
* #33 (CASE1): a sentinel must not land STRICTLY INSIDE a delimiter run. If `at`
* sits between two identical delimiter chars, snap it to the run's start (a
* position outside the run) so the run stays intact. Delimiters are invisible once
* rendered, so snapping only shifts the colored boundary across markup, never over
* visible text.
*/
function clampOffDelimiterRun(raw: string, at: number): number {
if (at <= 0 || at >= raw.length) return at;
if (!(isDelimChar(raw[at]) && raw[at - 1] === raw[at])) return at;
let p = at;
while (p > 0 && raw[p - 1] === raw[at]) p--;
return p;
}
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
const inserts: { at: number; marker: string }[] = [];
for (const s of spans) {
const lo = Math.max(0, s.start - blockStart);
const hi = Math.min(raw.length, s.end - blockStart);
if (hi <= lo) continue;
const lo = clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart));
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
inserts.push({ at: lo, marker: SENT[s.author].open });
inserts.push({ at: hi, marker: SENT[s.author].close });
}
@@ -431,13 +584,69 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
return out;
}
/** Replace the rendered sentinels with author <span> tags. */
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = {
[SENT.claude.open]: { author: "claude", open: true },
[SENT.claude.close]: { author: "claude", open: false },
[SENT.human.open]: { author: "human", open: true },
[SENT.human.close]: { author: "human", open: false },
};
const ALL_SENTINELS = new RegExp(
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
"g",
);
/**
* #33: token-aware replacement of the rendered author sentinels with `cw-by-*`
* spans. A naive global string-replace (the old approach) could emit a span that
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
* emits the author span only around TEXT runs, CLOSING it before any `<tag>` and
* REOPENING it after, so a span is always well-nested within the inline elements
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
*/
function sentinelsToSpans(html: string): string {
return html
.split(SENT.claude.open).join('<span class="cw-by-claude">')
.split(SENT.claude.close).join("</span>")
.split(SENT.human.open).join('<span class="cw-by-human">')
.split(SENT.human.close).join("</span>");
const out: string[] = [];
let current: AuthorKind | null = null; // which author region we're inside
let spanOpen = false; // whether a <span> is currently open in `out`
const openSpan = () => {
if (current && !spanOpen) {
out.push(`<span class="cw-by-${current}">`);
spanOpen = true;
}
};
const closeSpan = () => {
if (spanOpen) {
out.push("</span>");
spanOpen = false;
}
};
for (let i = 0; i < html.length; i++) {
const ch = html[i];
const sentinel = SENTINEL_OF[ch];
if (sentinel) {
if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char
else {
closeSpan();
current = null;
}
continue;
}
if (ch === "<") {
// An HTML tag: never let an author span straddle it (text `<` is escaped to
// &lt;, so a raw `<` is always a real tag). Copy the tag verbatim, sentinel-free.
closeSpan();
const gt = html.indexOf(">", i);
const end = gt === -1 ? html.length - 1 : gt;
out.push(html.slice(i, end + 1).replace(ALL_SENTINELS, ""));
i = end;
continue;
}
openSpan();
out.push(ch);
}
closeSpan();
return out.join("");
}
/**
@@ -548,6 +757,14 @@ export function renderReview(
const render = opts.render ?? defaultRender;
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
// colorByAuthor for every block in this case; data-src mapping and any pending
// proposals (review actions, not annotations) are kept below. A baseline advanced
// by a machine-landing (accept) is ALSO zero-diff but is NOT pinned — it keeps
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically.
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
// Associate each resolved proposal with the current-side block index whose range
// it anchors into: the largest block with start <= anchorStart (the containing
@@ -578,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 ? colorByAuthor(raw, blk.start, authorSpans, 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));
+84 -22
View File
@@ -13,13 +13,18 @@ import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, diffToHunks, type BlockOp } from "./trackChangesModel";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult } from "./liveTurn";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
type EditTurn = (instruction: string, text: string) => Promise<EditTurnResult>;
/**
* 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: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
@@ -37,7 +42,8 @@ type ToolbarMsg =
| { type: "reject"; proposalId: string }
| { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number };
| { type: "askClaude"; scope: "selection"; start: number; end: number }
| { type: "acceptAll" };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
@@ -52,9 +58,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) => {
private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text);
return runEditTurn(instruction, text, opts);
};
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
@@ -67,6 +73,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly extensionUri: vscode.Uri,
private readonly attribution: AttributionController,
private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi,
) {
this.disposables.push(
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
@@ -82,10 +89,17 @@ export class TrackChangesPreviewController implements vscode.Disposable {
}
this.show(vscode.window.activeTextEditor?.document);
}),
// F11: document-scoped Ask-Claude (also reused by #42's gateway). Edits the
// active markdown doc; the rewrite is diffed into per-hunk F4 proposals.
vscode.commands.registerCommand("cowriting.editDocument", () => {
const doc = vscode.window.activeTextEditor?.document;
// F11: document-scoped Ask-Claude (also reused by #42's reach gateways).
// Edits a markdown doc; the rewrite is diffed into F4 proposals.
// #42 (INV-38): the editor/title/context (tab) entry passes the clicked
// tab's resource Uri — target THAT document, opening it if it isn't already
// an open buffer (mirrors showTrackChangesPreview's #41 resolution); the
// palette / keybinding / editor/context pass nothing → the active editor.
vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => {
const doc = uri
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
(await vscode.workspace.openTextDocument(uri))
: vscode.window.activeTextEditor?.document;
if (!doc || !this.isMarkdown(doc)) {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
return;
@@ -183,9 +197,29 @@ export class TrackChangesPreviewController implements vscode.Disposable {
const target: EditTarget =
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
void this.askClaude(document, target);
} else if (m?.type === "acceptAll") {
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
void this.acceptAll(document);
}
}
/**
* #46 (INV-42): apply every pending proposal on the document through the F4
* accept seam (orphan-skip) and report the applied-vs-skipped tally. No
* confirmation dialog — VS Code undo restores (parity with single accept).
* Public so the `cowriting.acceptAllProposals` command can reach it for the
* active doc (not only the webview button).
*/
async acceptAll(document: vscode.TextDocument): Promise<void> {
const { applied, skipped } = await this.proposals.acceptAllProposals(document);
this.refresh(document);
if (applied === 0 && skipped === 0) return;
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
void vscode.window.showInformationMessage(
`Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`,
);
}
/**
* 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
@@ -202,8 +236,24 @@ export class TrackChangesPreviewController implements vscode.Disposable {
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
() => this.runEditAndPropose(document, target, instruction),
{
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;
}
},
);
if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
@@ -219,16 +269,18 @@ export class TrackChangesPreviewController implements vscode.Disposable {
}
/**
* F11 (INV-35/37): run one host edit turn and record the result as F4
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
* block-union; a DOCUMENT rewrite is `diffToHunks`'d into one single-range
* proposal per changed hunk (reusing the F4 single-range model N times, no new
* model). Never mutates the document (INV-10). Returns the created proposal ids.
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
* Never mutates the document (INV-10). Returns the created proposal ids.
*/
async runEditAndPropose(
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,
@@ -238,17 +290,25 @@ 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);
const turn = await this.editTurn(instruction, selected, opts);
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);
const turn = await this.editTurn(instruction, full, opts);
const ids: string[] = [];
for (const h of diffToHunks(full, turn.replacement)) {
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) —
// not per word. Each is tagged `granularity:"block"` so accept reconciles
// attribution per word inside the block (INV-40).
for (const h of diffToBlockHunks(full, turn.replacement)) {
const fp = buildFingerprint(full, { start: h.start, end: h.end });
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { turnId, instruction });
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
turnId,
instruction,
granularity: "block",
});
if (id) ids.push(id);
}
return ids;
@@ -300,7 +360,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
void panel.webview.postMessage({
type: "render",
mode,
html: renderReview(baselineText, current, spans, proposals),
html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }),
epoch: this.epochLabel(baseline),
summary,
authorable,
@@ -376,6 +436,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
<button id="cw-pin" type="button" title="Pin the review baseline to now (clears the change-marks)">⌖ Pin baseline</button>
<button id="cw-ask" type="button" title="Ask Claude to edit (the selection if any, else the whole document)">✦ Ask Claude to Edit Document</button>
<button id="cw-acceptall" type="button" hidden title="Accept every pending Claude proposal on this document">✓✓ Accept all</button>
<span id="cw-epoch">Review</span>
<span id="cw-summary"></span>
<span id="cw-legend"></span>
@@ -426,6 +487,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
current,
this.attribution.spansFor(doc),
this.proposals.listProposals(doc),
{ pinned: baseline?.reason === "pinned" },
);
}
/** F10: current annotations mode for a panel (default on). */
+122
View File
@@ -0,0 +1,122 @@
/**
* 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);
}
+11 -8
View File
@@ -67,8 +67,10 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
// SLICE-3: Edit Document → a whole-document rewrite diffed into N F4 proposals.
test("runEditAndPropose(document) with a stubbed multi-hunk rewrite → N proposals matching the hunks (PUC-4, INV-37)", async () => {
// #47 (was INV-37): Edit Document now cuts at BLOCK granularity — two word
// changes in ONE paragraph are ONE block proposal (INV-39 supersedes INV-37's
// per-word cut). Full block coverage lives in f12Review.test.ts.
test("runEditAndPropose(document) — two word edits in one paragraph → ONE block proposal (PUC-4, INV-39)", async () => {
const { doc, key } = await freshDoc(
"docs/f11doc.md",
"# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n",
@@ -87,15 +89,16 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
await settle();
assert.strictEqual(ids.length, 2, "two changed words → two independent proposals");
assert.strictEqual(ids.length, 1, "two changed words in one block → ONE block proposal (INV-39)");
const views = api.proposalController.listProposals(doc);
assert.ok(
ids.every((id) => views.some((v) => v.id === id)),
"every returned proposal id is a live pending proposal",
const view = views.find((v) => v.id === ids[0]);
assert.ok(view, "the returned proposal id is a live pending proposal");
assert.strictEqual(
view!.replacement,
"The quick RED fox jumps over the lazy CAT.",
"the block proposal carries the whole rewritten paragraph",
);
const replacements = views.map((v) => v.replacement);
assert.ok(replacements.includes("RED") && replacements.includes("CAT"), "proposals carry the per-hunk replacements");
// 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;
+128
View File
@@ -0,0 +1,128 @@
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 && 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() };
}
// #46 SLICE-3 (accept): one "Accept all" gesture applies every pending proposal
// on the current document through the existing F4 accept seam (INV-42) — batched,
// re-anchor-safe (descending), orphan-skip + report. Host E2E, no LLM.
suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => {
// PUC-6: N pending → all applied, text replaced, proposals cleared.
test("acceptAll applies every pending proposal and reconstructs the document", async () => {
const original = "# All\n\nFirst para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
const rewrite = "# All\n\nFirst para ALPHA.\n\nSecond para BETA.\n\nThird para GAMMA.\n";
const { doc, key } = await freshDoc("docs/f12-all.md", original);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns");
await settle();
assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals");
// Simulate the toolbar "Accept all" button posting its intent.
ctl.receiveMessage(key, { type: "acceptAll" });
await settle();
await settle();
assert.strictEqual(doc.getText(), rewrite, "accept-all reconstructs the intended document");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "all proposals cleared");
});
// PUC-6 / INV-42: an orphaned proposal is skipped (not force-applied) and the
// resolvable ones still apply — reported via the {applied, skipped} tally.
test("acceptAllProposals skips an orphaned proposal and applies the rest (report)", async () => {
const original = "# Mix\n\nKeep alpha here.\n\nKeep gamma here.\n";
const rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n";
const { doc } = await freshDoc("docs/f12-orphan.md", original);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase");
await settle();
assert.strictEqual(ids.length, 2, "two pending proposals");
// Orphan the FIRST block's proposal by mangling its target text in the buffer.
const para = "Keep alpha here.";
const start = doc.getText().indexOf(para);
const edit = new vscode.WorkspaceEdit();
edit.replace(
doc.uri,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + para.length)),
"Totally different first paragraph now.",
);
assert.ok(await vscode.workspace.applyEdit(edit), "mangling edit applied");
await settle();
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
assert.strictEqual(applied, 1, "the still-resolvable proposal applied");
assert.strictEqual(skipped, 1, "the orphaned proposal was skipped, not mangled");
assert.ok(doc.getText().includes("Keep GAMMA here."), "the resolvable block landed");
assert.ok(
doc.getText().includes("Totally different first paragraph now."),
"the orphaned block kept the operator's text (never force-applied)",
);
assert.strictEqual(api.proposalController.listProposals(doc).length, 1, "the orphaned proposal remains pending");
});
// A single pending proposal still applies through the batch path (the button is
// hidden < 2 pending in the webview, but the command/seam handle any count).
test("acceptAllProposals with one pending proposal applies it", async () => {
const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({
replacement: "# One\n\nThe ONLY paragraph here.\n",
model: "sonnet",
sessionId: "e2e-f12-one",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase only");
await settle();
assert.strictEqual(ids.length, 1, "one pending proposal");
const { applied, skipped } = await api.proposalController.acceptAllProposals(doc);
assert.strictEqual(applied, 1);
assert.strictEqual(skipped, 0);
assert.strictEqual(doc.getText(), "# One\n\nThe ONLY paragraph here.\n");
});
// The command is registered + palette-guarded on markdown.
test("cowriting.acceptAllProposals is registered and palette-guarded on markdown", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.acceptAllProposals"), "command registered");
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.acceptAllProposals",
);
assert.ok(entry, "has a commandPalette entry");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
});
+134
View File
@@ -0,0 +1,134 @@
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<{ 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);
return { doc, key: uri.toString() };
}
function pkg(): any {
return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
}
function menu(id: string): Array<{ command: string; when?: string; group?: string }> {
return pkg().contributes.menus[id] ?? [];
}
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
// the editor TAB, selection-aware (selection → editSelection; no selection →
// editDocument), both markdown/authorable-gated, both routing through the single
// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we
// assert them directly; the tab-targeting behavior is exercised through the command.
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
const m = menu("editor/context");
const sel = m.find((e) => e.command === "cowriting.editSelection");
const doc = m.find((e) => e.command === "cowriting.editDocument");
assert.ok(sel, "editSelection is in editor/context");
assert.ok(doc, "editDocument is in editor/context");
assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection");
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection");
assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown");
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
});
// 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 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,
// not whatever editor happens to be active (mirrors #41's clicked-doc resolution).
test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => {
const api = await getApi();
const ctl = api.trackChangesPreviewController;
// Doc A is the active editor; Doc B is the "clicked tab" we pass by URI.
const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n");
const b = await freshDoc("docs/f12-tab.md", "# Tab\n\nThe tab target paragraph to rewrite.\n");
await vscode.window.showTextDocument(a.doc);
await settle();
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
const origInput = vscode.window.showInputBox;
(vscode.window as any).showInputBox = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
model: "sonnet",
sessionId: "e2e-f12-tab",
}));
try {
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle();
} finally {
(vscode.window as any).showInputBox = origInput;
}
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
assert.ok(api.proposalController.listProposals(b.doc).length >= 1, "tab doc B received the document-edit proposal(s)");
assert.strictEqual(
api.proposalController.listProposals(a.doc).length,
0,
"active doc A was NOT edited — editDocument honored the tab URI",
);
// INV-10: proposing never mutates the document.
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
});
// No URI arg (palette / keybinding) → fall back to the active editor.
test("editDocument() with no arg targets the active editor", async () => {
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n");
await vscode.window.showTextDocument(a.doc);
await settle();
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",
sessionId: "e2e-f12-noarg",
}));
try {
await vscode.commands.executeCommand("cowriting.editDocument");
await settle();
} finally {
(vscode.window as any).showInputBox = origInput;
}
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
});
});
+134
View File
@@ -0,0 +1,134 @@
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 && api?.proposalController, "exports controllers");
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;
}
// #47 SLICE-2 (review, P1): a document rewrite proposes ONE F4 proposal per
// CHANGED BLOCK (INV-39 supersedes INV-37's per-word cut), but accepting a block
// reconciles attribution at WORD granularity (INV-40 — block = decision unit,
// word = attribution unit). Host E2E, no LLM (the edit turn is stubbed).
suite("F12 SLICE-2 — block-granularity document proposals (#47, INV-39/40/41)", () => {
// PUC-4: M changed blocks → M proposals; unchanged blocks → none.
test("edits across two paragraphs → two proposals; the untouched paragraph yields none (INV-39)", async () => {
const doc = await freshDoc(
"docs/f12-multi.md",
"# Doc\n\nFirst paragraph alpha.\n\nSecond paragraph beta.\n\nThird paragraph gamma.\n",
);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({
replacement: "# Doc\n\nFirst paragraph ALPHA.\n\nSecond paragraph beta.\n\nThird paragraph GAMMA.\n",
model: "sonnet",
sessionId: "e2e-f12-multi",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the first/last nouns");
await settle();
assert.strictEqual(ids.length, 2, "two changed blocks → two proposals; the unchanged middle block → none");
// each proposal's anchor spans a whole block, and the replacement is that block's rewrite
const views = api.proposalController.listProposals(doc);
const replacements = views.map((v) => v.replacement).sort();
assert.deepStrictEqual(replacements, ["First paragraph ALPHA.", "Third paragraph GAMMA."]);
});
// PUC-4 + INV-23: a changed code fence is ONE atomic whole-fence proposal.
test("a changed code fence → one atomic proposal over the whole fence (INV-23)", async () => {
const doc = await freshDoc(
"docs/f12-fence.md",
"# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n",
);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({
replacement: "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n",
model: "sonnet",
sessionId: "e2e-f12-fence",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "bump a to 10");
await settle();
assert.strictEqual(ids.length, 1, "a changed fence is one atomic proposal");
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0])!;
assert.strictEqual(view.replacement, "```js\nconst a = 10;\nconst b = 2;\n```", "whole-fence replacement");
});
// PUC-5 / INV-40: accepting a block attributes ONLY the words Claude changed —
// unchanged words in the block are NOT swept into Claude's authorship.
test("accepting a block proposal attributes only the changed words to Claude (INV-40)", async () => {
const doc = await freshDoc("docs/f12-attr.md", "# T\n\nThe quick brown fox jumps lazily.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const key = api.proposalController.keyFor(doc);
ctl.setEditTurnForTest(async () => ({
replacement: "# T\n\nThe quick RED fox jumps SLOWLY.\n",
model: "sonnet",
sessionId: "e2e-f12-attr",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "change two words");
await settle();
assert.strictEqual(ids.length, 1, "one changed paragraph → one block proposal");
const ok = await api.proposalController.acceptById(key, ids[0]);
assert.ok(ok, "the block proposal accepts");
await settle();
assert.strictEqual(doc.getText(), "# T\n\nThe quick RED fox jumps SLOWLY.\n", "the whole block landed");
const agentTexts = api.attributionController
.getSpans(key)
.filter((s) => s.authorKind === "agent")
.map((s) => doc.getText().slice(s.range.start, s.range.end).trim())
.filter((t) => t.length > 0);
// Only the two words Claude actually changed are Claude-attributed.
const joined = agentTexts.join(" ");
assert.ok(joined.includes("RED"), "the changed word RED is Claude-attributed");
assert.ok(joined.includes("SLOWLY"), "the changed word SLOWLY is Claude-attributed");
assert.ok(!/\bquick\b/.test(joined), "the unchanged word 'quick' is NOT swept into Claude's authorship");
assert.ok(!/\bfox\b/.test(joined), "the unchanged word 'fox' is NOT swept into Claude's authorship");
});
// PUC-4 / INV-41: a block-insertion rewrite produces acceptable proposals and
// accepting them all reconstructs the intended document (no born-orphaned hunk).
test("a rewrite that inserts a paragraph → acceptable proposals; accept-all reaches the rewrite (INV-41)", async () => {
const original = "# Ins\n\nAlpha block.\n\nBeta block.\n";
const rewrite = "# Ins\n\nAlpha block.\n\nBrand new middle block.\n\nBeta block.\n";
const doc = await freshDoc("docs/f12-insert.md", original);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const key = api.proposalController.keyFor(doc);
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-insert" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "insert a paragraph");
await settle();
assert.ok(ids.length >= 1, "the insertion produced at least one proposal");
for (const id of ids) {
const ok = await api.proposalController.acceptById(key, id);
assert.ok(ok, `proposal ${id} is acceptable (not born-orphaned, INV-41)`);
await settle();
}
assert.strictEqual(doc.getText(), rewrite, "accepting all proposals reconstructs the intended rewrite");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
});
});
+83
View File
@@ -0,0 +1,83 @@
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");
assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)");
});
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");
});
});
+63
View File
@@ -0,0 +1,63 @@
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<{ 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() };
}
// #48 host E2E (no LLM): pinning the baseline leaves the review panel fully clean
// — no authorship coloring on unchanged blocks — while re-divergence brings the
// annotations back. The author colors are read from the on-state renderReview HTML.
suite("S48 — pin → fully clean review panel (host E2E, no LLM)", () => {
test("pin clears authorship coloring; a later edit brings annotations back", async () => {
const { doc, key } = await freshDoc("docs/s48pin.md", "# S48\n\nAn original baseline paragraph.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Type a paragraph → a human attribution span + author coloring in the on-state.
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "typed text is author-colored before the pin");
// Pin the baseline → zero diff → the panel must be fully clean.
ctl.receiveMessage(key, { type: "pinBaseline" });
await settle();
const pinned = ctl.renderHtmlFor(key);
assert.ok(!pinned.includes("cw-by-human"), "no human authorship coloring after pin");
assert.ok(!pinned.includes("cw-by-claude"), "no Claude authorship coloring after pin");
assert.ok(!pinned.includes("cw-add") && !pinned.includes("cw-del"), "no change marks after pin");
assert.match(pinned, /data-src-start/, "blocks still carry data-src offsets (INV-36 mapping kept)");
assert.ok(pinned.includes("freshly typed human paragraph"), "the body text is still rendered, just plain");
// Edit again → there are changes since the pinned baseline → annotations return.
const edit2 = new vscode.WorkspaceEdit();
edit2.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA second typed paragraph diverges again.\n");
assert.ok(await vscode.workspace.applyEdit(edit2), "second operator edit applied");
await settle();
assert.match(ctl.renderHtmlFor(key), /cw-by-human/, "authorship coloring returns once the doc diverges from the pin");
});
});
+41
View File
@@ -0,0 +1,41 @@
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,6 +3,7 @@ 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));
@@ -34,6 +35,14 @@ 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();
+59 -2
View File
@@ -1,5 +1,32 @@
import { describe, it, expect } from "vitest";
import { extractReplacement } from "../src/liveTurn";
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" };
}
},
};
}
describe("extractReplacement", () => {
it("returns plain text untouched", () => {
@@ -24,3 +51,33 @@ 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();
});
});
+172 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, test, expect } from "vitest";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => {
@@ -168,6 +168,45 @@ describe("colorByAuthor", () => {
expect(html).toContain('<span class="cw-by-human">hello</span>');
expect(html).toContain("world");
});
// #33: author-coloring must be sentinel-safe around markdown emphasis. These
// exercise the REAL markdown-it renderer (via renderReview on an unchanged doc),
// since the failure is in markdown-it's inline parsing / element nesting.
// CASE1 — a span boundary strictly inside a delimiter run must not split it.
test("#33 CASE1: a span boundary inside ** does not break emphasis parsing", () => {
const doc = "a**b**c";
const html = renderReview(doc, doc, [{ start: 0, end: 2, author: "human" }], []);
expect(html).toContain("<strong>b</strong>"); // emphasis still renders
expect(html).not.toContain("**"); // no raw delimiters left
expect(html).not.toContain("<em></em>"); // no stray empty emphasis (the parse-break symptom)
expect(html).toContain('class="cw-by-human"'); // coloring present
});
// CASE3 — a span boundary inside an emphasis run must color the text without
// misnesting span/element (the span is split at the element boundary).
test("#33 CASE3: a span boundary inside **bold** colors the text without misnesting", () => {
const doc = "**bold**";
const html = renderReview(doc, doc, [{ start: 0, end: 4, author: "human" }], []); // covers "**bo"
expect(html).toContain("<strong>");
expect(html).toContain('<span class="cw-by-human">bo</span>ld</strong>'); // span INSIDE strong, closed before "ld"
expect(html).not.toContain('cw-by-human"><strong>'); // NOT the old misnest (span wrapping the <strong> open)
});
// CASE2 — a span covering a whole emphasis run stays correct (regression).
test("#33 CASE2: a span over the whole **bold** colors it correctly", () => {
const doc = "**bold**";
const html = renderReview(doc, doc, [{ start: 0, end: 8, author: "human" }], []);
expect(html).toContain("<strong>");
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
expect(html).toContain("bold");
});
test("#33: no Private-Use-Area sentinel chars leak into the rendered output", () => {
const doc = "a**b**c and `co de` and _x_";
const spans: AuthorSpan[] = [
{ start: 0, end: 2, author: "human" },
{ start: 12, end: 16, author: "claude" },
];
const html = renderReview(doc, doc, spans, []);
expect(html).not.toMatch(/[\uE000-\uF8FF]/); // no leftover BMP Private-Use-Area sentinels
});
});
import { renderPlain } from "../src/trackChangesModel";
@@ -277,6 +316,54 @@ describe("renderReview", () => {
expect(uIdx).toBeGreaterThan(html.indexOf("Beta there")); // unanchored still trails the body
expect(html).toContain("cw-proposal-unanchored");
});
// #48: a PINNED baseline with zero changes leaves the panel fully un-annotated —
// no authorship coloring on unchanged blocks — while pending proposals still show.
test("renderReview: pinned + zero diff with author spans renders NO authorship coloring", () => {
const doc = "Human wrote this.\n\nClaude wrote that.";
const spans: AuthorSpan[] = [
{ start: 0, end: 17, author: "human" },
{ start: 19, end: doc.length, author: "claude" },
];
const html = renderReview(doc, doc, spans, [], { pinned: true });
expect(html).not.toContain("cw-by-human");
expect(html).not.toContain("cw-by-claude");
expect(html).not.toContain("cw-add");
expect(html).not.toContain("cw-del");
// still a selection→source surface (INV-36): blocks carry data-src offsets.
expect(html).toContain("data-src-start");
// the body text is still there, just plain.
expect(html).toContain("Human wrote this.");
expect(html).toContain("Claude wrote that.");
});
test("renderReview: pinned + zero diff still renders a pending proposal block", () => {
const doc = "Human wrote this.\n\nClaude wrote that.";
const spans: AuthorSpan[] = [{ start: 0, end: 17, author: "human" }];
const proposals: ProposalView[] = [
{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "Human", replacement: "Person" },
];
const html = renderReview(doc, doc, spans, proposals, { pinned: true });
expect(html).not.toContain("cw-by-human"); // body still clean
expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action)
expect(html).toContain("Person");
});
test("renderReview: zero diff WITHOUT a pin (e.g. machine-landing) keeps authorship coloring (INV-33)", () => {
// accepting a Claude edit advances the baseline (zero diff) but is NOT a pin —
// the landed author coloring must remain.
const doc = "Human wrote this.\n\nClaude wrote that.";
const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }];
const html = renderReview(doc, doc, spans, []); // no pinned flag
expect(html).toContain("cw-by-claude");
});
test("renderReview: with REAL changes since a pin, author coloring returns", () => {
// a genuine added block is still author-colored even when pinned (only the
// zero-diff-after-pin state is clean).
const baseline = "Hello world";
const current = "Hello world\n\nHello world";
const spans: AuthorSpan[] = [{ start: 19, end: 24, author: "human" }];
const html = renderReview(baseline, current, spans, [], { pinned: true });
expect((html.match(/cw-by-human/g) ?? []).length).toBe(1);
});
test("renderReview is deterministic with mixed anchored/unanchored proposals", () => {
const doc = "one two three";
const proposals: ProposalView[] = [
@@ -402,6 +489,90 @@ describe("F11 diffToHunks (INV-37)", () => {
});
});
// #47 SLICE-2 (INV-39): a whole-document rewrite is diffed into ONE EditHunk per
// CHANGED BLOCK (the unit a human reviews), superseding INV-37's per-word hunks.
// Built by coarsening diffToHunks to block boundaries; fences stay atomic
// (INV-23); inserted/deleted blocks keep their anchored gap hunks (INV-41).
// Applying all hunks right→left must still reconstruct the rewrite exactly.
describe("#47 diffToBlockHunks (INV-39/41)", () => {
const applyHunks = (current: string, hunks: ReturnType<typeof diffToBlockHunks>): string => {
let out = current;
for (const h of [...hunks].sort((a, b) => b.start - a.start)) {
out = out.slice(0, h.start) + h.replacement + out.slice(h.end);
}
return out;
};
test("an identical rewrite → zero hunks", () => {
expect(diffToBlockHunks("# H\n\nSame body.\n", "# H\n\nSame body.\n")).toEqual([]);
});
test("two word edits in ONE paragraph → ONE block hunk (supersedes INV-37's two)", () => {
const current = "# Doc\n\nThe quick brown fox jumps over the lazy dog.\n";
const rewrite = "# Doc\n\nThe quick RED fox jumps over the lazy CAT.\n";
const hunks = diffToBlockHunks(current, rewrite);
expect(hunks).toHaveLength(1);
// the hunk spans the whole changed paragraph block
const para = "The quick brown fox jumps over the lazy dog.";
const start = current.indexOf(para);
expect(hunks[0].start).toBe(start);
expect(hunks[0].end).toBe(start + para.length);
expect(hunks[0].replacement).toBe("The quick RED fox jumps over the lazy CAT.");
expect(applyHunks(current, hunks)).toBe(rewrite);
});
test("edits across two paragraphs → two block hunks, one per changed block; unchanged → none", () => {
const current = "First para alpha.\n\nSecond para beta.\n\nThird para gamma.\n";
const rewrite = "First para ALPHA.\n\nSecond para beta.\n\nThird para GAMMA.\n";
const hunks = diffToBlockHunks(current, rewrite);
expect(hunks).toHaveLength(2);
// each hunk lands on a real block boundary in current
const blocks = splitBlocksWithRanges(current);
for (const h of hunks) {
expect(blocks.some((b) => b.start === h.start && b.end === h.end)).toBe(true);
}
expect(applyHunks(current, hunks)).toBe(rewrite);
});
test("a changed code fence → ONE atomic whole-fence hunk (INV-23)", () => {
const current = "# Code\n\n```js\nconst a = 1;\nconst b = 2;\n```\n";
const rewrite = "# Code\n\n```js\nconst a = 10;\nconst b = 2;\n```\n";
const hunks = diffToBlockHunks(current, rewrite);
expect(hunks).toHaveLength(1);
const fence = "```js\nconst a = 1;\nconst b = 2;\n```";
const start = current.indexOf(fence);
expect(hunks[0].start).toBe(start);
expect(hunks[0].end).toBe(start + fence.length);
expect(hunks[0].replacement).toBe("```js\nconst a = 10;\nconst b = 2;\n```");
expect(applyHunks(current, hunks)).toBe(rewrite);
});
test("every hunk is resolvable (non-zero-width, real source text) and reconstructs", () => {
const cases: Array<[string, string]> = [
["# H\n\nThe brown fox sleeps.\n", "# H\n\nThe brown fox QUIETLY sleeps today.\n"], // insert words mid-block
["A para.\n\nB para.\n\nC para.\n", "A para.\n\nC para.\n"], // delete a whole block
["A para.\n\nB para.\n", "A para.\n\nNEW para.\n\nB para.\n"], // insert a whole block
["Only one block here.\n", "A totally different single block.\n"], // wholesale
["Keep me.\n\nDrop this one.\n", "Keep me.\n"], // delete trailing block
["unchanged body\n", "unchanged body\n"], // no-op
];
for (const [current, rewrite] of cases) {
const hunks = diffToBlockHunks(current, rewrite);
for (const h of hunks) {
expect(h.end).toBeGreaterThan(h.start); // non-zero-width → F4 fp resolves
expect(current.slice(h.start, h.end).length).toBeGreaterThan(0);
}
expect(applyHunks(current, hunks)).toBe(rewrite);
}
});
test("is deterministic — same inputs → identical hunks", () => {
const c = "P one.\n\nP two.\n";
const r = "P ONE.\n\nP two.\n";
expect(diffToBlockHunks(c, r)).toEqual(diffToBlockHunks(c, r));
});
});
// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end
// (source char offsets from BlockWithRange) on every LIVE-source rendered block,
// in BOTH modes. The webview's selection→source mapping walks the DOM to the
+102
View File
@@ -0,0 +1,102 @@
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");
});
});