7 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
36 changed files with 143 additions and 3167 deletions
File diff suppressed because it is too large Load Diff
-5
View File
@@ -89,11 +89,6 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
} }
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; } .cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; } .cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
.cw-btngroup { display: inline-flex; }
.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; }
.cw-actions .cw-accept { font-weight: 600; }
.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
/* F7.1 (#22) intra-diagram mermaid diff legend. */ /* F7.1 (#22) intra-diagram mermaid diff legend. */
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; } .cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
+3 -4
View File
@@ -94,14 +94,13 @@ acceptAllEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "acceptAll" }); vscodeApi.postMessage({ type: "acceptAll" });
}); });
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block. // F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
body.addEventListener("click", (e) => { body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button"); const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return; if (!btn) return;
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId; const block = btn.closest<HTMLElement>(".cw-proposal");
const id = block?.dataset.proposalId;
const action = btn.dataset.action; const action = btn.dataset.action;
if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" });
if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" });
if (id && (action === "accept" || action === "reject")) { if (id && (action === "accept" || action === "reject")) {
vscodeApi.postMessage({ type: action, proposalId: id }); vscodeApi.postMessage({ type: action, proposalId: id });
} }
+15 -52
View File
@@ -59,11 +59,6 @@
"title": "Apply Agent Edit (internal seam)", "title": "Apply Agent Edit (internal seam)",
"category": "Cowriting" "category": "Cowriting"
}, },
{
"command": "cowriting.edit",
"title": "Ask Claude to Edit",
"category": "Cowriting"
},
{ {
"command": "cowriting.editSelection", "command": "cowriting.editSelection",
"title": "Ask Claude to Edit Selection", "title": "Ask Claude to Edit Selection",
@@ -103,21 +98,6 @@
"command": "cowriting.acceptAllProposals", "command": "cowriting.acceptAllProposals",
"title": "Accept All Claude Proposals", "title": "Accept All Claude Proposals",
"category": "Cowriting" "category": "Cowriting"
},
{
"command": "cowriting.rejectAllProposals",
"title": "Reject All Claude Proposals",
"category": "Cowriting"
},
{
"command": "cowriting.proposalAcceptMenu",
"title": "Accept Claude Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.proposalRejectMenu",
"title": "Reject Claude Proposal",
"category": "Cowriting"
} }
], ],
"menus": { "menus": {
@@ -142,33 +122,13 @@
"command": "cowriting.pinDiffBaseline", "command": "cowriting.pinDiffBaseline",
"when": "editorLangId == markdown" "when": "editorLangId == markdown"
}, },
{
"command": "cowriting.edit",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.editSelection",
"when": "false"
},
{ {
"command": "cowriting.editDocument", "command": "cowriting.editDocument",
"when": "false" "when": "editorLangId == markdown"
}, },
{ {
"command": "cowriting.acceptAllProposals", "command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown" "when": "editorLangId == markdown"
},
{
"command": "cowriting.rejectAllProposals",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.proposalAcceptMenu",
"when": "false"
},
{
"command": "cowriting.proposalRejectMenu",
"when": "false"
} }
], ],
"editor/title": [ "editor/title": [
@@ -180,8 +140,13 @@
], ],
"editor/title/context": [ "editor/title/context": [
{ {
"command": "cowriting.edit", "command": "cowriting.editSelection",
"when": "resourceLangId == markdown", "when": "editorHasSelection && resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
"command": "cowriting.editDocument",
"when": "!editorHasSelection && resourceLangId == markdown",
"group": "1_cowriting@1" "group": "1_cowriting@1"
}, },
{ {
@@ -199,8 +164,13 @@
], ],
"editor/context": [ "editor/context": [
{ {
"command": "cowriting.edit", "command": "cowriting.editSelection",
"when": "editorLangId == markdown && (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" "group": "1_cowriting@1"
}, },
{ {
@@ -233,14 +203,7 @@
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.showTrackChangesPreview",
"key": "ctrl+alt+r", "key": "ctrl+alt+r",
"mac": "cmd+alt+r",
"when": "editorLangId == markdown" "when": "editorLangId == markdown"
},
{
"command": "cowriting.edit",
"key": "ctrl+alt+e",
"mac": "cmd+alt+e",
"when": "editorTextFocus && editorLangId == markdown"
} }
] ]
}, },
@@ -1,106 +0,0 @@
# Session 0056.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T04-24 (PST)
> End: 2026-06-26T04-54 (PST)
> Type: planning-and-executing
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md`
(Continuation of brainstorming session 0055, which graduated the spec; the
operator said "implement it" → this coding session.)
## Plan
Plan + execute **#60** (P1 feature — live turn progress) from the graduated
Solution Design `coauthoring-live-progress.md`. Four cuts: (1) pure
`turnProgress.ts` reducer + tests; (2) `runEditTurn` + `onProgress`/`AbortSignal`;
(3) `liveProgressUi` + setting + both call sites; (4) host-E2E + smoke.
Branch `s60-live-progress` → PR → main.
## Pre-state
- Clean `main` at session start (fast-forwarded 13 commits — the sessions/ repo
is the code repo itself). Baseline: 222 unit green.
- #60 has a graduated Solution Design → §4.3 R2(b) satisfied.
## Session arc
1. **Claim + plan.** Claimed 0056 (no in-flight). Wrote the implementation plan
with `superpowers:writing-plans``docs/superpowers/plans/2026-06-26-live-turn-progress.md`
(self-reviewed: full spec coverage, no placeholders). Grounded in the exact SDK
types (`AgentToolCallPart.toolName`, `AgentUsage.inputTokens+outputTokens`), the
vitest/host-E2E patterns, and the injectable `editTurn` seam. Branch
`s60-live-progress`.
2. **Task 1 — pure reducer.** `src/turnProgress.ts` + `test/turnProgress.test.ts`
(13 cases). Fixed the type-only event import: `AgentRuntimeEvent` is exported
from **`@cline/shared`**, not `@cline/sdk` (which doesn't re-export it) — caught
by typecheck; amended the commit.
3. **Task 2 — `runEditTurn`.** Added `RunEditTurnOptions {onProgress, signal}`;
subscribes to the agent, folds events through the reducer, wires `signal`
`agent.abort()`; `finally` unsubscribes. Mocked-agent unit tests (vitest
`doMock` intercepts the dynamic import).
4. **Task 3 — host relay.** `src/liveProgressUi.ts` (notification line +
"Cowriting: Claude" OutputChannel, reveal gated by setting) + `package.json`
`contributes.configuration` `cowriting.liveProgress.revealOutput`.
5. **Tasks 4+5 — both call sites.** `editSelection` (extension.ts) and preview
`askClaude`/`runEditAndPropose` (trackChangesPreview.ts) made cancellable,
relaying via the shared `liveProgressUi`; widened the `EditTurn` seam to accept
`opts?` (back-compat). Added `liveProgressUi` to `CowritingApi`.
6. **Task 6 — host E2E.** `test/e2e/suite/liveProgress.test.ts`: progress is
additive (INV-44), aborted turn proposes nothing (INV-47).
7. **Task 7 — verify + smoke.** typecheck + 237 unit + build green; enhanced
`scripts/smoke-live-turn.mjs` to log progress.
8. **Subagent review.** Found a **Medium** (a pre-aborted `AbortSignal` was a
no-op — `agent.abort()` runs before the SDK's AbortController exists, so the
turn ran to completion) + a **Low** (a throwing `onProgress` relay could fail
the turn). Fixed both: short-circuit (throw) before `run()` on a pre-aborted
signal; wrap the relay in try/catch. Strengthened the unit + E2E to prove it.
9. **Ship.** Pushed; opened PR #61; operator approved merge despite the
concurrency (below). Squash-merged `644885c`; issue #60 auto-closed.
## ⚠️ Concurrency event (mid-session)
Partway through, the shared working tree gained **uncommitted changes this session
did NOT make** — a `cowriting.edit` / `routeEdit` reach refactor across
`src/workspacePath.ts`, `test/workspacePath.test.ts`, and the menu/`routeEdit`
parts of `package.json` + `src/extension.ts`. It broke 3 E2E (`f12Reach` ×2,
`f11Toolbar` ×1) that still expect the old `editSelection`/`editDocument` command
surface. Handling: committed **nothing** of it, discarded **nothing**; verified
#60 green in isolation by stashing it (by path, reversible) — 237 unit + both #60
E2E pass, only the documented `F10 #38` undo-sandbox flake remains — then popped
the stash to restore it. Surfaced to the operator, who chose to merge #60 (its 7
commits are isolated; the PR diff contains none of the foreign WIP).
## Cut state (at finalize)
- **#60 merged** to main (PR #61 squash `644885c`); issue #60 closed.
- **Plan archived:** `submit-plan.sh` → content repo `plans/2026-06-26-live-turn-progress.md` (`22ec57e`).
- **Foreign refactor WIP:** still uncommitted in the working tree, untouched —
for whoever is doing it to continue/commit (it rebases onto the new main).
- **Local tree:** left on branch `s60-live-progress` (merged) DELIBERATELY — a
`checkout main`/`pull` would have disturbed the foreign uncommitted WIP. Local
main not synced; origin/main has #60. Operator should reconcile the local tree
+ the in-flight refactor.
- **Memory:** added `session-0056-60-live-progress-shipped.md` + index line.
## Next-session prompt
```
/wgl-planning-and-executing reconcile the local tree (sync main; land/branch the in-flight cowriting.edit/routeEdit refactor), then pick the next item (open: #59 P1 bug, #57, #58, #32, #35, #40, OQ-2)
```
## Deferred decisions
- **Left local on `s60-live-progress` (did not sync main):** to avoid disturbing
the concurrent uncommitted refactor in the shared tree. Low confidence this is
the tidiest end state, but it is the safest for the foreign WIP. Operator to
reconcile.
- **Token field for the activity line:** used `inputTokens + outputTokens` from
`AgentUsage` (no `totalTokens` field exists). Cache tokens excluded.
- **OutputChannel append-not-clear + auto-reveal gating:** per spec §3.5; carried
from session 0055's confirmed sub-decisions.
@@ -1,13 +1,13 @@
# Session 0061.0 — Transcript # Session 0056.0 — Transcript
> App: vscode-cowriting-plugin > App: vscode-cowriting-plugin
> Start: 2026-06-27T05-14 (PST) > Start: 2026-06-26T04-24 (PST)
> Type: planning-and-executing > Type: planning-and-executing
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.** > Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
> >
> This file reserves session ID 0061 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 > body with the full transcript and renames the file to its final
> SESSION-0061.0-TRANSCRIPT-2026-06-27T05-14--<end>.md form at session end. > SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--<end>.md form at session end.
## Launch prompt ## Launch prompt
@@ -1,111 +0,0 @@
# Session 0057.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T04-35 (PST)
> End: 2026-06-26T06-55 (PST)
> Type: executing-plans
> Posture: yolo
> Claude-Session: 3d66a467-8026-472d-9693-52a37939d493
> Goal: Operator-feedback UX fixes on the Ask-Claude-to-Edit experience (broken
> keybinding, missing/duplicated shortcuts, edit-box placement) — then iterate the
> edit-input UX to its final form.
> Outcome: Shipped two PRs to vscode-cowriting-plugin main (#62, #65); fixed and
> shipped a dev-plugin bug (#134 / PR #135, v0.60.0) surfaced mid-session.
## Plan
Operator opened with three asks on the Ask-Claude UX: the live/review-panel
shortcut didn't work, there was no shortcut for "Ask Claude to Edit
Selection/Document" (wanted one combined shortcut), and the edit box appeared in
a disliked spot (near the command palette) — with a request to recommend a
VS-Code-idiomatic placement. Routed as an executing-plans (yolo) session.
The session became an extended live iterate-and-verify loop on the edit-input UX,
driven by the operator testing each build in an Extension Development Host.
## Pre-session state
- On branch `s60-live-progress`; `#60` (live turn progress) work in flight.
- A **concurrent session (0056)** was live in the SAME working checkout, actively
committing/stashing `#60` — which clobbered this session's uncommitted edits
twice before isolation.
## Turn-by-turn arc
1. **Phase A (keybindings + command unify).** Added one `cowriting.edit` command
that routes selection-vs-document at runtime via a pure `routeEdit` helper
(unit-tested); hid the two underlying commands from the palette; collapsed the
split menus to one entry. Added `⌘⌥E`/`Ctrl+Alt+E` (edit) and `mac` variants
for `⌘⌥R`/`Ctrl+Alt+R` (review panel) — the missing `mac` variant was why the
panel shortcut "didn't work" (Option-key combos are unreliable on macOS).
2. **Concurrent-checkout incident + recovery.** Discovered session 0056 was
clobbering the shared tree (git stash/pop/commit out from under this session).
Exported the edits as a durable patch, created an **isolated git worktree**, and
moved all work there. (§5.4 lesson — should have isolated up front.)
3. **Input-box placement (design Q).** Recommended + implemented an inline input
via the Comments API; then iterated heavily per operator feedback:
inline-at-selection → top-of-document → **finally a multi-line webview in a
split pane below the document for BOTH scopes**. Each iteration was built and
verified live in the EDH.
4. **Dev-plugin bug #134 (the root cause of the clobber).** Filed plugin feedback
#134 (two sessions can share one working tree — isolation was prose, not a
deterministic guard), then implemented the fix: `claim-session-id.sh` now stamps
`> Checkout:` and **refuses a claim when another live session occupies the same
checkout** (self-session excepted; `WGL_ALLOW_SHARED_CHECKOUT=1` override).
Shipped as PR #135 / v0.60.0 and made live in the running install.
5. **Final edit-input form (PR #65).** Unified both scopes on the split-below
multi-line webview (`editInstructionInput.ts` / `promptEditInstruction`):
auto-focus, ⌘↵ send, Esc cancel-with-confirm-if-text, split collapses + focus
restored to the doc on close (no Output/Debug panel popping), selection stays
highlighted above. **Deleted the entire inline-comment mechanism** (`inlineAsk.ts`,
`cowriting.askClaude.submit/cancel`, their menus, the Escape keybinding) — net
deletion. Verified interactively, merged.
## Cut state (end of session)
| Repo | Change | Ref |
| --- | --- | --- |
| vscode-cowriting-plugin | Phase A: unify `cowriting.edit` + `routeEdit` + mac keybindings + inline Comments-API input | PR #62 → main (squash `9432300`) |
| vscode-cowriting-plugin | Final: split-below multi-line webview for both scopes; remove inline-comment mechanism | PR #65 → main (squash `7e42d11`) |
| wiggleverse-dev-claude-plugin | Deterministic shared-checkout guard in `claim-session-id.sh` + Step 6b docs; v0.60.0 | PR #135 → main (merge `ca383c4`); installed live |
| wiggleverse-dev-claude-plugin (tracker) | Filed + closed feedback #134 (shared-checkout hazard) | issue #134 `resolution:done` |
- All worktrees (`s60-edit-ux`, `edit-top-anchor`, dev-plugin `issue134`) removed;
branches deleted; all temporary EDH windows closed.
- 242 unit + typecheck (src + e2e) + build green on the final tip. E2E electron
suite not run (known env-broken locally); webview UX verified interactively.
- Local `main` is 3 behind `origin/main` (sessions 0058/0059 + PR #65 merge) —
cosmetic; a future session pulls.
- Untracked strays `docs/superpowers/plans/2026-06-26-live-turn-progress.md` and
`specs/` predate this session (from the `#60` line / a known `specs/` stray);
left untouched — not this session's to land.
## Deferred decisions
None logged. No silent low-confidence calls — every judgment call (worktree
recovery, each UX fork, cancel/placement/size trade-offs) was surfaced live and
decided by the operator or flagged as a hard VS Code API limit.
## What lands on the operator's plate
- Nothing blocking. The edit-input UX line is complete and shipped.
- The dev-plugin shared-checkout guard (#134/v0.60.0) is **live for new
sessions** (this session's running install was updated; new sessions get it on
start).
- Known VS Code API limits captured in memory ([[ask-claude-edit-input-ux]]): no
multi-line input at the command-palette location; no API to set an editor split
ratio; no API to read bottom-panel visibility.
## Prompt the operator can paste into the next session
This session's thread (Ask-Claude edit UX) is **complete** — there is no single
forced next step. Open backlog items the operator may choose from (each its own
session): `#32` scroll-sync (feature, needs design), `#35` repo rename, `#40` undo
provenance (P3), F11 spec graduation (OQ-2), content-repo draft reconciliation.
No `Next /goal:` is recorded — the next move is an operator pick from the backlog
above.
@@ -1,85 +0,0 @@
# Session 0058.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T05-12 (PST)
> End: 2026-06-26T05-58 (PST)
> Type: brainstorming
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
Operator opened by asking what was in the Gitea backlog, then directed a
brainstorming session for a new feature: *"We should also show the proposed change
in the Markdown file in the same way. The proposed change in the markdown file
should be human-editable, show the diff just like it would after the change is
completed in both the markdown file and the webview as the change is made. The
diff in both the markdown file and preview webview should be exactly as they will
be shown if the edit is accepted. Create a new working tree in a directory other
than main for this work."* — extended mid-session with: *"replace the checkbox
icon and 'x' icon on the proposed change as 'Accept' and 'Reject'. Each should
have an arrow next to them for a dropdown that is 'Accept All' or 'Reject All'.
One should be able to accept, reject, accept all, or reject all from either the
markdown file or the web preview file."*
## Pre-state
- Branch `main`, clean/pushed baseline (after FF). Latest shipped work: #60 live
turn progress (session 0056, PR #61). Session 0057 (a `cowriting.edit`/`routeEdit`
refactor) still **in-flight** sharing the checkout.
- Backlog at session start (8 open): #59 (P1 bug, Apple-Events prompt), #54/#57/#58
(P2), #32/#35/#40 (P3), #62 (untriaged).
## Turn-by-turn arc
1. **Backlog query** — listed the 8 open Gitea issues for `benstull/vscode-cowriting-plugin`
by priority; flagged #62 as the only untriaged item.
2. **Session routing** — operator's feature request was feature-shaped *and*
reversed a locked invariant (F10/INV-32 "clean editor"), so the session type
was ambiguous. Asked; operator chose **brainstorm a spec first**. Routed via
`wgl-brainstorming`; claimed session **0058**.
3. **Isolation** — session 0057 still in-flight on the shared checkout → created an
isolated worktree `vscode-cowriting-plugin-s58` on branch
`s58-inline-editor-diff` off `origin/main` (§5.4).
4. **Exploration** — dispatched an Explore agent that mapped five areas: webview
render engine (`trackChangesModel.ts`), F4 proposal model + seam, the F10
clean-editor decision (zero editor decorations remain), controllers/wiring, and
commands/toolbar. Key constraint surfaced: **VS Code text editors cannot host
editable phantom text** — anything editable in the editor is buffer content;
decorations only style existing text or inject *non-editable* before/after
content.
5. **Four design forks (AskUserQuestion, with ASCII previews):**
- Editor model → **optimistic apply + decorations**.
- Timing → **on proposal** (not live token-stream into the editor).
- Editor affordance → **CodeLens `Accept ▾ / Reject ▾` + QuickPick dropdown**.
- (Operator-added) Control parity → Accept/Reject/Accept-all/Reject-all from
both surfaces; ✓/✗ superseded.
6. **Design presented** section-by-section; operator approved with "keep going"
(nodded the two flagged sub-decisions: dedicated `EditorProposalController`;
allow save of pending text).
7. **Anchor + spec** — filed Feature **#64** (`type/feature`, `priority/P2`) since
no tracker anchor existed; wrote `specs/coauthoring-inline-editor-diff.md`
(INV-48..54, reversing INV-10/INV-32). Self-review clean. Operator **approved**.
## Cut state
- Spec **graduated** → content repo `vscode-cowriting-plugin-content` at `80d5d1a`
(`specs/coauthoring-inline-editor-diff.md`).
- Feature **#64** filed + labelled.
- Worktree `vscode-cowriting-plugin-s58` (branch `s58-inline-editor-diff`) stands
ready for the downstream planning-and-executing session; no code committed this
session (brainstorming output is the spec only).
- Memory updated: `f12-inline-editor-diff-spec-graduated.md` + MEMORY.md index.
## Deferred decisions
No autonomous low-confidence calls — every fork was decided live with the operator
via AskUserQuestion, and the spec was operator-approved before submission. (The
two design sub-decisions — dedicated controller, allow-save-of-pending — were
flagged in the presented design and nodded by the operator.)
## Next-session prompt
```
/goal Plan-and-execute #64 (F12 inline editable proposed-change diff in the Markdown editor) from specs/coauthoring-inline-editor-diff.md, in worktree vscode-cowriting-plugin-s58 (branch s58-inline-editor-diff); follow the spec's 5-slice Delivery Plan
```
@@ -1,101 +0,0 @@
# Session 0059.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T06-01 (PST)
> End: 2026-06-26T08-32 (PST)
> Type: planning-and-executing
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
Plan-and-execute #64 (F12 inline editable proposed-change diff in the Markdown
editor) from the graduated spec `specs/coauthoring-inline-editor-diff.md`, in
isolated worktree `vscode-cowriting-plugin-s58` (branch `s58-inline-editor-diff`),
opened straight off brainstorming session 0058.
## Plan
Implement #64 per spec: optimistic-apply the proposed change into the editor
buffer (editable, tinted insertions + struck-red deletion hints), accept =
finalize-in-place / reject = revert-in-place, `Accept ▾`/`Reject ▾` (+ Accept-all
/Reject-all) from both editor (CodeLens+QuickPick) and webview. Reverses
INV-10/INV-32; INV-48..54. Implementation plan:
`docs/superpowers/plans/2026-06-26-inline-editor-diff.md`.
## Outcome — SHIPPED
#64 (F12) **shipped to main** — PR #66 squash `7b98249`, issue auto-closed.
Worktree built, subagent-driven (fresh implementer per task + two-stage review),
adversarial final review, then merged through the concurrent #65 landing.
## Turn-by-turn arc
1. **Init** — claimed session 0059 (0057 still in-flight; isolated worktree made
it §5.4-safe). Synced worktree, `npm ci`, green baseline (242 unit).
2. **Plan** — read the whole subsystem (proposalController, trackChangesModel,
attributionController, trackChangesPreview, preview.ts/css, extension.ts,
package.json, E2E harness); wrote an 8-task TDD plan. Surfaced a **spec
refinement**: optimistic apply must re-anchor the proposal to the applied text
+ store `Proposal.original` (F4's `fp.text` is the original, gone once applied).
3. **Execute (subagent-driven, sonnet implementers):**
- T1 `Proposal.original` (model) — `bea9fd5`.
- T2 `setProposalApplied` re-anchor helper — `92ff4dd`.
- T3 seam `landBaseline` opt + `signalLanded``6d54963`. **Surfaced the
host-E2E was blocked by a macOS UNIX-socket-length `EINVAL`** (the long
worktree path). Fixed by pinning `--user-data-dir` to a short `/tmp` path
(`930b4ab`) → host-E2E now launches from a worktree.
- T4 `optimisticApply`/`finalizeInPlace`/`revertInPlace`/`rejectAll`
`ddbbb7a` (subagent caught a real `keyOf` vs `uri.toString()` test bug).
- T5 `EditorProposalController` + pure `decorationPlan``8c6e782`+`e599284`
(subagent added a setTimeout(0) apply-debounce for the batch race; updated 5
existing E2E for the INV-10 reversal — verified legitimate, not weakened).
- T6 render-once INV-50 reconciliation — `6529332`; I added a coloring-
alignment fix (`landedSpans` via `toLanded`) the subagent's note flagged —
`3805323`.
- T7 webview `Accept ▾`/`Reject ▾` + dropdown + `rejectAllProposals``f4594da`.
4. **Adversarial final review (opus subagent)** — found a **CRITICAL** (revert
target clobbered on save+reload, in-memory `applied` empty post-reload) and a
MINOR (toolbar summary double-counts). Both fixed + a reload-safety regression
E2E — `6560894`. Verdict invariants INV-48..54 upheld; decorationPlan alignment
verified correct.
5. **Concurrency** — session 0057 had merged **#65** (unify Ask-Claude on a
split-below webview; removed `InlineAskController`) to main mid-session. Merged
origin/main into the branch, resolved the one `extension.ts` conflict (drop
inlineAsk, keep `EditorProposalController`); suite green; squash-merged PR #66.
6. **Hygiene** — a `git add -A` during the merge accidentally tracked the spec
copy in the code repo; removed it (PR #67 `844a278`) — specs are canonical in
the content repo. Plan archived (`8c14f54`); spec patched to **v0.1.1** with the
re-anchor refinement and submitted (`43913dc`).
## Cut state
- **main @ `844a278`** (clean, pushed). #64 closed.
- Tests: **249 unit + 87/5 host-E2E green**, typecheck clean.
- Content repo: spec v0.1.1 (`43913dc`) + plan (`8c14f54`).
- Worktree removed.
## Deferred decisions
- **Re-anchor on optimistic apply (spec refinement, RESOLVED into v0.1.1).** Added
`Proposal.original` + re-fingerprint to the applied text; captured once →
reload-safe. Folded into the spec.
- **`finalizeInPlace` has no orphan re-resolve guard** (CodeLens Accept), unlike
the webview `acceptById`. Intentional — spec §2.2 says Accept keeps the human's
in-place edits (which would orphan the exact-text fp) — but it's asymmetric. Left
as-is; noted as a follow-up nit.
- **Per-proposal webview carets always show Accept-all/Reject-all** even at 1
pending (the toolbar button correctly hides <2). Cosmetic; not fixed.
## Not done
- **Manual smoke (interactive F5)** — the live editor diff, CodeLens dropdown,
both-surface parity, and save-while-pending need an Extension Development Host
run (operator-side). No flotilla/PPE for this app — the PR-merge to main is the
ship.
## Next-session prompt
```
/goal Manually smoke-test #64 (F12 inline editor diff) in an Extension Development Host (F5) — verify the live editable diff + deletion hints, the Accept ▾/Reject ▾ CodeLens dropdown, editor↔webview parity, and save-while-pending — then start the next backlog item; #59 (P1, macOS Apple-Events prompt, likely @cline/sdk) is highest priority
```
@@ -1,124 +0,0 @@
# Session 0060.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T12-52 (PST)
> End: 2026-06-26T13-14 (PST)
> Type: planning-and-executing
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/goal next` — resume the stored next-goal from session 0059's finalize:
"Manually smoke-test #64 in an Extension Development Host (F5), then pick the
next backlog item — #59 (P1 Apple-Events prompt) is highest priority."
The #64 F5 smoke is an interactive operator gesture (flagged, not performed by
the session). The autonomous substantive work was **fixing #59** — a standalone
leaf `type/bug` (P1), eligible for plan-and-execute under §4.3 R2(b) with no
design required.
## Plan
Fix #59: the plugin triggers a spurious macOS "control other applications"
(Apple Events / Automation TCC) permission prompt; works fine when declined.
Root-cause via systematic-debugging, fix via TDD, ship through branch → PR →
merge to main.
## Turn-by-turn arc
1. **Session init.** Classified `/goal next` → planning-and-executing; claimed
session 0060 (`claim-session-id.sh`, no other sessions in flight). Baseline:
pulled `main` (was 1 behind), clean apart from two pre-existing stray
untracked files (`specs/coauthoring-live-progress.md`,
`docs/superpowers/plans/2026-06-26-live-turn-progress.md` — canonical copies
live in the content repo; left untouched).
2. **Read #59** (Gitea API). P1, type/bug. Lead in the issue: not the extension's
own code; probable origin `@cline/sdk` agent activation / Claude-Code
hub-discovery, which falls back gracefully when denied.
3. **Root-cause investigation (systematic-debugging Phase 12).**
- The extension's own code and the **entire JS dependency tree** contain no
osascript/AppleScript (only the build-only `vite`). The Apple Event is sent
by a spawned subprocess.
- Traced the spawn chain: `src/liveTurn.ts` `new sdk.Agent({providerId:
"claude-code"})` → `@cline/sdk` → `@cline/llms` `createClaudeCodeProviderModule`
`ai-sdk-provider-claude-code` `createClaudeCode(options)` → `@anthropic-ai/
claude-agent-sdk` `query()` → **spawns the bundled `claude` CLI**
(`@anthropic-ai/claude-agent-sdk-darwin-arm64/claude`) headless
(`--print --output-format stream-json`).
- `runEditTurn` (liveTurn.ts:63) is the **sole** agent-spawn site (`cline.ts`
only reads the static tool catalog).
- Binary forensics: the `claude` binary contains **26 osascript calls** incl.
`tell application "Terminal"/"iTerm"/"Electron"/"Finder"/"System Events"`.
Categorized all 26 — every real call is **user-action-gated** (clipboard
image, screenshot, `Apple_Terminal`-only theme read, notifications,
terminal-setup, Claude-Desktop deep link via `tell application "Electron"`,
browser-default-handler detection). The only path that fires **without user
action** when `claude` is launched inside the VS Code extension host is
**IDE auto-connect** (osascripts to identify/activate the editor). We never
use the IDE connection → declining is harmless = exact symptom.
- Gate confirmed: `if(!((autoConnectIde||…||CLAUDE_CODE_SSE_PORT||…||
CH(CLAUDE_CODE_AUTO_CONNECT_IDE)) && !mK(CLAUDE_CODE_AUTO_CONNECT_IDE))) return;`
with `mK("0")===true``CLAUDE_CODE_AUTO_CONNECT_IDE=0` hard-disables it.
4. **Fix design (verified end-to-end, statically).** Pass `options.env` to the
Agent. The provider merges `{...getBaseProcessEnv(), ...settings.env}` and the
agent-SDK spawns the child with `env = L4 ? {...L4} : {...process.env}`. Seeding
`options.env` with the full `process.env` keeps default inheritance intact
(login under HOME, proxy/CA vars) and adds only `CLAUDE_CODE_AUTO_CONNECT_IDE=0`
(+ `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1`, belt-and-suspenders for the
extension-install deep link). `options` type is `Record<string,unknown>` — no
cast.
5. **TDD.** RED: unit test asserts `runEditTurn` constructs the Agent with both
switches set and the rest of `process.env` preserved (fake Agent captures
`cfg`). Watched it fail (`env` undefined). GREEN: added `options.env` to the
Agent constructor in `liveTurn.ts`. 250 unit + typecheck + build green.
6. **Empirical live-turn smoke (`scripts/smoke-live-turn.mjs` + osascript shim)
was DECLINED** by the operator (it makes a live network/auth'd agent call).
Env-preservation rests on the static chain proof + the unit test instead.
7. **Ship.** Branch `s60-59-apple-events-prompt` → commit → push → **PR #68**
squash-merge to main (`e671c4b`), branch deleted, **#59 auto-closed**.
Post-merge: 250 unit green on fresh main.
## Cut state
- **#59 SHIPPED** to main (squash `e671c4b`, PR #68; issue closed).
- `src/liveTurn.ts` (+19) and `test/liveTurn.test.ts` (+38) only.
- 250 unit + typecheck + build green; E2E unaffected (it stubs the turn, never
reaching the real spawn path).
- No implementation-plan artifact (small leaf bug) → nothing to archive in
`plans/`. No deploy pipeline stage (VS Code extension, no flotilla/PPE) →
change ships at merge.
## Deferred decisions
- **Did not run the real-turn empirical smoke** (operator declined the live
agent call). Alternative: run it with an osascript shim to observe the trigger
directly. Why proceeded: the env→spawn chain and gate semantics are proven by
static analysis, the plumbing is unit-tested, and the definitive confirmation
(fresh-TCC GUI check) is inherently an operator gesture per the issue's
acceptance — so the smoke was not load-bearing for shipping.
- **Set both `AUTO_CONNECT_IDE=0` and `IDE_SKIP_AUTO_INSTALL=1`** rather than the
single minimal switch. Alternative: auto-connect=0 alone (likely sufficient,
since auto-install rides the auto-connect flow). Why both: cheap, explicit,
both are no-ops outside an IDE; defense-in-depth against a separately-gated
install path.
## Operator plate (next session)
1. **Operator F5 smoke of #64** (F12 inline editor diff) — still pending from 0059.
2. **Fresh-TCC GUI smoke of #59** — reset macOS Automation TCC for the host app,
exercise a Claude edit, confirm the "control other applications" prompt is
gone and the edit still works.
3. Then a backlog item — **#58** (P2 story, self-contained) is the most actionable.
## Next-session prompt
```
/goal Operator-smoke #64 (F5) + fresh-TCC GUI smoke of #59, then plan-and-execute #58 (proposal accept/decline word-level diff — route proposalBlockHtml through wordMergedMarkdown)
```
-15
View File
@@ -166,20 +166,5 @@
}, },
"0056": { "0056": {
"title": "" "title": ""
},
"0057": {
"title": ""
},
"0058": {
"title": ""
},
"0059": {
"title": ""
},
"0060": {
"title": ""
},
"0061": {
"title": ""
} }
} }
+5 -15
View File
@@ -249,7 +249,7 @@ export class AttributionController implements vscode.Disposable {
range: vscode.Range, range: vscode.Range,
newText: string, newText: string,
provenance: Provenance, provenance: Provenance,
opts?: { expectedVersion?: number; turnId?: string; landBaseline?: boolean }, opts?: { expectedVersion?: number; turnId?: string },
): Promise<boolean> { ): Promise<boolean> {
if (!this.isTracked(document)) return false; if (!this.isTracked(document)) return false;
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false; if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
@@ -290,25 +290,15 @@ export class AttributionController implements vscode.Disposable {
"(host minimized differently?) — the edit may be mis-attributed (INV-9).", "(host minimized differently?) — the edit may be mis-attributed (INV-9).",
); );
} }
if (ok && opts?.landBaseline !== false) { if (ok) {
// F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48) // F6 (INV-18): a real machine landing — signal the baseline to advance so
// suppresses this for optimistic apply: the proposed text is in the buffer // this text never shows as a change in the diff view. Fire regardless of
// but the change stays PENDING (baseline at pre-proposal) until accept. // attribution-match bookkeeping above; the landing happened either way.
this.applyEmitter.fire({ document }); this.applyEmitter.fire({ document });
} }
return ok; return ok;
} }
/**
* F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text used
* by finalize-in-place, where the proposed text already landed in the buffer via
* optimistic apply (`landBaseline:false`) and accept only needs to advance the
* F6 baseline so the now-accepted change stops reading as pending.
*/
signalLanded(document: vscode.TextDocument): void {
if (this.isTracked(document)) this.applyEmitter.fire({ document });
}
// ---- PUC-4: persistence on save ---------------------------------------------------- // ---- PUC-4: persistence on save ----------------------------------------------------
private onDidSave(document: vscode.TextDocument): void { private onDidSave(document: vscode.TextDocument): void {
-147
View File
@@ -1,147 +0,0 @@
import * as vscode from "vscode";
import { randomBytes } from "crypto";
/**
* The multi-line instruction input for "Ask Claude to Edit" BOTH the selection
* and the whole-document case. A small focused webview (a tall, resizable
* textarea + Send) opened in a split pane BELOW the document not a comment
* thread, and not the top QuickInput (which is single-line only; VS Code has no
* multi-line input at the command-palette location). For a selection edit the
* document stays in the pane above with the selection still highlighted (VS
* Code's inactive-selection style), so the user can see exactly what Claude will
* edit; the caller has already captured the selection, so we never touch it.
*
* The split collapses when the input is submitted or cancelled, and focus is
* handed back to the document so the collapsing split doesn't reveal the bottom
* panel. `header` names the scope ("Ask Claude to Edit This Selection" /
* "…This Document").
*
* The webview only collects text and posts it to the host no SDK or secret
* surface lives in it (INV-8/35); the sealed CSP allows no network. Because a
* webview CAN read its own textarea, Cancel / Escape confirms ONLY when there is
* text to lose (closing the tab is an explicit dismiss, no prompt). Resolves with
* the typed instruction, or `undefined` if cancelled / closed / left empty.
*/
export async function promptEditInstruction(header: string): Promise<string | undefined> {
// Remember the document editor we came from so we can hand focus back to it
// when the input closes — otherwise, when the empty split group below collapses,
// focus falls into the bottom panel (Output / Debug Console / …) and it pops
// open. Restoring editor focus leaves whatever panel state the user had untouched.
const source = vscode.window.activeTextEditor;
// Open a split editor group BELOW the current one and host the input there, so
// it sits under the document instead of covering it as a tab. The new group is
// empty, so disposing the panel on submit/cancel leaves it empty and VS Code
// collapses the split (the `workbench.editor.closeEmptyGroups` default). Falls
// back to a tab in the active group if the split command is unavailable.
try {
await vscode.commands.executeCommand("workbench.action.newGroupBelow");
} catch {
/* no split — the panel opens as a tab in the active group instead */
}
return new Promise((resolve) => {
const panel = vscode.window.createWebviewPanel(
"cowriting.askClaudeInput",
header,
{ viewColumn: vscode.ViewColumn.Active, preserveFocus: false },
{ enableScripts: true, retainContextWhenHidden: false },
);
let settled = false;
const done = (value: string | undefined): void => {
if (settled) return;
settled = true;
resolve(value);
panel.dispose();
// Hand focus back to the originating document so the collapsing split
// doesn't leave focus in (and reveal) the bottom panel.
if (source) {
void vscode.window.showTextDocument(source.document, {
viewColumn: source.viewColumn ?? vscode.ViewColumn.One,
preserveFocus: false,
});
}
};
panel.webview.onDidReceiveMessage((m: { type?: string; text?: string }) => {
const text = (m?.text ?? "").trim();
if (m?.type === "submit") {
done(text ? text : undefined);
} else if (m?.type === "cancel") {
// Confirm only if there's something to lose (we can read the textarea here).
if (!text) {
done(undefined);
return;
}
void vscode.window
.showWarningMessage("Discard your Ask-Claude instruction?", { modal: true }, "Discard")
.then((pick) => {
if (pick === "Discard") done(undefined);
// else: leave the panel open so the operator can keep editing.
});
}
});
// Closing the tab is an explicit dismiss — cancel without a prompt.
panel.onDidDispose(() => done(undefined));
panel.webview.html = htmlFor(header);
});
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
}
function htmlFor(header: string): string {
const nonce = randomBytes(16).toString("base64");
// Sealed CSP: no network; inline style only; the one script is nonce-gated.
const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';`;
const title = escapeHtml(header);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<title>${title}</title>
<style>
body { padding: 14px 16px; font-family: var(--vscode-font-family); color: var(--vscode-foreground); }
h2 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
textarea {
width: 100%; min-height: 160px; resize: vertical; box-sizing: border-box;
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 8px;
font-family: var(--vscode-editor-font-family); font-size: var(--vscode-editor-font-size); line-height: 1.4;
}
textarea::placeholder { color: var(--vscode-input-placeholderForeground); }
textarea:focus { outline: 1px solid var(--vscode-focusBorder); border-color: var(--vscode-focusBorder); }
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; gap: 12px; }
.hint { color: var(--vscode-descriptionForeground); font-size: 12px; }
button {
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px;
}
button:hover { background: var(--vscode-button-hoverBackground); }
</style>
</head>
<body>
<h2>${title}</h2>
<textarea id="inst" placeholder="e.g. tighten the intro, add a conclusion, and fix the heading levels" autofocus></textarea>
<div class="row">
<span class="hint"> / Ctrl+ to send · Esc to cancel</span>
<button id="send">Send to Claude</button>
</div>
<script nonce="${nonce}">
const api = acquireVsCodeApi();
const ta = document.getElementById('inst');
const focus = () => ta.focus();
focus();
window.addEventListener('focus', focus);
const submit = () => api.postMessage({ type: 'submit', text: ta.value });
const cancel = () => api.postMessage({ type: 'cancel', text: ta.value });
document.getElementById('send').addEventListener('click', submit);
ta.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
</script>
</body>
</html>`;
}
-151
View File
@@ -1,151 +0,0 @@
/**
* EditorProposalController F12/#64 editor surface (spec coauthoring-inline-editor-diff
* §3.5). Reverses INV-32 for pending proposals: on `onDidChangeProposals` it
* (1) optimistically applies any not-yet-applied proposal into the active editor's
* buffer (ProposalController.optimisticApply, INV-48), (2) decorates each applied
* proposal insertion tint over the proposed text + a non-editable struck-red hint
* for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens
* `Accept ▾ / Reject ▾` above each block whose opens a QuickPick (this / all).
* Owns no proposal STATE it is a view over ProposalController (which stays the
* pure F4 owner). A document with no pending proposals shows nothing (INV-32's
* spirit when nothing is pending).
*/
import * as vscode from "vscode";
import type { ProposalController } from "./proposalController";
import { decorationPlan } from "./trackChangesModel";
import { isAuthorable } from "./workspacePath";
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
private readonly disposables: vscode.Disposable[] = [];
private readonly insertionDeco = vscode.window.createTextEditorDecorationType({
backgroundColor: new vscode.ThemeColor("diffEditor.insertedTextBackground"),
});
private readonly deletionDeco = vscode.window.createTextEditorDecorationType({
// a non-editable struck-red hint injected AFTER the insertion (INV-52)
after: { color: new vscode.ThemeColor("gitDecoration.deletedResourceForeground") },
textDecoration: "none",
});
private readonly lensEmitter = new vscode.EventEmitter<void>();
readonly onDidChangeCodeLenses = this.lensEmitter.event;
/** Pending debounce timers one per URI coalesce rapid-fire propose events
* (e.g. runEditAndPropose's N sequential propose() calls) into a single
* optimistic-apply pass that runs after ALL proposals are created. Without this,
* each propose() fires onDidChangeProposals synchronously and the controller's
* optimisticApply runs concurrently with the still-in-progress propose loop,
* causing "file changed in the meantime" workspace-edit conflicts. */
private readonly pendingApply = new Map<string, ReturnType<typeof setTimeout>>();
constructor(private readonly proposals: ProposalController) {
this.disposables.push(
this.insertionDeco, this.deletionDeco, this.lensEmitter,
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
// the four QuickPick-backed menu commands
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
);
}
/** Debounce-schedule an optimistic-apply pass for the given URI. Multiple rapid
* onDidChangeProposals events (from a single runEditAndPropose batch) collapse
* into one pass that runs after the batch completes. */
private scheduleApply(uri: string): void {
const prev = this.pendingApply.get(uri);
if (prev !== undefined) clearTimeout(prev);
this.pendingApply.set(
uri,
setTimeout(() => {
this.pendingApply.delete(uri);
void this.onProposalsChanged(uri);
}, 0),
);
}
/** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */
private async onProposalsChanged(uri: string): Promise<void> {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return;
const key = this.proposals.keyFor(doc);
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
await this.proposals.optimisticApply(doc, v.id); // fires onDidChangeProposals again; guarded by isApplied
}
}
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
if (ed) this.renderEditor(ed);
this.lensEmitter.fire();
}
/** Decorate the editor for every applied proposal on its document (INV-52). */
private renderEditor(editor: vscode.TextEditor): void {
const doc = editor.document;
if (doc.languageId !== "markdown") {
editor.setDecorations(this.insertionDeco, []);
editor.setDecorations(this.deletionDeco, []);
return;
}
const key = this.proposals.keyFor(doc);
const insertions: vscode.Range[] = [];
const deletions: vscode.DecorationOptions[] = [];
for (const v of this.proposals.listProposals(doc)) {
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
for (const ins of plan.insertions) {
insertions.push(new vscode.Range(doc.positionAt(ins.start), doc.positionAt(ins.end)));
}
for (const del of plan.deletions) {
deletions.push({
range: new vscode.Range(doc.positionAt(del.at), doc.positionAt(del.at)),
renderOptions: { after: { contentText: ` ${del.text} `, textDecoration: "line-through" } },
});
}
}
editor.setDecorations(this.insertionDeco, insertions);
editor.setDecorations(this.deletionDeco, deletions);
}
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
if (document.languageId !== "markdown") return [];
const key = this.proposals.keyFor(document);
const lenses: vscode.CodeLens[] = [];
for (const v of this.proposals.listProposals(document)) {
if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue;
const pos = document.positionAt(v.anchorStart);
const line = new vscode.Range(pos.line, 0, pos.line, 0);
lenses.push(
new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }),
new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", arguments: [v.id] }),
);
}
return lenses;
}
/** The dropdown: this-proposal vs all-proposals, then dispatch. */
private async menu(kind: "accept" | "reject", id?: string): Promise<void> {
const doc = vscode.window.activeTextEditor?.document;
if (!doc || !id) return;
const key = this.proposals.keyFor(doc);
const verb = kind === "accept" ? "Accept" : "Reject";
const pick = await vscode.window.showQuickPick(
[`${verb} this proposal`, `${verb} ALL proposals`],
{ placeHolder: `${verb} Claude's proposal` },
);
if (!pick) return;
const all = pick.includes("ALL");
if (kind === "accept") {
if (all) await this.proposals.acceptAllProposals(doc);
else await this.proposals.finalizeInPlace(key, id);
} else {
if (all) await this.proposals.rejectAll(doc);
else await this.proposals.revertInPlace(key, id);
}
}
dispose(): void {
for (const t of this.pendingApply.values()) clearTimeout(t);
this.pendingApply.clear();
for (const d of this.disposables) d.dispose();
}
}
+13 -56
View File
@@ -12,8 +12,7 @@ import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController"; import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview"; import { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi"; import { LiveProgressUi } from "./liveProgressUi";
import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, selectionRejection } from "./workspacePath";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)"; const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -26,7 +25,6 @@ export interface CowritingApi {
trackChangesPreviewController: TrackChangesPreviewController; trackChangesPreviewController: TrackChangesPreviewController;
sidecarRouter: SidecarRouter; sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi; liveProgressUi: LiveProgressUi;
editorProposalController: EditorProposalController;
} }
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -117,11 +115,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
); );
context.subscriptions.push(trackChangesPreviewController); context.subscriptions.push(trackChangesPreviewController);
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
const editorProposalController = new EditorProposalController(proposalController);
context.subscriptions.push(editorProposalController);
// #46 (INV-42): accept every pending proposal on the active doc in one gesture // #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 // (also reachable from the preview toolbar's "Accept all" button). Reuses the
// batched F4 seam + reports applied-vs-skipped. // batched F4 seam + reports applied-vs-skipped.
@@ -136,18 +129,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}), }),
); );
// #64 (INV-53): reject every pending proposal on the active doc in one gesture.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => {
const doc = vscode.window.activeTextEditor?.document;
if (!doc || doc.languageId !== "markdown") {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
return;
}
await trackChangesPreviewController.rejectAll(doc);
}),
);
// --- F6 machine-landing wiring — now for ANY authorable doc --- // --- F6 machine-landing wiring — now for ANY authorable doc ---
// The seam's single machine-landing signal advances the F6 baseline (INV-18); // 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. // the seam can now fire on out-of-folder files too, so wire it unconditionally.
@@ -236,25 +217,24 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
return; return;
} }
if (!editor) return; // unreachable once reason is null, but narrows the type if (!editor) return; // unreachable once reason is null, but narrows the type
const instruction = await vscode.window.showInputBox({
prompt: "What should Claude do with the selection?",
placeHolder: "e.g. tighten this paragraph",
});
if (!instruction) return;
if (editor.selection.isEmpty) {
void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first.");
return;
}
const document = editor.document; const document = editor.document;
const selection = editor.selection; // non-empty (selectionRejection guaranteed it) const selection = editor.selection;
// Capture the selection text + anchor BEFORE prompting — the inline prompt
// moves the cursor to the document top (where its box anchors), which would
// otherwise collapse the selection we act on (spec §6.5 PUC-1: anchor
// captured pre-turn so mid-turn edits can't skew it).
const selectedText = document.getText(selection); const selectedText = document.getText(selection);
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
// can't skew it — the proposal renders wherever the target re-resolves.
const fp = buildFingerprint(document.getText(), { const fp = buildFingerprint(document.getText(), {
start: document.offsetAt(selection.start), start: document.offsetAt(selection.start),
end: document.offsetAt(selection.end), end: document.offsetAt(selection.end),
}); });
// The instruction prompt is the multi-line split-below webview box (shared
// with the document case, via the preview controller); the document above
// keeps the selection highlighted while it's open. selectedText/fp were
// captured above, so moving focus to the box doesn't affect what we edit.
const instruction = await trackChangesPreviewController.askEditInstruction(
"Ask Claude to Edit This Selection",
);
if (!instruction) return;
const turnId = `turn-${Date.now().toString(36)}`; const turnId = `turn-${Date.now().toString(36)}`;
try { try {
await vscode.window.withProgress( await vscode.window.withProgress(
@@ -319,28 +299,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}), }),
); );
// The single user-facing "Ask Claude to Edit" gesture (one command, one
// keybinding, one menu entry). It routes to the selection flow (editSelection)
// or the whole-document flow (editDocument) by selection/context — see
// routeEdit. The two underlying commands stay registered for the host E2E
// harness and the internal seams, but are hidden from the palette.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
const editor = vscode.window.activeTextEditor;
const route = routeEdit({
hasUri: !!uri,
uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(),
hasActiveEditor: !!editor,
selectionEmpty: editor?.selection.isEmpty ?? true,
});
if (route === "selection") {
await vscode.commands.executeCommand("cowriting.editSelection");
} else {
await vscode.commands.executeCommand("cowriting.editDocument", uri);
}
}),
);
// Render threads + attributions for already-open editors, and on future opens. // Render threads + attributions for already-open editors, and on future opens.
const renderIfOpen = (doc: vscode.TextDocument) => { const renderIfOpen = (doc: vscode.TextDocument) => {
if (isAuthorable(doc.uri.scheme)) { if (isAuthorable(doc.uri.scheme)) {
@@ -361,7 +319,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
trackChangesPreviewController, trackChangesPreviewController,
sidecarRouter, sidecarRouter,
liveProgressUi, liveProgressUi,
editorProposalController,
}; };
} }
-19
View File
@@ -64,25 +64,6 @@ export async function runEditTurn(
providerId: "claude-code", providerId: "claude-code",
modelId, modelId,
systemPrompt: SYSTEM_PROMPT, systemPrompt: SYSTEM_PROMPT,
// The claude-code provider spawns the bundled `claude` CLI. On macOS, when
// that process is launched from inside VS Code it performs IDE auto-connect,
// which shells out to `osascript` (`tell application …`) to identify/activate
// the editor — raising the macOS "control other applications" (Apple Events)
// permission prompt (#59). We only consume the turn's text result and never
// use the IDE connection, so we disable it: `CLAUDE_CODE_AUTO_CONNECT_IDE=0`
// hard-returns out of the auto-connect path in the binary, and
// `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1` skips the extension-install deep link.
// Seeding with the full `process.env` keeps the default inheritance intact
// (login under HOME, proxy/CA vars) — we only add the two switches. The
// provider forwards `options.env` into the spawned child (verified end to end
// through @cline/sdk → ai-sdk-provider-claude-code → @anthropic-ai/claude-agent-sdk).
options: {
env: {
...process.env,
CLAUDE_CODE_AUTO_CONNECT_IDE: "0",
CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1",
},
},
}); });
// Stream reduced progress snapshots out (INV-44 additive) and wire cancellation // Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
-10
View File
@@ -97,14 +97,6 @@ export interface Proposal {
* back-compat with older sidecars) a single-range proposal accepted whole. * back-compat with older sidecars) a single-range proposal accepted whole.
*/ */
granularity?: "block" | "single"; granularity?: "block" | "single";
/**
* F12/#64 (INV-48): the pre-apply text, captured when a proposal is
* optimistically applied to the buffer. `replacement` is now in the buffer and
* `fp.text` re-anchors to it, so `original` is the only record of what to revert
* to (revert-in-place) and what to show struck in the `<del>` half. Absent on a
* proposal created but not yet optimistically applied (or older sidecars).
*/
original?: string;
} }
export interface Artifact { export interface Artifact {
@@ -260,8 +252,6 @@ export function serializeArtifact(a: Artifact): string {
createdAt: p.createdAt, createdAt: p.createdAt,
...(p.turnId !== undefined ? { turnId: p.turnId } : {}), ...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
...(p.instruction !== undefined ? { instruction: p.instruction } : {}), ...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
...(p.original !== undefined ? { original: p.original } : {}),
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
}, },
p, p,
), ),
+4 -191
View File
@@ -13,8 +13,8 @@
import * as vscode from "vscode"; import * as vscode from "vscode";
import { SidecarRouter, docIdentity } from "./sidecarRouter"; import { SidecarRouter, docIdentity } from "./sidecarRouter";
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
import { resolve, shift, buildFingerprint, type OffsetRange } from "./anchorer"; import { resolve, shift, type OffsetRange } from "./anchorer";
import { addProposal, removeProposal, setProposalApplied } from "./proposalModel"; import { addProposal, removeProposal } from "./proposalModel";
import type { AttributionController } from "./attributionController"; import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard"; import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
@@ -39,8 +39,6 @@ interface DocState {
live: Map<string, OffsetRange>; live: Map<string, OffsetRange>;
/** proposal ids whose anchor did not resolve at last render (stale/orphaned). */ /** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
unresolved: Set<string>; unresolved: Set<string>;
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
applied: Set<string>;
} }
export class ProposalController implements vscode.Disposable { export class ProposalController implements vscode.Disposable {
@@ -91,9 +89,8 @@ export class ProposalController implements vscode.Disposable {
id: p.id, id: p.id,
anchorStart: resolved === "orphaned" ? null : resolved.start, anchorStart: resolved === "orphaned" ? null : resolved.start,
anchorEnd: resolved === "orphaned" ? null : resolved.end, anchorEnd: resolved === "orphaned" ? null : resolved.end,
replaced: p.original ?? fp?.text ?? "", replaced: fp?.text ?? "",
replacement: p.replacement, replacement: p.replacement,
original: p.original,
}; };
}); });
} }
@@ -107,7 +104,6 @@ export class ProposalController implements vscode.Disposable {
artifact: this.store.load(docPath) ?? emptyArtifact(docPath), artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
live: new Map(), live: new Map(),
unresolved: new Set(), unresolved: new Set(),
applied: new Set(),
}; };
this.docs.set(docPath, state); this.docs.set(docPath, state);
} }
@@ -141,33 +137,8 @@ export class ProposalController implements vscode.Disposable {
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ---------------------------------- // ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
/** Accept by id — F12: finalize the already-applied text in place (INV-51). */ /** Accept by proposal id (test-facing twin of the thread-menu gesture). */
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> { async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
if (this.isApplied(docPath, proposalId)) {
// INV-11 (applied path): if an external write mangled the optimistically-applied
// text so the fingerprint no longer resolves, refuse finalize — same guard as the
// legacy accept path. Direct finalizeInPlace calls (CodeLens Accept gesture where
// the user may have edited inside the applied span) bypass this check intentionally.
const hit = this.byId(docPath, proposalId);
if (hit) {
const document = this.openDoc(hit.state);
if (document) {
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved === "orphaned") {
if (!opts?.silent) {
void vscode.window.showWarningMessage(
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
);
}
return false;
}
}
}
return this.finalizeInPlace(docPath, proposalId);
}
// Fallback: a proposal that was never optimistically applied (e.g. orphaned at
// apply time) keeps the legacy seam-apply accept.
const hit = this.byId(docPath, proposalId); const hit = this.byId(docPath, proposalId);
return hit ? this.accept(hit.state, hit.proposal, opts) : false; return hit ? this.accept(hit.state, hit.proposal, opts) : false;
} }
@@ -210,12 +181,6 @@ export class ProposalController implements vscode.Disposable {
return true; return true;
} }
/** Reject by id — F12: revert the applied text in place (INV-51). */
async rejectByIdInPlace(docPath: string, proposalId: string): Promise<boolean> {
if (this.isApplied(docPath, proposalId)) return this.revertInPlace(docPath, proposalId);
return this.rejectById(docPath, proposalId);
}
private async accept(state: DocState, proposal: Proposal, opts?: { silent?: boolean }): Promise<boolean> { private async accept(state: DocState, proposal: Proposal, opts?: { silent?: boolean }): Promise<boolean> {
if (this.guard.isReadOnly(state.docPath)) return false; if (this.guard.isReadOnly(state.docPath)) return false;
const document = this.openDoc(state); const document = this.openDoc(state);
@@ -292,158 +257,6 @@ export class ProposalController implements vscode.Disposable {
return true; return true;
} }
/** True once this proposal's text is in the buffer (optimistic apply ran). */
isApplied(docPath: string, proposalId: string): boolean {
return this.docs.get(docPath)?.applied.has(proposalId) ?? false;
}
/**
* F12/#64 (INV-48): optimistically apply a pending proposal INTO the buffer so
* the editor shows the would-be-accepted result (editable). Reuses the F4
* word-precise seam (block per-word hunks, INV-40; single whole range) but
* with `landBaseline:false` (the change stays pending). Then re-anchors the
* proposal to the applied text and stores the original (`setProposalApplied`), so
* `resolve()` finds it in the mutated buffer and revert/decorate key off it.
* Idempotent: a no-op if already applied.
*/
async optimisticApply(document: vscode.TextDocument, proposalId: string): Promise<boolean> {
if (!this.isTracked(document) || this.guard.isReadOnly(this.keyOf(document))) return false;
const docPath = this.keyOf(document);
const state = this.ensureState(document);
if (state.applied.has(proposalId)) return true;
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const proposal = state.artifact.proposals.find((p) => p.id === proposalId);
const fp = proposal ? state.artifact.anchors[proposal.anchorId]?.fingerprint : undefined;
if (!proposal || !fp) return false;
// Reload-safety (INV-51/54): a proposal that already carries `original` was
// optimistically applied in a PRIOR session — the buffer holds the applied text
// and `fp` points at it, but this (fresh) controller's in-memory `applied` set is
// empty. Re-applying would recapture `original` from the already-applied buffer
// (= the replacement) and CLOBBER the true revert target, breaking Reject. Mark it
// applied in memory and stop — `original` is captured exactly once, on first apply.
if (proposal.original !== undefined) {
state.applied.add(proposalId);
this.renderAll(document);
return true;
}
const resolved = resolve(document.getText(), fp);
if (resolved === "orphaned") return false;
const original = document.getText(
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
);
const ok =
proposal.granularity === "block"
? await this.applyBlockOptimistic(document, resolved, proposal)
: await this.attribution.applyAgentEdit(
document,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
proposal.replacement,
proposal.author,
{ expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false },
);
if (!ok) return false;
state.applied.add(proposalId);
// Re-anchor to the applied text now in the buffer (its start is unchanged; its
// end shifts by the net length delta of the replacement).
const appliedStart = resolved.start;
const appliedEnd = appliedStart + proposal.replacement.length;
const appliedFp = buildFingerprint(document.getText(), { start: appliedStart, end: appliedEnd });
this.store.update(docPath, (a) => setProposalApplied(a, proposalId, appliedFp, original));
this.renderAll(document);
return true;
}
/** Block optimistic apply: the INV-40 per-word hunks, but landBaseline:false. */
private async applyBlockOptimistic(
document: vscode.TextDocument,
resolved: OffsetRange,
proposal: Proposal,
): Promise<boolean> {
const blockText = document.getText(
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
);
const subHunks = wordEditHunks(blockText, proposal.replacement);
if (subHunks.length === 0) return true;
for (const h of [...subHunks].sort((a, b) => b.start - a.start)) {
const range = new vscode.Range(
document.positionAt(resolved.start + h.start),
document.positionAt(resolved.start + h.end),
);
const ok = await this.attribution.applyAgentEdit(document, range, h.replacement, proposal.author, {
expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false,
});
if (!ok) return false;
}
return true;
}
/**
* F12/#64 (INV-51): ACCEPT an optimistically-applied proposal the text is
* already in the buffer, so this only advances the F6 baseline (machine-landing,
* via `attribution.signalLanded`) and clears the proposal. No re-application.
*/
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
this.attribution.signalLanded(document);
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
this.renderAll(document);
return true;
}
/**
* F12/#64 (INV-51): REJECT an optimistically-applied proposal replace its live
* applied span with the stored `original`, then clear it. Reverts the whole block
* regardless of any in-place edits the human made to the inserted text.
*/
async revertInPlace(docPath: string, proposalId: string): Promise<boolean> {
const hit = this.byId(docPath, proposalId);
if (!hit) return false;
const document = this.openDoc(hit.state);
if (!document) return false;
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
if (resolved !== "orphaned" && hit.proposal.original !== undefined) {
const we = new vscode.WorkspaceEdit();
we.replace(
document.uri,
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
hit.proposal.original,
);
if (!(await vscode.workspace.applyEdit(we))) return false;
}
this.store.update(docPath, (a) => removeProposal(a, proposalId));
hit.state.applied.delete(proposalId);
this.renderAll(document);
return true;
}
/**
* F12/#64 (INV-53): reject EVERY pending proposal on a document revert each in
* DESCENDING anchor order (so an earlier revert never shifts a later one's
* offsets), symmetric with #46's accept-all. Returns the reverted count.
*/
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number }> {
if (!this.isTracked(document)) return { reverted: 0 };
const docPath = this.keyOf(document);
const state = this.ensureState(document);
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
const text = document.getText();
const ordered = state.artifact.proposals
.map((p) => {
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
const r = fp ? resolve(text, fp) : "orphaned";
return { id: p.id, start: r === "orphaned" ? -1 : r.start };
})
.sort((a, b) => b.start - a.start);
let reverted = 0;
for (const it of ordered) if (await this.revertInPlace(docPath, it.id)) reverted++;
return { reverted };
}
private reject(state: DocState, proposal: Proposal): void { private reject(state: DocState, proposal: Proposal): void {
if (this.guard.isReadOnly(state.docPath)) return; if (this.guard.isReadOnly(state.docPath)) return;
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
-19
View File
@@ -37,25 +37,6 @@ export function removeProposal(artifact: Artifact, proposalId: string): boolean
return artifact.proposals.length < before; return artifact.proposals.length < before;
} }
/**
* F12/#64 (INV-48): record a proposal as optimistically applied store the
* pre-apply `original` (for revert + the struck `<del>`) and re-point its anchor
* fingerprint to the now-in-buffer applied text so `resolve()` finds it. Idempotent
* shape: a second call simply overwrites with the same values.
*/
export function setProposalApplied(
artifact: Artifact,
proposalId: string,
appliedFp: Fingerprint,
original: string,
): boolean {
const p = artifact.proposals.find((x) => x.id === proposalId);
if (!p) return false;
p.original = original;
artifact.anchors[p.anchorId] = { fingerprint: appliedFp };
return true;
}
/** Markdown comment body: instruction header + fenced whole-range diff. */ /** Markdown comment body: instruction header + fenced whole-range diff. */
export function proposalBody(targetText: string, p: Proposal): string { export function proposalBody(targetText: string, p: Proposal): string {
const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**"; const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**";
+7 -102
View File
@@ -263,42 +263,6 @@ export function wordEditHunks(currentText: string, rewrittenText: string): EditH
return hunks; return hunks;
} }
/** F12/#64 (INV-49/52): the editor render of one optimistically-applied proposal. */
export interface DecorationPlan {
/** buffer ranges of inserted (proposed) text → tinted (INV-52). */
insertions: { start: number; end: number }[];
/** struck original text shown as a non-editable hint at a buffer offset (INV-52). */
deletions: { at: number; text: string }[];
}
/**
* F12/#64 (INV-49): compute the editor decoration plan for a proposal whose applied
* text occupies `[anchorStart, anchorStart+replacement.length)` in the buffer. The
* SAME word diff the webview uses (`wordEditHunks`) drives it, so both surfaces show
* the identical diff. A changed run maps to (a) an insertion range over the run's
* applied text and (b) a deletion hint carrying the run's removed original text at
* the run start; a pure insertion has no deletion hint; a pure deletion has only a
* hint. Pure, vscode-free, deterministic.
*/
export function decorationPlan(anchorStart: number, original: string, replacement: string): DecorationPlan {
const insertions: { start: number; end: number }[] = [];
const deletions: { at: number; text: string }[] = [];
// Walk the word diff once, tracking the applied-side offset as we consume parts.
let appliedOffset = anchorStart;
for (const part of diffWordsWithSpace(original, replacement)) {
if (part.added) {
insertions.push({ start: appliedOffset, end: appliedOffset + part.value.length });
appliedOffset += part.value.length;
} else if (part.removed) {
deletions.push({ at: appliedOffset, text: part.value });
// removed text is NOT in the applied buffer → appliedOffset does not advance
} else {
appliedOffset += part.value.length;
}
}
return { insertions, deletions };
}
const isWs = (c: string): boolean => /\s/.test(c); const isWs = (c: string): boolean => /\s/.test(c);
/** /**
@@ -737,8 +701,6 @@ export interface ProposalView {
replaced: string; replaced: string;
/** the proposed replacement text. */ /** the proposed replacement text. */
replacement: string; replacement: string;
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
original?: string;
} }
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string { function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
@@ -754,14 +716,8 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`; const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
const actions = const actions =
`<span class="cw-actions">` + `<span class="cw-actions">` +
`<span class="cw-btngroup">` + `<button class="cw-accept" data-action="accept">✓</button>` +
`<button class="cw-accept" data-action="accept">Accept</button>` + `<button class="cw-reject" data-action="reject">✗</button>` +
`<button class="cw-caret" data-action="acceptAll" title="Accept all pending proposals">▾</button>` +
`</span>` +
`<span class="cw-btngroup">` +
`<button class="cw-reject" data-action="reject">Reject</button>` +
`<button class="cw-caret" data-action="rejectAll" title="Reject all pending proposals">▾</button>` +
`</span>` +
`</span>`; `</span>`;
return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`; return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
} }
@@ -791,26 +747,6 @@ function renderReviewOp(
* INV-34). Deterministic: proposals in the same block are ordered by anchorStart * INV-34). Deterministic: proposals in the same block are ordered by anchorStart
* then id; trailing proposals keep input order. * then id; trailing proposals keep input order.
*/ */
/**
* F12/#64 (INV-50): `currentText` with every resolved pending proposal's applied
* span reverted to its original (`replaced`) the "landed" text the baseline diff
* should run against, so a pending proposal renders ONCE (as a proposal), never also
* as a landed change. Reverts highlow so earlier offsets stay valid. Pure. The
* preview's summary tally diffs against this too, so the toolbar count matches the
* body (a pending change is not double-counted as both a landed add/remove and a
* proposal).
*/
export function landedTextOf(currentText: string, proposals: ProposalView[]): string {
const pendingApplied = proposals
.filter((p) => p.anchorStart !== null)
.sort((a, b) => b.anchorStart! - a.anchorStart!);
let landedText = currentText;
for (const p of pendingApplied) {
landedText = landedText.slice(0, p.anchorStart!) + p.replaced + landedText.slice(p.anchorEnd!);
}
return landedText;
}
export function renderReview( export function renderReview(
baselineText: string, baselineText: string,
currentText: string, currentText: string,
@@ -819,18 +755,8 @@ export function renderReview(
opts: RenderOptions = {}, opts: RenderOptions = {},
): string { ): string {
const render = opts.render ?? defaultRender; const render = opts.render ?? defaultRender;
const ranges = splitBlocksWithRanges(currentText);
// F12/#64 (INV-50): with optimistic apply the proposed text is already in const ops = diffBlocks(baselineText, currentText);
// `currentText`, so a naive baseline→current diff would render each proposed
// change BOTH as a landed diff and as its proposal block. Diff against the
// "landed" text (current minus pending proposals) so they render once.
const pendingApplied = proposals
.filter((p) => p.anchorStart !== null)
.sort((a, b) => b.anchorStart! - a.anchorStart!);
const landedText = landedTextOf(currentText, proposals);
const ranges = splitBlocksWithRanges(landedText);
const ops = diffBlocks(baselineText, landedText);
// #48: right after a PIN (baseline reason "pinned") with no changes since, the // #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 // 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 // coloring, so the pin reads as "this is my clean starting point". Skip
@@ -840,28 +766,7 @@ export function renderReview(
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically. // 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"); const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
// Map a currentText offset to its landedText offset (account for reverted spans // Associate each resolved proposal with the current-side block index whose range
// that precede it; reverts were applied high→low so the cumulative delta is stable).
const toLanded = (curOff: number): number => {
let delta = 0;
for (const p of [...pendingApplied].sort((a, b) => a.anchorStart! - b.anchorStart!)) {
if (p.anchorStart! < curOff) delta += p.replaced.length - (p.anchorEnd! - p.anchorStart!);
}
return curOff + delta;
};
// F12/#64 (INV-49/50): blocks are split from `landedText`, so `blk.start` is a
// landedText offset — but `authorSpans` arrive in `currentText` coordinates. A
// colored block sitting AFTER a length-changing pending proposal would otherwise
// be mis-colored (the offsets diverge by the revert delta). Map the spans into
// landedText coordinates once so authorship coloring stays aligned.
const landedSpans: AuthorSpan[] = authorSpans.map((s) => ({
...s,
start: toLanded(s.start),
end: toLanded(s.end),
}));
// Associate each resolved proposal with the landedText block index whose range
// it anchors into: the largest block with start <= anchorStart (the containing // it anchors into: the largest block with start <= anchorStart (the containing
// block, or the nearest preceding block when the anchor sits in a gap). A // block, or the nearest preceding block when the anchor sits in a gap). A
// resolved anchor before all blocks, and every unresolved proposal, trails. // resolved anchor before all blocks, and every unresolved proposal, trails.
@@ -873,7 +778,7 @@ export function renderReview(
const byBlock = new Map<number, ProposalView[]>(); const byBlock = new Map<number, ProposalView[]>();
const trailing: ProposalView[] = []; const trailing: ProposalView[] = [];
for (const p of proposals) { for (const p of proposals) {
const j = p.anchorStart === null ? -1 : blockOf(toLanded(p.anchorStart)); const j = p.anchorStart === null ? -1 : blockOf(p.anchorStart);
if (j < 0) { if (j < 0) {
trailing.push(p); trailing.push(p);
continue; continue;
@@ -890,7 +795,7 @@ export function renderReview(
const blockIndex = op.kind === "removed" ? -1 : ci; const blockIndex = op.kind === "removed" ? -1 : ci;
const blk = op.kind === "removed" ? undefined : ranges[ci++]; const blk = op.kind === "removed" ? undefined : ranges[ci++];
const colored = (raw: string): string => const colored = (raw: string): string =>
blk && !clean ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw); blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk))); bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined; const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render)); if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
+13 -37
View File
@@ -13,12 +13,11 @@ import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController"; import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController"; import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController"; import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel"; import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer"; import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi"; import type { LiveProgressUi } from "./liveProgressUi";
import { promptEditInstruction } from "./editInstructionInput";
/** /**
* F11: a host edit turn (selection/document text + instruction rewrite). * F11: a host edit turn (selection/document text + instruction rewrite).
@@ -44,8 +43,7 @@ type ToolbarMsg =
| { type: "pinBaseline" } | { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" } | { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number } | { type: "askClaude"; scope: "selection"; start: number; end: number }
| { type: "acceptAll" } | { type: "acceptAll" };
| { type: "rejectAll" };
export class TrackChangesPreviewController implements vscode.Disposable { export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = []; private readonly disposables: vscode.Disposable[] = [];
@@ -64,13 +62,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
const { runEditTurn } = await import("./liveTurn"); const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text, opts); return runEditTurn(instruction, text, opts);
}; };
/**
* The instruction prompt (the multi-line split-below webview box) for BOTH the
* selection and document cases. A field so host E2E can stub it the webview
* DOM can't run in CI (mirrors `editTurn`). Also used by the editor's
* `cowriting.editSelection` command (via this controller).
*/
askEditInstruction: (header: string) => Promise<string | undefined> = promptEditInstruction;
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0; private turnSeq = 0;
private nextTurnSeq(): number { private nextTurnSeq(): number {
@@ -197,7 +188,8 @@ export class TrackChangesPreviewController implements vscode.Disposable {
.acceptById(this.proposals.keyFor(document), m.proposalId) .acceptById(this.proposals.keyFor(document), m.proposalId)
.then(() => this.refresh(document)); .then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) { } else if (m?.type === "reject" && m.proposalId) {
void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document)); this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
this.refresh(document);
} else if (m?.type === "pinBaseline") { } else if (m?.type === "pinBaseline") {
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription. // F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
this.diffView.pin(document); this.diffView.pin(document);
@@ -208,8 +200,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
} else if (m?.type === "acceptAll") { } else if (m?.type === "acceptAll") {
// #46 (INV-42): batch-accept every pending proposal on this doc, then report. // #46 (INV-42): batch-accept every pending proposal on this doc, then report.
void this.acceptAll(document); void this.acceptAll(document);
} else if (m?.type === "rejectAll") {
void this.rejectAll(document);
} }
} }
@@ -230,29 +220,19 @@ export class TrackChangesPreviewController implements vscode.Disposable {
); );
} }
/** #64 (INV-53): revert every pending proposal on the document; report the count. */
async rejectAll(document: vscode.TextDocument): Promise<void> {
const { reverted } = await this.proposals.rejectAll(document);
this.refresh(document);
if (reverted > 0) {
void vscode.window.showInformationMessage(
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
);
}
}
/** /**
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret * 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 * surface out of the sealed webview, INV-8/35), run the edit turn, and surface
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
*/ */
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> { private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
// Both scopes use the same multi-line split-below webview box; only the header const instruction = await vscode.window.showInputBox({
// (and the downstream proposal logic) differs. For a selection the document prompt:
// above keeps the selection highlighted while the box is open. target.kind === "document"
const header = ? "What should Claude do with the document?"
target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection"; : "What should Claude do with the selection?",
const instruction = await this.askEditInstruction(header); placeHolder: "e.g. tighten the prose",
});
if (!instruction) return; if (!instruction) return;
try { try {
const ids = await vscode.window.withProgress( const ids = await vscode.window.withProgress(
@@ -372,13 +352,9 @@ export class TrackChangesPreviewController implements vscode.Disposable {
} }
const spans = this.attribution.spansFor(document); const spans = this.attribution.spansFor(document);
const proposals = this.proposals.listProposals(document); const proposals = this.proposals.listProposals(document);
// F12/#64 (INV-50): count added/removed against the LANDED text (current minus
// pending proposals), matching the body — a pending change shows once, as a
// proposal, and is not also tallied as a landed add/remove.
const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals));
const summary = { const summary = {
added: landedOps.filter((o) => o.kind === "added").length, added: ops.filter((o) => o.kind === "added").length,
removed: landedOps.filter((o) => o.kind === "removed").length, removed: ops.filter((o) => o.kind === "removed").length,
proposals: proposals.length, proposals: proposals.length,
}; };
void panel.webview.postMessage({ void panel.webview.postMessage({
-26
View File
@@ -60,29 +60,3 @@ export function selectionRejection(ctx: SelectionContext): string | null {
} }
return null; return null;
} }
export interface EditRouteContext {
/** Was the command invoked on a specific resource (a tab right-click)? */
hasUri: boolean;
/** Does that resource match the focused editor's document? */
uriMatchesActiveEditor: boolean;
/** Is there a focused text editor at all? */
hasActiveEditor: boolean;
/** Is the focused editor's selection empty (no highlight)? */
selectionEmpty: boolean;
}
/**
* Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the
* selection or whole-document flow. One conceptual command, two destinations:
*
* - A tab right-click on a document that ISN'T the focused editor has no
* selection to act on edit the whole document.
* - Otherwise a non-empty selection in the focused editor edit the selection;
* an empty selection (or no editor) edit the whole document.
*/
export function routeEdit(ctx: EditRouteContext): "selection" | "document" {
if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document";
if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection";
return "document";
}
-27
View File
@@ -1,27 +0,0 @@
import { describe, test, expect } from "vitest";
import { decorationPlan } from "../src/trackChangesModel";
describe("decorationPlan (INV-49)", () => {
test("derives insertion ranges + deletion hints from original→replacement", () => {
// anchorStart 10; original 'the brown fox' → 'the red fox'
const plan = decorationPlan(10, "the brown fox", "the red fox");
// one changed run: 'brown ' → 'red ' (word-level); insertion 'red ' tinted,
// deletion hint 'brown ' shown at the run start.
expect(plan.insertions.length).toBeGreaterThan(0);
expect(plan.deletions.length).toBeGreaterThan(0);
// insertion offsets are in buffer coords (>= anchorStart) and lie within the applied text
for (const ins of plan.insertions) {
expect(ins.start).toBeGreaterThanOrEqual(10);
expect(ins.end).toBeLessThanOrEqual(10 + "the red fox".length);
}
// a deletion hint carries the struck original text at a buffer offset
expect(plan.deletions[0].text).toContain("brown");
});
test("pure insertion (no deletion) yields an insertion range and no deletion hint", () => {
// 'hello' → 'hello world': ' world' is a pure word-level insertion (no removed tokens)
const plan = decorationPlan(0, "hello", "hello world");
expect(plan.insertions.length).toBe(1);
expect(plan.deletions.length).toBe(0);
});
});
+2 -12
View File
@@ -13,20 +13,11 @@ async function main(): Promise<void> {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-")); const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-"));
fs.cpSync(fixture, workspace, { recursive: true }); fs.cpSync(fixture, workspace, { recursive: true });
// VS Code derives its IPC socket path from --user-data-dir. The default
// (`<projectRoot>/.vscode-test/user-data`) plus `/<version>-main.sock` can blow
// past macOS's ~103-char UNIX-socket limit (`listen EINVAL`) when the project
// lives at a long path (e.g. a git worktree). Pin the user-data dir to a SHORT
// root under /tmp so the socket path stays well under the limit. (os.tmpdir() on
// macOS is itself a long /var/folders/… path, so we use /tmp directly.)
const userDataRoot = process.platform === "win32" ? os.tmpdir() : "/tmp";
const userDataDir = fs.mkdtempSync(path.join(userDataRoot, "cwud-"));
try { try {
await runTests({ await runTests({
extensionDevelopmentPath, extensionDevelopmentPath,
extensionTestsPath, extensionTestsPath,
launchArgs: [workspace, "--disable-extensions", "--user-data-dir", userDataDir], launchArgs: [workspace, "--disable-extensions"],
extensionTestsEnv: { E2E_WORKSPACE: workspace }, extensionTestsEnv: { E2E_WORKSPACE: workspace },
}); });
@@ -35,11 +26,10 @@ async function main(): Promise<void> {
await runTests({ await runTests({
extensionDevelopmentPath, extensionDevelopmentPath,
extensionTestsPath: path.resolve(__dirname, "./suite-no-workspace/index"), extensionTestsPath: path.resolve(__dirname, "./suite-no-workspace/index"),
launchArgs: ["--disable-extensions", "--user-data-dir", userDataDir], launchArgs: ["--disable-extensions"],
}); });
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
fs.rmSync(userDataDir, { recursive: true, force: true });
} }
} }
@@ -28,7 +28,6 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
"cowriting.reply", "cowriting.reply",
"cowriting.resolveThread", "cowriting.resolveThread",
"cowriting.reopenThread", "cowriting.reopenThread",
"cowriting.edit",
"cowriting.editSelection", "cowriting.editSelection",
"cowriting.applyAgentEdit", "cowriting.applyAgentEdit",
"cowriting.proposeAgentEdit", "cowriting.proposeAgentEdit",
+5 -7
View File
@@ -109,9 +109,8 @@ suite("F10 interactive review (host E2E — preview is the single review surface
const pIdx = html.indexOf(`data-proposal-id="${id}"`); const pIdx = html.indexOf(`data-proposal-id="${id}"`);
const t2Idx = html.indexOf("second claude target"); const t2Idx = html.indexOf("second claude target");
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)"); assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
// F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer; // INV-10: proposing never touches the document.
// the replacement text is now in the document, proposal is still pending. assert.ok(doc.getText().includes(T1), "document unchanged by propose");
assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer");
}); });
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => { test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
@@ -132,18 +131,17 @@ suite("F10 interactive review (host E2E — preview is the single review surface
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)"); assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
}); });
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => { test("reject → the proposal vanishes and the document is untouched (PUC-5)", async () => {
const { doc, key } = await reopen(DOC_REL); const { doc, key } = await reopen(DOC_REL);
const api = await getApi(); const api = await getApi();
const before = doc.getText(); const before = doc.getText();
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2"); const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
await settle(); await settle();
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending"); assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit. assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject");
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
await settle(); await settle();
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone"); assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state"); assert.strictEqual(doc.getText(), before, "document untouched by reject");
const html = api.trackChangesPreviewController.renderHtmlFor(key); const html = api.trackChangesPreviewController.renderHtmlFor(key);
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview"); assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
}); });
+11 -20
View File
@@ -99,9 +99,8 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
"The quick RED fox jumps over the lazy CAT.", "The quick RED fox jumps over the lazy CAT.",
"the block proposal carries the whole rewritten paragraph", "the block proposal carries the whole rewritten paragraph",
); );
// F12 (INV-48): EditorProposalController optimistically applies the proposed text, // INV-10: proposing never mutates the document.
// so after settle the buffer has the replacement. The proposal is still pending. assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer");
void key; void key;
}); });
@@ -130,9 +129,7 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.ok(view, "the proposal is live"); assert.ok(view, "the proposal is live");
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement"); assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range"); assert.strictEqual(view!.replaced, target, "replaces exactly the selected range");
// F12 (INV-48): the EditorProposalController optimistically applies, so the buffer assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)");
// now has the replacement. `view.replaced` still records the original target text.
assert.ok(doc.getText().includes("The REWRITTEN paragraph from Claude."), "F12 optimistic-apply: proposed text in buffer");
void key; void key;
}); });
@@ -177,22 +174,16 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending"); assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
}); });
// SLICE-3: the document-scoped command stays registered for #42 reuse (now behind // SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
// the unified `cowriting.edit`). editDocument is hidden from the palette; the test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
// markdown-guarded user-facing palette command is `cowriting.edit`.
test("cowriting.editDocument stays registered; cowriting.edit is the markdown-guarded palette command", async () => {
const all = await vscode.commands.getCommands(true); const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered"); assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
assert.ok(all.includes("cowriting.edit"), "unified edit command registered"); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const palette = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")).contributes const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
.menus.commandPalette as Array<{ command: string; when?: string }>; (m) => m.command === "cowriting.editDocument",
// editDocument is hidden (routed via cowriting.edit). );
const docEntry = palette.find((m) => m.command === "cowriting.editDocument"); assert.ok(entry, "editDocument has a commandPalette entry");
assert.strictEqual(docEntry?.when, "false", "editDocument hidden from the palette"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
// cowriting.edit is the user-facing, markdown-guarded entry.
const editEntry = palette.find((m) => m.command === "cowriting.edit");
assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
}); });
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only). // SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
-251
View File
@@ -1,251 +0,0 @@
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import { addProposal, setProposalApplied } from "../../../src/proposalModel";
import { buildFingerprint } from "../../../src/anchorer";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers");
return api;
}
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
return { doc, key: uri.toString() };
}
suite("F12 inline diff — seam landBaseline (#64, INV-48)", () => {
test("applyAgentEdit with landBaseline:false applies text but does NOT advance the baseline", async () => {
const { doc, key } = await freshDoc("docs/f12-land.md", "# T\n\nalpha here.\n");
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const before = api.diffViewController.getBaseline(key)?.text ?? doc.getText();
const start = doc.getText().indexOf("alpha");
const ok = await api.attributionController.applyAgentEdit(
doc,
new vscode.Range(doc.positionAt(start), doc.positionAt(start + "alpha".length)),
"ALPHA",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } },
{ landBaseline: false },
);
await settle();
assert.ok(ok, "edit applied");
assert.ok(doc.getText().includes("ALPHA"), "text in buffer");
assert.strictEqual(api.diffViewController.getBaseline(key)?.text ?? doc.getText(), before, "baseline unchanged");
});
});
suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
// Optimistic apply lands the text + re-anchors; the buffer becomes the accepted result.
test("optimisticApply puts the proposed text in the buffer and re-anchors", async () => {
const { doc } = await freshDoc("docs/f12-opt.md", "# T\n\nReplace alpha please.\n");
const api = await getApi();
const p = api.proposalController;
const fp = { text: "Replace alpha please.", before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Replace ALPHA please.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await api.proposalController.optimisticApply(doc, id!);
await settle();
assert.ok(doc.getText().includes("Replace ALPHA please."), "applied to buffer");
// re-anchored: the proposal still resolves against the mutated buffer
assert.strictEqual(p.listProposals(doc)[0].anchorStart !== null, true, "re-anchored, resolves");
assert.strictEqual(p.listProposals(doc)[0].original, "Replace alpha please.", "original stored");
});
test("finalizeInPlace clears the proposal and keeps the applied text (no double-apply)", async () => {
const { doc } = await freshDoc("docs/f12-fin.md", "# T\n\nKeep alpha now.\n");
const api = await getApi();
const p = api.proposalController;
const docPath = p.keyFor(doc);
const fp = { text: "Keep alpha now.", before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Keep ALPHA now.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
const ok = await p.finalizeInPlace(docPath, id!);
await settle();
assert.ok(ok, "finalized");
assert.strictEqual(doc.getText(), "# T\n\nKeep ALPHA now.\n", "applied text retained, no double-apply");
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
});
test("revertInPlace restores the original and clears the proposal", async () => {
const { doc } = await freshDoc("docs/f12-rev.md", "# T\n\nUndo alpha here.\n");
const api = await getApi();
const p = api.proposalController;
const docPath = p.keyFor(doc);
const fp = { text: "Undo alpha here.", before: "", after: "", lineHint: 2 };
const id = await p.propose(doc, fp, "Undo ALPHA here.",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
await p.optimisticApply(doc, id!);
await settle();
const ok = await p.revertInPlace(docPath, id!);
await settle();
assert.ok(ok, "reverted");
assert.strictEqual(doc.getText(), "# T\n\nUndo alpha here.\n", "original restored");
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
});
test("rejectAll reverts every pending proposal", async () => {
const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
const p = api.proposalController;
ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
await settle();
for (const id of ids) await p.optimisticApply(doc, id);
await settle();
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"), "both applied");
const { reverted } = await p.rejectAll(doc);
await settle();
assert.strictEqual(reverted, ids.length, "all reverted");
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document restored");
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
});
});
suite("F12 inline diff — INV-50 listProposals.replaced", () => {
test("listProposals reports the original as `replaced` after optimistic apply", async () => {
const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "x");
await settle(); await settle();
assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here.");
});
});
suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => {
const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
await settle(); await settle();
assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer");
const v = api.proposalController.listProposals(doc)[0];
assert.strictEqual(v.original, "The brown fox runs.", "original captured for the deletion hint/revert");
});
test("editing the inserted text then finalizing keeps the human edit", async () => {
const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle();
// human tweaks the inserted text
const at = doc.getText().indexOf("ALPHA");
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 5)), "ALPHA!");
await vscode.workspace.applyEdit(we);
await settle();
// keyFor(doc) gives the repo-relative path that finalizeInPlace uses as its key;
// the `key` from freshDoc is the URI string, which would not match for in-workspace docs.
const docKey = api.proposalController.keyFor(doc);
await api.proposalController.finalizeInPlace(docKey, ids[0]);
await settle();
assert.ok(doc.getText().includes("ALPHA! word here."), "human edit preserved on accept");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "cleared");
});
});
suite("F12 inline diff — control parity (#64, INV-53)", () => {
test("reject from the webview reverts in place; rejectAll clears every proposal", async () => {
const { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle();
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
// reject ONE via the webview intent → that block reverts, the other stays applied
ctl.receiveMessage(key, { type: "reject", proposalId: ids[0] });
await settle(); await settle();
assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied");
// rejectAll via the command → all gone, document restored
ctl.receiveMessage(key, { type: "rejectAll" });
await settle(); await settle();
assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0);
});
test("cowriting.rejectAllProposals is registered + markdown-guarded", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.rejectAllProposals"));
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.rejectAllProposals",
);
assert.ok(entry && /editorLangId == markdown/.test(entry.when ?? ""), "palette-guarded on markdown");
});
});
// Reload-safety: a proposal that was optimistically applied in a PRIOR session
// persists with `original` set and its fp re-anchored to the applied text. A fresh
// controller (empty in-memory `applied` set) must NOT re-capture `original` from the
// already-applied buffer — doing so would clobber the true revert target and break
// Reject. (Regression for the final-review CRITICAL; INV-51/54.)
suite("F12 inline diff — reload-safety (#64, INV-51/54)", () => {
test("optimisticApply does not clobber a previously-persisted original", async () => {
const TRUE_ORIGINAL = "The original sentence here.";
const APPLIED = "The APPLIED sentence here.";
const { doc } = await freshDoc("docs/f12-reload.md", `# R\n\n${TRUE_ORIGINAL}\n`);
const api = await getApi();
const p = api.proposalController;
const key = p.keyFor(doc);
// 1) The buffer holds the APPLIED text (as the saved-while-pending file would).
const at = doc.getText().indexOf(TRUE_ORIGINAL);
const we = new vscode.WorkspaceEdit();
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + TRUE_ORIGINAL.length)), APPLIED);
assert.ok(await vscode.workspace.applyEdit(we), "apply the prior-session applied text");
await settle();
// 2) Record the proposal directly in the sidecar exactly as a prior session left
// it: fp anchored to the APPLIED text + `original` = the TRUE original. We do
// NOT call optimisticApply, so this controller's in-memory `applied` stays empty.
const appliedAt = doc.getText().indexOf(APPLIED);
const appliedFp = buildFingerprint(doc.getText(), { start: appliedAt, end: appliedAt + APPLIED.length });
let id = "";
api.sidecarRouter.update(key, (a) => {
id = addProposal(a, appliedFp, APPLIED, { kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" }).proposalId;
setProposalApplied(a, id, appliedFp, TRUE_ORIGINAL);
});
p.renderAll(doc);
await settle();
// 3) Re-entry as a reload would trigger (EditorProposalController re-applies on
// onDidChangeProposals because `applied` is empty). The guard must preserve original.
await p.optimisticApply(doc, id);
await settle();
const view = p.listProposals(doc).find((v) => v.id === id);
assert.ok(view, "proposal still present");
assert.strictEqual(view!.original, TRUE_ORIGINAL, "true original preserved, NOT clobbered with the applied text");
// 4) Reject restores the TRUE original (not the applied text).
assert.ok(await p.revertInPlace(key, id), "revert");
await settle();
assert.ok(doc.getText().includes(TRUE_ORIGINAL), "reject restored the true original");
assert.ok(!doc.getText().includes(APPLIED), "applied text removed on reject");
});
});
+41 -37
View File
@@ -31,37 +31,43 @@ function menu(id: string): Array<{ command: string; when?: string; group?: strin
} }
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and // SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
// the editor TAB. The two split commands (editSelection / editDocument) were // the editor TAB, selection-aware (selection → editSelection; no selection →
// unified behind ONE user-facing command `cowriting.edit` that auto-routes by // editDocument), both markdown/authorable-gated, both routing through the single
// selection at runtime (routeEdit: selection → editSelection, none → editDocument) // runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we
// — so the menus carry a SINGLE selection-agnostic entry, both markdown/authorable // assert them directly; the tab-targeting behavior is exercised through the command.
// -gated. The routing itself is unit-tested (routeEdit) and exercised through the
// command below; here we assert the declarative menu `when` clauses.
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
// PUC-1/2: editor BODY (editor/context) offers the single unified entry, // PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
// markdown + authorable gated, NOT selection-gated (it routes both cases). test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
test("editor/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown+authorable", () => {
const m = menu("editor/context"); const m = menu("editor/context");
const edit = m.find((e) => e.command === "cowriting.edit"); const sel = m.find((e) => e.command === "cowriting.editSelection");
assert.ok(edit, "cowriting.edit is in editor/context"); const doc = m.find((e) => e.command === "cowriting.editDocument");
assert.match(edit!.when ?? "", /editorLangId == markdown/, "gated on markdown"); assert.ok(sel, "editSelection is in editor/context");
assert.match(edit!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "gated authorable"); assert.ok(doc, "editDocument is in editor/context");
assert.ok(!/editorHasSelection/.test(edit!.when ?? ""), "not selection-gated — one entry routes both cases");
// The old split pair is gone from the menu (the commands stay registered/hidden). assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection");
assert.ok(!m.some((e) => e.command === "cowriting.editSelection"), "old editSelection menu entry removed"); assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection");
assert.ok(!m.some((e) => e.command === "cowriting.editDocument"), "old editDocument menu entry removed"); assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown");
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
}); });
// PUC-3: editor TAB (editor/title/context) carries the same single entry. // PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair.
test("editor/title/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown-gated", () => { test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => {
const m = menu("editor/title/context"); const m = menu("editor/title/context");
const edit = m.find((e) => e.command === "cowriting.edit"); const sel = m.find((e) => e.command === "cowriting.editSelection");
assert.ok(edit, "cowriting.edit is in editor/title/context"); const doc = m.find((e) => e.command === "cowriting.editDocument");
assert.match(edit!.when ?? "", /resourceLangId == markdown/, "tab entry gated on markdown"); assert.ok(sel, "editSelection is in editor/title/context");
assert.ok( assert.ok(doc, "editDocument is in editor/title/context");
!m.some((e) => e.command === "cowriting.editSelection" || e.command === "cowriting.editDocument"),
"old split pair removed from the tab menu", 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, // PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
@@ -76,9 +82,9 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.window.showTextDocument(a.doc); await vscode.window.showTextDocument(a.doc);
await settle(); await settle();
// Stub the document instruction prompt (the webview can't run in CI) + the LLM turn. // Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
const origPrompt = ctl.askEditInstruction; const origInput = vscode.window.showInputBox;
ctl.askEditInstruction = async () => "rewrite it"; (vscode.window as any).showInputBox = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({ ctl.setEditTurnForTest(async () => ({
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n", replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
model: "sonnet", model: "sonnet",
@@ -88,7 +94,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri); await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle(); await settle();
} finally { } finally {
ctl.askEditInstruction = origPrompt; (vscode.window as any).showInputBox = origInput;
} }
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none. // The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
@@ -98,10 +104,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
0, 0,
"active doc A was NOT edited — editDocument honored the tab URI", "active doc A was NOT edited — editDocument honored the tab URI",
); );
// F12 (INV-48): EditorProposalController optimistically applies proposals into the // INV-10: proposing never mutates the document.
// buffer, so the proposed text is now in the tab doc B. Confirm one of the proposal assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
// texts is present (the tab doc was edited, not A).
assert.ok(b.doc.getText().includes("REWRITTEN"), "F12 optimistic-apply: proposed text in tab doc B");
}); });
// No URI arg (palette / keybinding) → fall back to the active editor. // No URI arg (palette / keybinding) → fall back to the active editor.
@@ -112,8 +116,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.window.showTextDocument(a.doc); await vscode.window.showTextDocument(a.doc);
await settle(); await settle();
const origPrompt = ctl.askEditInstruction; const origInput = vscode.window.showInputBox;
ctl.askEditInstruction = async () => "rewrite it"; (vscode.window as any).showInputBox = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({ ctl.setEditTurnForTest(async () => ({
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n", replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
model: "sonnet", model: "sonnet",
@@ -123,7 +127,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument"); await vscode.commands.executeCommand("cowriting.editDocument");
await settle(); await settle();
} finally { } finally {
ctl.askEditInstruction = origPrompt; (vscode.window as any).showInputBox = origInput;
} }
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg"); assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
}); });
+1 -4
View File
@@ -50,10 +50,7 @@ suite("#60 live turn progress (additive + cancel)", () => {
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events"); 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]); const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
assert.ok(view, "the proposal is live"); assert.ok(view, "the proposal is live");
// F12 (INV-48): the EditorProposalController optimistically applies the proposed assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)");
// text into the buffer, so after settle the active-editor buffer shows the
// replacement. The proposal is still pending (not finalized) until Accept.
assert.ok(doc.getText().includes("New paragraph."), "F12 optimistic-apply: proposed text in buffer");
}); });
test("an aborted turn proposes nothing (INV-47)", async () => { test("an aborted turn proposes nothing (INV-47)", async () => {
+15 -38
View File
@@ -63,14 +63,13 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
const TARGET = "The propose target sentence lives here."; const TARGET = "The propose target sentence lives here.";
const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence."; const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence.";
test("propose records a pending proposal; F12 optimistically applies it (INV-48, reverses INV-10)", async () => { test("propose records + renders a pending proposal and does NOT touch the document (INV-10)", async () => {
const doc = await openDoc(); const doc = await openDoc();
const api = await getApi(); const api = await getApi();
const before = doc.getText();
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1"); await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
await settle(); await settle();
// F12 (INV-48): EditorProposalController auto-applies the proposed text into the assert.strictEqual(doc.getText(), before, "document text unchanged (INV-10)");
// buffer, so after settle the buffer has the replacement text (INV-10 is reversed).
assert.ok(doc.getText().includes(REPLACEMENT), "F12 optimistic-apply: replacement in buffer after propose");
const rendered = api.proposalController.getRendered(DOC_REL); const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1); assert.strictEqual(rendered.length, 1);
assert.strictEqual(rendered[0].pending, true); assert.strictEqual(rendered[0].pending, true);
@@ -78,8 +77,7 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)"); assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)");
const art = readSidecar(); const art = readSidecar();
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time"); assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time");
// After optimistic apply, the fingerprint re-anchors to the replacement text. assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET);
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, REPLACEMENT);
}); });
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => { test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
@@ -99,18 +97,15 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar"); assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
}); });
test("reject reverts the optimistically-applied text and removes the proposal (PUC-3, F12-INV-48)", async () => { test("reject leaves the document untouched and removes the proposal (PUC-3)", async () => {
const doc = await openDoc(); const doc = await openDoc();
const api = await getApi(); const api = await getApi();
const before = doc.getText(); const before = doc.getText();
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2"); const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
await settle(); await settle();
// F12: optimistic apply has put "WOULD-BE replacement." in the buffer. assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
assert.ok(doc.getText().includes("WOULD-BE replacement."), "F12: optimistically applied");
// rejectByIdInPlace reverts the buffer to the original (PUC-3, INV-51).
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id), "rejectByIdInPlace removes + reverts");
await settle(); await settle();
assert.strictEqual(doc.getText(), before, "document restored to pre-propose state"); assert.strictEqual(doc.getText(), before, "document untouched");
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0); assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
assert.strictEqual(readSidecar().proposals.length, 0); assert.strictEqual(readSidecar().proposals.length, 0);
}); });
@@ -119,35 +114,25 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
let doc = await openDoc(); let doc = await openDoc();
const api = await getApi(); const api = await getApi();
const anchor = "A stable closing paragraph."; const anchor = "A stable closing paragraph.";
const appliedText = "A PROPOSED closing paragraph."; await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "turn-p3");
await proposeViaCommand(doc, anchor, appliedText, "turn-p3");
await settle(); await settle();
// F12: optimistic apply has put the proposed text in the buffer (anchor → appliedText).
assert.ok(doc.getText().includes(appliedText), "F12: optimistically applied before reload");
const uri = vscode.Uri.file(path.join(WS, DOC_REL)); const uri = vscode.Uri.file(path.join(WS, DOC_REL));
// doc.getText() now has appliedText; the reload prepends a line.
doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText()); doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
await settle(); await settle();
api.proposalController.renderAll(doc); api.proposalController.renderAll(doc);
const rendered = api.proposalController.getRendered(DOC_REL); const rendered = api.proposalController.getRendered(DOC_REL);
assert.strictEqual(rendered.length, 1, "proposal survived reload"); assert.strictEqual(rendered.length, 1, "proposal survived reload");
assert.strictEqual(rendered[0].pending, true, "still decidable"); assert.strictEqual(rendered[0].pending, true, "still decidable");
// After F12 re-anchor, the fingerprint tracks the appliedText, not the original anchor. const moved = doc.getText().indexOf(anchor);
const moved = doc.getText().indexOf(appliedText); assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move");
assert.ok(moved >= 0, "applied text present in the reloaded document");
assert.strictEqual(rendered[0].range.start, moved, "re-anchored to appliedText after the move");
}); });
test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => { test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => {
let doc = await openDoc(); let doc = await openDoc();
const api = await getApi(); const api = await getApi();
const id = api.proposalController.getRendered(DOC_REL)[0].id; const id = api.proposalController.getRendered(DOC_REL)[0].id;
// After F12 optimistic apply the fingerprint tracks the APPLIED text ("A PROPOSED const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph.");
// closing paragraph."), not the original. Mangle that text to make the proposal stale. doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled);
const preMangled = doc.getText();
const mangled = preMangled.replace("A PROPOSED closing paragraph.", "A reworded closing paragraph.");
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
doc = await externalWriteAndReload(uri, mangled);
await settle(); await settle();
api.proposalController.renderAll(doc); api.proposalController.renderAll(doc);
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale"); assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
@@ -158,30 +143,22 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)"); assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
// discard the husk so later tests see a clean sidecar // discard the husk so later tests see a clean sidecar
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true); assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
// Restore the file so subsequent tests can find their fixture targets:
// replace the optimistically-applied text back to the original fixture text.
const restored = preMangled.replace("A PROPOSED closing paragraph.", "A stable closing paragraph.");
doc = await externalWriteAndReload(uri, restored);
await settle();
}); });
test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => { test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => {
const doc = await openDoc(); const doc = await openDoc();
const api = await getApi(); const api = await getApi();
// Clean up any proposals left over from prior tests (F12: proposals persist in state).
await api.proposalController.rejectAll(doc);
await settle();
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4"); const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
const id2 = await proposeViaCommand(doc, "A stable closing paragraph.", "A REPLACED closing paragraph.", "turn-p5"); const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5");
await settle(); await settle();
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2, "two new proposals"); assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2);
// decide the SECOND first, then the first — order independence // decide the SECOND first, then the first — order independence
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true); assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
await settle(); await settle();
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true); assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
await settle(); await settle();
assert.ok(doc.getText().includes("An ACCEPTED opening paragraph.")); assert.ok(doc.getText().includes("An ACCEPTED opening paragraph."));
assert.ok(doc.getText().includes("A REPLACED closing paragraph.")); assert.ok(doc.getText().includes("A REPLACED-AGAIN sentence."));
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0); assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
}); });
}); });
-38
View File
@@ -52,44 +52,6 @@ describe("extractReplacement", () => {
}); });
}); });
describe("runEditTurn macOS automation prompt (#59)", () => {
// The claude-code provider spawns the bundled `claude` CLI, which on macOS
// performs IDE auto-connect when launched inside VS Code — sending Apple Events
// that raise the "control other applications" prompt. The extension only
// consumes the text result and never needs that connection, so we disable it.
it("spawns the agent with IDE auto-connect + auto-install disabled, preserving the rest of the environment", async () => {
vi.resetModules();
const cfgs: any[] = [];
vi.doMock("@cline/sdk", () => ({
Agent: class {
constructor(cfg: any) {
cfgs.push(cfg);
}
subscribe() {
return () => {};
}
abort() {}
async run() {
return { status: "completed", outputText: "X", runId: "r" };
}
},
}));
process.env.__WGL_TEST_SENTINEL__ = "keep-me";
await runEditTurn("do it", "old");
const cfg = cfgs[0];
// mK("0") === true in the claude binary → hard-disables IDE auto-connect,
// so no osascript / Apple Event fires.
expect(cfg.options?.env?.CLAUDE_CODE_AUTO_CONNECT_IDE).toBe("0");
// Belt-and-suspenders: skip the IDE-extension auto-install (deep-link path).
expect(cfg.options?.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL).toBe("1");
// The full process environment must survive (login under HOME, proxy/CA vars).
expect(cfg.options?.env?.__WGL_TEST_SENTINEL__).toBe("keep-me");
delete process.env.__WGL_TEST_SENTINEL__;
vi.doUnmock("@cline/sdk");
vi.resetModules();
});
});
describe("runEditTurn progress + cancel", () => { describe("runEditTurn progress + cancel", () => {
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => { it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
vi.resetModules(); vi.resetModules();
+1 -15
View File
@@ -1,5 +1,4 @@
import assert from "node:assert"; import { describe, it, expect } from "vitest";
import { describe, it, test, expect } from "vitest";
import { import {
SCHEMA_VERSION, SCHEMA_VERSION,
emptyArtifact, emptyArtifact,
@@ -180,19 +179,6 @@ describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => {
}); });
}); });
test("serializeArtifact round-trips Proposal.original", () => {
const a = emptyArtifact("doc.md");
a.anchors["a_1"] = { fingerprint: { text: "new", before: "", after: "", lineHint: 0 } };
a.proposals.push({
id: "pr_1", anchorId: "a_1", replacement: "new",
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s" } },
createdAt: "2026-06-26T00:00:00.000Z", original: "old", granularity: "block",
});
const json = serializeArtifact(a);
assert.match(json, /"original": "old"/);
assert.match(json, /"granularity": "block"/);
});
describe("attributions[] (F3 SLICE-1)", () => { describe("attributions[] (F3 SLICE-1)", () => {
it("round-trips an attribution record through serialize → parse", () => { it("round-trips an attribution record through serialize → parse", () => {
const a = emptyArtifact("docs/x.md"); const a = emptyArtifact("docs/x.md");
+1 -16
View File
@@ -9,7 +9,7 @@ import {
type Proposal, type Proposal,
} from "../src/model"; } from "../src/model";
import { CoauthorStore } from "../src/store"; import { CoauthorStore } from "../src/store";
import { addProposal, proposalBody, removeProposal, setProposalApplied } from "../src/proposalModel"; import { addProposal, proposalBody, removeProposal } from "../src/proposalModel";
const agent = { const agent = {
kind: "agent" as const, kind: "agent" as const,
@@ -104,19 +104,4 @@ describe("proposalModel helpers (spec §6.4)", () => {
expect(body).toContain("- old line two"); expect(body).toContain("- old line two");
expect(body).toContain("+ new only line"); expect(body).toContain("+ new only line");
}); });
it("setProposalApplied stores original and re-anchors the fingerprint to the applied text", () => {
const a = emptyArtifact("docs/x.md");
const { proposalId, anchorId } = addProposal(
a,
{ text: "old", before: "", after: "", lineHint: 0 },
"new",
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } },
{ granularity: "block" },
);
setProposalApplied(a, proposalId, { text: "new", before: "", after: "", lineHint: 0 }, "old");
const p = a.proposals.find((x) => x.id === proposalId)!;
expect(p.original).toBe("old");
expect(a.anchors[anchorId].fingerprint.text).toBe("new");
});
}); });
+1 -50
View File
@@ -231,7 +231,7 @@ describe("renderReview", () => {
const html = renderReview("hello world", "hello", [], []); const html = renderReview("hello world", "hello", [], []);
expect(html).toMatch(/<del>|cw-del/); expect(html).toMatch(/<del>|cw-del/);
}); });
test("renderReview: a pending proposal renders a blue block with data-proposal-id and Accept/Reject actions", () => { test("renderReview: a pending proposal renders a blue block with data-proposal-id and ✓/✗ actions", () => {
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }]; const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
const html = renderReview("hello", "hello", [], proposals); const html = renderReview("hello", "hello", [], proposals);
expect(html).toContain('class="cw-proposal"'); expect(html).toContain('class="cw-proposal"');
@@ -375,55 +375,6 @@ describe("renderReview", () => {
const b = renderReview(doc, doc, [], proposals); const b = renderReview(doc, doc, [], proposals);
expect(a).toBe(b); expect(a).toBe(b);
}); });
test("renderReview renders an applied proposal ONCE, not also as a landed diff (INV-50)", () => {
const baseline = "# T\n\nThe brown fox.\n";
const current = "# T\n\nThe red fox.\n"; // proposal already optimistically applied
const proposals: ProposalView[] = [{
id: "pr_1",
anchorStart: current.indexOf("The red fox."),
anchorEnd: current.indexOf("The red fox.") + "The red fox.".length,
replaced: "The brown fox.", // original
replacement: "The red fox.",
}];
const html = renderReview(baseline, current, [], proposals);
// exactly one proposal block
expect((html.match(/cw-proposal/g) ?? []).length).toBe(1);
// the applied paragraph is NOT also emitted as a word-merged changed block
expect(html).not.toContain("<del>brown</del>"); // no double-render of the change as a baseline diff
});
test("renderReview keeps author coloring aligned on a block AFTER a length-changing pending proposal (INV-49/50)", () => {
// block 1 has a pending proposal whose applied text is MUCH LONGER than its
// original; block 2 carries a Claude authorship span (in currentText coords).
const baseline = "one short.\n\ntwo stable here.\n";
const current = "one MUCH LONGER REPLACED TEXT.\n\ntwo stable here.\n";
const proposals: ProposalView[] = [{
id: "pr_1",
anchorStart: current.indexOf("one MUCH LONGER REPLACED TEXT."),
anchorEnd: current.indexOf("one MUCH LONGER REPLACED TEXT.") + "one MUCH LONGER REPLACED TEXT.".length,
replaced: "one short.",
replacement: "one MUCH LONGER REPLACED TEXT.",
original: "one short.",
}];
const sStart = current.indexOf("stable");
const authorSpans = [{ start: sStart, end: sStart + "stable".length, author: "claude" as const }];
const html = renderReview(baseline, current, authorSpans, proposals);
// the Claude coloring wraps "stable" exactly — not shifted by the proposal's length delta.
expect(html).toMatch(/<span class="cw-by-claude">stable<\/span>/);
});
test("proposalBlockHtml renders Accept/Reject controls with dropdown carets (#64)", () => {
const html = renderReview("a\n", "b\n", [], [
{ id: "pr_1", anchorStart: 0, anchorEnd: 1, replaced: "a", replacement: "b" },
]);
expect(html).toMatch(/data-action="accept"/);
expect(html).toMatch(/data-action="reject"/);
expect(html).toMatch(/data-action="acceptAll"/);
expect(html).toMatch(/data-action="rejectAll"/);
expect(html).toMatch(/Accept/);
expect(html).toMatch(/Reject/);
});
}); });
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel"; import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
+1 -25
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath"; import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath";
describe("isUnderRoot", () => { describe("isUnderRoot", () => {
const root = "/a/vscode-cowriting-plugin/sandbox"; const root = "/a/vscode-cowriting-plugin/sandbox";
@@ -58,27 +58,3 @@ describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)",
expect(msg).not.toMatch(/select some text/i); expect(msg).not.toMatch(/select some text/i);
}); });
}); });
describe("routeEdit (single Ask-Claude-to-Edit gesture)", () => {
const focused = { hasUri: false, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false };
it("non-empty selection in the focused editor → selection", () => {
expect(routeEdit(focused)).toBe("selection");
});
it("empty selection in the focused editor → document", () => {
expect(routeEdit({ ...focused, selectionEmpty: true })).toBe("document");
});
it("no active editor → document", () => {
expect(routeEdit({ ...focused, hasActiveEditor: false, selectionEmpty: true })).toBe("document");
});
it("tab right-click on a doc that ISN'T the focused editor → document (no selection to act on)", () => {
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false })).toBe(
"document",
);
});
it("tab right-click on the focused editor with a selection → selection", () => {
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: true, hasActiveEditor: true, selectionEmpty: false })).toBe(
"selection",
);
});
});