Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5645b926a3 |
+16
-65
@@ -18,16 +18,6 @@
|
||||
"onStartupFinished"
|
||||
],
|
||||
"contributes": {
|
||||
"configuration": {
|
||||
"title": "Cowriting",
|
||||
"properties": {
|
||||
"cowriting.liveProgress.revealOutput": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams."
|
||||
}
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "cowriting.showClineSdkInfo",
|
||||
@@ -59,21 +49,6 @@
|
||||
"title": "Apply Agent Edit (internal seam)",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"title": "Ask Claude to Edit",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.submit",
|
||||
"title": "Ask Claude to Edit",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.cancel",
|
||||
"title": "Cancel",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"title": "Ask Claude to Edit Selection",
|
||||
@@ -133,29 +108,13 @@
|
||||
"command": "cowriting.rejectProposal",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.submit",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.cancel",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "false"
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.acceptAllProposals",
|
||||
@@ -171,8 +130,13 @@
|
||||
],
|
||||
"editor/title/context": [
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"when": "resourceLangId == markdown",
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "editorHasSelection && resourceLangId == markdown",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "!editorHasSelection && resourceLangId == markdown",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
@@ -190,8 +154,13 @@
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
@@ -205,11 +174,6 @@
|
||||
"command": "cowriting.reply",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.threads"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.submit",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.askClaude"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/title": [
|
||||
@@ -222,11 +186,6 @@
|
||||
"command": "cowriting.reopenThread",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.cancel",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.askClaude"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -234,14 +193,7 @@
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"key": "ctrl+alt+r",
|
||||
"mac": "cmd+alt+r",
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"key": "ctrl+alt+e",
|
||||
"mac": "cmd+alt+e",
|
||||
"when": "editorTextFocus && editorLangId == markdown"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -250,8 +202,7 @@
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean:e2e": "node -e \"require('fs').rmSync('out/test',{recursive:true,force:true})\"",
|
||||
"pretest:e2e": "npm run build && npm run clean:e2e && tsc -p tsconfig.e2e.json",
|
||||
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
|
||||
"test:e2e": "node ./out/test/e2e/runTest.js",
|
||||
"smoke:live": "npm run build && node scripts/smoke-live-turn.mjs",
|
||||
"vscode:prepublish": "node esbuild.mjs"
|
||||
|
||||
@@ -10,15 +10,7 @@ console.log(`instruction: ${instruction}`);
|
||||
console.log(`text: ${text}`);
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
// #60: exercise the live-progress path against the real SDK — log each reduced
|
||||
// snapshot so the smoke shows streaming/activity/tokens, not just the result.
|
||||
const result = await runEditTurn(instruction, text, {
|
||||
onProgress: (s) => {
|
||||
const bits = [s.phase === "tool" ? `tool:${s.tool}` : s.phase, `${s.chars}c`];
|
||||
if (s.tokens) bits.push(`${s.tokens}tok`);
|
||||
console.log(` progress: ${bits.join(" ")}`);
|
||||
},
|
||||
});
|
||||
const result = await runEditTurn(instruction, text);
|
||||
console.log(`replacement: ${JSON.stringify(result.replacement)}`);
|
||||
console.log(`model: ${result.model}`);
|
||||
console.log(`sessionId: ${result.sessionId}`);
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# Session 0048.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T08-45 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T08-58 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
**#40 (task, P3)** — restore exact author attribution on undo/redo (follow-up to
|
||||
#38). Anchor: leaf `task` → §4.3 R2 (no design gate). Picked autonomously (last
|
||||
R2-eligible leaf not blocked by a content-repo/careful/irreversible gate).
|
||||
|
||||
#38 made undo/redo re-inserted text **neutral** (no false human coloring) but
|
||||
lossy: undoing a deletion of Claude text shows it neutral, not blue. #40 restores
|
||||
the **exact prior** attribution.
|
||||
|
||||
**Mechanism (engineering choice): text-keyed attribution snapshots.** Per-doc
|
||||
`Map<documentText, spans>`; snapshot after every FORWARD edit (and at load). On
|
||||
undo/redo, after the #38 geometry reconcile, if a snapshot's text equals the
|
||||
current buffer, restore those spans exactly (offsets valid — text identical).
|
||||
Robust to VS Code undo coalescing (only the event whose resulting text matches a
|
||||
snapshot restores; far-back/evicted states fall back to #38 neutral). Bounded
|
||||
history.
|
||||
|
||||
Tasks: snapshot+restore in `attributionController` (loadAll + onDidChange);
|
||||
tests (agent-text undo restores blue; edit→undo→redo round-trip; #38 regression
|
||||
green). No deploy pipeline (extension).
|
||||
|
||||
## Results
|
||||
|
||||
**#40 implemented but NOT shipped — verification-blocked.** On branch
|
||||
`s40-undo-provenance` (pushed, unmerged); issue #40 **kept open**.
|
||||
|
||||
- `attributionController.ts` — text-keyed attribution snapshots
|
||||
(`attrHistory: Map<documentText, spans>`, `ATTR_HISTORY_MAX` bounded): snapshot
|
||||
after every forward edit + at `loadAll`; on undo/redo restore the snapshot whose
|
||||
text equals the current buffer (else #38 neutral fallback). Robust to undo
|
||||
coalescing.
|
||||
- `test/e2e/suite/s40Provenance.test.ts` — agent-text-undo-restores-blue +
|
||||
edit→undo→redo round-trip.
|
||||
- **222 unit + typecheck green.**
|
||||
|
||||
**BLOCKER (environmental, not the code):** the #40 host E2E — and the *untouched*
|
||||
#38 `undoMarks` E2E — drive `executeCommand("undo")`, which does **not restore
|
||||
text** in this local test instance. Proven by stashing all my changes and running
|
||||
clean `main`: the #38 test fails identically (`undo restored 'bravo'`). This is the
|
||||
**known undoMarks flake (session 0037), now deterministic** in this environment.
|
||||
The usual remedy — clearing `.vscode-test/user-data` — is **permission-blocked**
|
||||
this session (`rm -rf` denied). Focusing the doc before `undo` (`showTextDocument`)
|
||||
did not help. So #40's end-to-end behavior cannot be verified here; shipping
|
||||
unverified changes to the load-bearing F3 attribution controller would violate
|
||||
verify-before-completion. Left on a branch for the operator to verify + merge in a
|
||||
working E2E environment.
|
||||
|
||||
**Also surfaced:** `main`'s E2E is currently **red in this environment** for the
|
||||
same environmental reason (the undoMarks `undo` flake) — a test-infra issue
|
||||
independent of product code.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Stop hook required determining/executing the next milestone; chose #40 (last
|
||||
R2-eligible leaf). Claimed 0048, clean `main`.
|
||||
2. Read #40 + #38 + the attribution change handler; chose text-keyed snapshots.
|
||||
3. TDD: wrote #40 E2E + implemented snapshot/restore; 222 unit green.
|
||||
4. E2E: 3 undo-driven tests failed at the `undo restored X` step. Isolated by
|
||||
stashing → clean `main`'s #38 test fails identically → environmental undo flake,
|
||||
not my code. Could not clear `.vscode-test` (permission-blocked).
|
||||
5. Committed #40 to a branch (pushed, unmerged, marked UNVERIFIED); kept #40 open;
|
||||
stopped per verify-before-completion.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Empty if none._
|
||||
|
||||
- **Did not merge #40** (driver call). Alternative: merge on unit-green + reasoning
|
||||
alone. Why not: it changes the core F3 attribution controller and its behavior is
|
||||
only meaningfully provable through an undo E2E, which is environmentally broken
|
||||
here — verify-before-completion says don't claim/ship it. Preserved on a branch
|
||||
for operator verification instead. (Reversible: just merge once verified.)
|
||||
+8
-5
@@ -1,17 +1,20 @@
|
||||
# Session 0059.0 — Transcript
|
||||
# Session 0048.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T06-01 (PST)
|
||||
> Start: 2026-06-13T08-45 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0059 for vscode-cowriting-plugin. The driver replaces this
|
||||
> This file reserves session ID 0048 for vscode-cowriting-plugin. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0059.0-TRANSCRIPT-2026-06-26T06-01--<end>.md form at session end.
|
||||
> SESSION-0048.0-TRANSCRIPT-2026-06-13T08-45--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
_(launch prompt not captured at claim time)_
|
||||
```
|
||||
/goal plan-and-execute #40 (restore exact author attribution on undo/redo — follow-up to #38)
|
||||
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# Session 0049.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T09-19 (PST)
|
||||
> Type: planning-and-executing
|
||||
> End: 2026-06-13T09-24 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
> Posture: autonomous (yolo)
|
||||
|
||||
## Launch prompt
|
||||
|
||||
```
|
||||
/goal plan-and-execute: pretest:e2e cleans stale compiled tests in out/test (stale *.test.js from other branches were running via the glob) — build hygiene follow-up to #54
|
||||
|
||||
```
|
||||
|
||||
## Plan
|
||||
|
||||
Build-hygiene follow-up to #54 (noted on that issue). `tsc -p tsconfig.e2e.json`
|
||||
emits to `out/` but never removes outputs for test sources absent on the current
|
||||
branch, so stale compiled `out/test/e2e/suite/*.test.js` from other branches get
|
||||
run by the suite glob (`**/*.test.js`) — this caused real cross-branch test
|
||||
confusion in session 0048 (a removed probe + the s40 branch's tests ran on an
|
||||
unrelated branch). Fix: `pretest:e2e` cleans `out/test` before recompiling.
|
||||
|
||||
- `package.json` — add `clean:e2e` (node `fs.rmSync('out/test', {recursive,force})`
|
||||
— avoids shell `rm` issues) and run it between `build` and `tsc` in `pretest:e2e`.
|
||||
Clean ONLY `out/test` (NOT `out/`, which holds the just-built esbuild bundle).
|
||||
- Verify: introduce a stale `out/test/.../zz.test.js`, run `pretest:e2e`, confirm
|
||||
it's gone + the E2E suite is green.
|
||||
|
||||
Trivial, ungated, verifiable; test-infra only. No deploy pipeline (extension).
|
||||
|
||||
## Results
|
||||
|
||||
**Shipped to `main`** — PR #56 (merged). `package.json`: new `clean:e2e`
|
||||
(`fs.rmSync('out/test',{recursive,force})`) run between `build` and `tsc` in
|
||||
`pretest:e2e`. Cleans only `out/test` (never the esbuild bundle in `out/`).
|
||||
Verified: a planted stale `out/test/.../zz.test.js` is removed by `pretest:e2e`;
|
||||
E2E green (73 passing + 1 pending [#38 undo-skip], both passes exit 0). Test-infra
|
||||
only; no product code.
|
||||
|
||||
Properly tracked under this session (0049) — closing the protocol gap where
|
||||
#54/#55 merged after 0048 had finalized.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. Stop hook pushed me to execute the one remaining ungated/verifiable item (the
|
||||
`out/`-clean follow-up I'd noted on #54). Claimed 0049 to track it cleanly.
|
||||
2. Confirmed `tsc` outDir → `out/test`; esbuild bundle → `out/extension.cjs`
|
||||
(separate), so cleaning `out/test` is safe.
|
||||
3. Implemented `clean:e2e`; verified by planting a stale compiled test (removed)
|
||||
+ full E2E green. Shipped PR #56.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Empty if none._
|
||||
|
||||
- _No low-confidence calls this session._ (Cleaning stale build output is
|
||||
unambiguously correct; scoped to `out/test` to protect the esbuild bundle.)
|
||||
@@ -1,76 +0,0 @@
|
||||
# Session 0050.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T17-43 (PST)
|
||||
> End: 2026-06-13T17-52 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — "When changing documents, we should decide what happens to the
|
||||
review pane of the current document, and if the document switched to should get a
|
||||
review experience or not"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed (origin/main). No in-flight sessions for the app.
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`); content
|
||||
repo `vscode-cowriting-plugin-content` cloned, with ~18 prior uncommitted
|
||||
capture drafts in `issues/`.
|
||||
- Prior frontier exhausted (session 0047 note): remaining backlog items gated;
|
||||
operator directing. This is a capture session, not a build.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Claimed** tracked-lite session ID **0050** (`claim-session-id.sh --type
|
||||
capture`). Resolved app + content repo (`resolve-app.py`).
|
||||
2. **Grounded** the ask by reading the code (Explore subagent over
|
||||
`src/trackChangesPreview.ts`, `diffViewController.ts`, `proposalController.ts`):
|
||||
confirmed the review pane is one-panel-per-doc keyed by URI
|
||||
(`trackChangesPreview.ts:45`) with **no** `onDidChangeActiveTextEditor`
|
||||
listener — so on document switch the pane **stays pinned** to its original doc
|
||||
(incidental, not designed) and the switched-to doc gets no review until
|
||||
`showTrackChangesPreview` is re-invoked. Per-doc review state (F6 baseline / F4
|
||||
proposals / F3 attribution) auto-creates on demand.
|
||||
3. **Sized** the single ask: genuine forks (follow / pin / close; auto-review /
|
||||
on-demand) + needs design before build → `type/feature` (R3), `priority/P2`.
|
||||
Captures the decision-to-be-made; option space goes in Solution notes,
|
||||
non-binding.
|
||||
4. **Drafted** `issues/review-pane-on-document-switch.md` in the content repo
|
||||
(working tree only, INV-8), full §5 template. Scanned clean for secrets
|
||||
(INV-3).
|
||||
5. **Operator approved** filing as-is (feature, P2) via AskUserQuestion.
|
||||
6. **Filed** → ensured labels (all pre-existing), then `capture-issues.sh` →
|
||||
**#57** (https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/57).
|
||||
|
||||
## Cut state
|
||||
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, nothing committed this
|
||||
session (capture touches only the content repo working tree). No PRs.
|
||||
- Content repo: new draft `issues/review-pane-on-document-switch.md` left
|
||||
uncommitted for the Author to publish/discard (INV-8), alongside the prior
|
||||
drafts already there. Not pushed by this skill.
|
||||
- Issue **#57** filed and open on the tracker.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — single clear ask, operator approved the framing and type before filing._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- One feature filed: **#57** (review-pane behavior on document switch, P2).
|
||||
- ~19 capture drafts now sit uncommitted in `vscode-cowriting-plugin-content` —
|
||||
yours to commit/publish (or discard) when convenient.
|
||||
- #57 needs a design before build (R3). No active plan frontier; operator to
|
||||
direct next.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. Candidate: brainstorm a Solution Design for **#57**
|
||||
(review-pane behavior on document switch) — settle the two coupled rules (outgoing
|
||||
pane fate; whether the switched-to doc auto-gets a review experience) before
|
||||
building. Other open: OQ-2 (F11 graduation), #32 (scroll-sync, needs design),
|
||||
#35 (repo rename), #40 (undo provenance, P3, branch `s40-undo-provenance`
|
||||
unmerged), #54 (undo still broken in sandbox).
|
||||
@@ -1,71 +0,0 @@
|
||||
# Session 0051.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-13T17-49 (PST)
|
||||
> End: 2026-06-13T17-58 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — two asks:
|
||||
1. "Undo doesn't undo Claude's edits"
|
||||
2. "When I make a change within a block that Claude has changed that I haven't
|
||||
approved, it doesn't show my changes"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed. Immediately followed capture session 0050 (#57).
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`); content
|
||||
repo cloned.
|
||||
|
||||
## Arc
|
||||
|
||||
1. **Claimed** tracked-lite session ID **0051**.
|
||||
2. **Grounded** both asks in code (Explore subagent):
|
||||
- Undo: Claude's edits are pending proposals; `proposalController.ts:303-310`
|
||||
shifts anchors on edit but ignores `e.reason === Undo` — proposal not
|
||||
reconciled on undo (accepted edits hit the doc and are undoable, but the
|
||||
pending proposal lingers).
|
||||
- Pending-block edits: a proposal freezes its "before" (`replaced: fp.text`,
|
||||
`proposalController.ts:81-95`); the block renders from current text but the
|
||||
proposal's frozen view (`trackChangesModel.ts:706-722`/`:769-805`) obscures
|
||||
live human edits in that block.
|
||||
3. **Checked duplication** against the undo cluster — #38 (wrong marks, closed),
|
||||
#40 (attribution color, open), #54 (undo E2E infra, open). Both new asks are
|
||||
distinct (undo failing to *reverse* the edit; live edits obscured by a pending
|
||||
proposal). Verified `type/bug` exists on the tracker.
|
||||
4. **Drafted** both as `type/bug` (P1) in the content repo working tree
|
||||
(`issues/undo-does-not-undo-claude-edits.md`,
|
||||
`issues/edits-in-pending-block-not-shown.md`), each framed as user-facing pain
|
||||
with repro steps + code diagnosis demoted to parenthetical. Scanned clean for
|
||||
secrets (INV-3).
|
||||
5. **Operator triage:** before filing, operator said "ignore both of these
|
||||
issues — you can delete the drafts." Both drafts **deleted**; nothing filed.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **Nothing filed** (INV-4 — no-actionable outcome after operator triage).
|
||||
- Both draft files removed from the content repo working tree.
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — operator explicitly directed dropping both asks._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- No issues filed this session. The two undo/pending-block asks were
|
||||
considered and intentionally dropped (recorded in memory
|
||||
`session-0051-capture-no-actionable.md` so they aren't re-raised as new).
|
||||
- Note: these remain *real* observed behaviors per the code reading — dropped on
|
||||
operator triage, not because they were invalid. If they resurface, the prior
|
||||
grounding is in the memory note.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. From session 0050: candidate is brainstorming a
|
||||
Solution Design for **#57** (review-pane behavior on document switch). Other
|
||||
open: OQ-2 (F11 graduation), #32 (scroll-sync), #35 (repo rename), #40 (undo
|
||||
provenance, P3, branch `s40-undo-provenance` unmerged), #54 (undo E2E infra).
|
||||
@@ -1,73 +0,0 @@
|
||||
# Session 0052.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-15T08-33 (PST)
|
||||
> End: 2026-06-15T08-41 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — two asks (second arrived mid-session):
|
||||
1. "the review window and markdown source file should have scroll location sync'd"
|
||||
2. "the claude change recommendation block (which user will accept or decline)
|
||||
should show the diff with the original"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57) and 0051
|
||||
(no-actionable).
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
|
||||
|
||||
## Arc
|
||||
|
||||
**Ask 1 — scroll-sync (duplicate, nothing filed):**
|
||||
1. Claimed tracked-lite session ID **0052**.
|
||||
2. Recognized scroll-sync as a known F10 follow-up; fetched **#32** — exact match
|
||||
(*"Scroll-sync the review preview with the source editor"*, open,
|
||||
`type/feature`, `priority/P3`, filed session 0031).
|
||||
3. Surfaced to operator; operator chose to **leave #32 at P3**. No new issue
|
||||
(INV-4 duplicate).
|
||||
|
||||
**Ask 2 — proposal block diff vs original (filed #58):**
|
||||
4. Grounded in code (Explore): the pending proposal block renders original +
|
||||
proposed as **two separate full blocks** (`<del>` whole-before + `<ins>`
|
||||
whole-after, `trackChangesModel.ts:715-716`) with **no word-level diff** — the
|
||||
changed-block rendering already uses `wordMergedMarkdown`/`diffWords`
|
||||
(`trackChangesModel.ts:431-438`) but `proposalBlockHtml()` doesn't.
|
||||
`ProposalView` already carries `replaced`+`replacement`
|
||||
(`proposalController.ts:81-95`).
|
||||
5. Sized `type/story`, P2 (reuses existing word-diff helper; distinct from #31
|
||||
placement / #47 granularity). Drafted
|
||||
`issues/proposal-block-shows-diff-with-original.md` (working tree only, secrets
|
||||
scanned).
|
||||
6. Operator approved as-is → ensured labels → filed **#58**
|
||||
(https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/58).
|
||||
|
||||
## Cut state
|
||||
|
||||
- **#58** filed (story, P2). Scroll-sync left as existing **#32** (P3) — nothing
|
||||
new filed for it.
|
||||
- Draft `issues/proposal-block-shows-diff-with-original.md` left uncommitted in
|
||||
the content repo for the Author to publish (INV-8).
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — operator directly resolved the duplicate and approved the #58 framing._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- One issue filed: **#58** (proposal block word-diff, P2).
|
||||
- Scroll-sync remains **#32** (P3, open, unchanged).
|
||||
- One uncommitted capture draft added to the content repo (joins the prior
|
||||
drafts) — yours to publish or discard.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. Standing candidate (from 0050): brainstorm a
|
||||
Solution Design for **#57** (review-pane behavior on document switch). Open work:
|
||||
#58 (proposal word-diff, P2, story), #32 (scroll-sync, P3), OQ-2 (F11
|
||||
graduation), #35 (repo rename), #40 (undo provenance, P3, branch
|
||||
`s40-undo-provenance` unmerged), #54 (undo E2E infra).
|
||||
@@ -1,66 +0,0 @@
|
||||
# Session 0053.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-15T10-23 (PST)
|
||||
> End: 2026-06-15T10-29 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — "The plugin is asking for access to other applications but works
|
||||
fine when I decline. It just shouldn't"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57), 0051
|
||||
(no-actionable), 0052 (#58 + scroll-sync dup).
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
|
||||
|
||||
## Arc
|
||||
|
||||
1. Claimed tracked-lite session ID **0053**.
|
||||
2. **Investigated** the macOS Automation/Apple-Events trigger (Explore): the
|
||||
extension's own source has **no** AppleScript/`osascript`/automation calls; the
|
||||
probable origin is **`@cline/sdk`/`@cline/core`** during agent activation
|
||||
(likely local Claude Code hub-discovery), which falls back gracefully when
|
||||
denied — matching "works fine when declined." Framed as a lead, not a settled
|
||||
root cause.
|
||||
3. **Sized** `type/bug` (declared defect: unwanted OS permission request).
|
||||
Drafted `issues/spurious-automation-permission-prompt.md` (working tree only,
|
||||
secrets scanned) with repro steps + expected/actual + the upstream caveat.
|
||||
4. **Operator set priority P1** (trust/launch-optics over the harmless functional
|
||||
impact); updated the WSJF line accordingly.
|
||||
5. **Filed #59.** capture-issues.sh `--type` rejects `bug` (known #124 taxonomy
|
||||
gap), so filed with `--type task` then swapped the label to `type/bug` via the
|
||||
Gitea API (deleted type/task, added type/bug) — verified `['priority/P1',
|
||||
'type/bug']` (INV-2 holds).
|
||||
→ https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/59
|
||||
|
||||
## Cut state
|
||||
|
||||
- **#59** filed (bug, P1), label corrected to exactly `type/bug`.
|
||||
- Draft `issues/spurious-automation-permission-prompt.md` left uncommitted in the
|
||||
content repo for the Author to publish (INV-8).
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — operator set the priority directly and approved the framing._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- One issue filed: **#59** (spurious macOS automation prompt, bug, **P1**).
|
||||
- The cause is likely upstream in `@cline/sdk` — the fix may be a config/flag or
|
||||
dependency update, not necessarily this repo's code.
|
||||
- Reminder: bug capture needs the file-as-task-then-relabel workaround until
|
||||
plugin #124 (capture can't type bugs) is fixed.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. Standing candidate (from 0050): brainstorm a
|
||||
Solution Design for **#57** (review-pane behavior on document switch). Open work:
|
||||
**#59** (automation prompt, bug P1 — may be upstream), #58 (proposal word-diff,
|
||||
story P2), #32 (scroll-sync, P3), OQ-2 (F11 graduation), #35 (repo rename), #40
|
||||
(undo provenance, P3), #54 (undo E2E infra).
|
||||
@@ -1,66 +0,0 @@
|
||||
# Session 0054.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-15T10-42 (PST)
|
||||
> End: 2026-06-15T10-48 (PST)
|
||||
> Type: capture
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-capture` — "See the Claude output/progress in the 'asking Claude…' status"
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean, pushed. Follows capture sessions 0050 (#57), 0051
|
||||
(no-actionable), 0052 (#58 + scroll-sync dup), 0053 (#59).
|
||||
- Tracker `benstull/vscode-cowriting-plugin` (host `git.benstull.org`).
|
||||
|
||||
## Arc
|
||||
|
||||
1. Claimed tracked-lite session ID **0054**.
|
||||
2. **Grounded** (Explore): the "Cowriting: asking Claude…" status is an opaque
|
||||
`withProgress` notification (`extension.ts:232-233`,
|
||||
`trackChangesPreview.ts:232-233`) awaiting `agent.run()` as one black-box
|
||||
promise (`liveTurn.ts:52`). Key enabler: `@cline/sdk` already emits streaming
|
||||
events (`assistant-text-delta`, `tool-started/updated/finished`,
|
||||
`usage-updated`) via `agent.subscribe()` / an `onEvent` hook, but the extension
|
||||
constructs the Agent with **no hooks** (`liveTurn.ts:47-51`) and discards it.
|
||||
3. **Sized** `type/feature` — implementable by subscribing to existing events, but
|
||||
a real design fork on the surface (notification text vs. OutputChannel vs.
|
||||
status bar vs. webview relay) and content (text/tool/usage/reasoning) → design
|
||||
first (R3). Drafted `issues/show-live-claude-progress.md` (working tree only,
|
||||
secrets scanned).
|
||||
4. **Operator set priority** — chose "adjust priority" → **P1** (opaque wait hurts
|
||||
every turn). Updated WSJF line.
|
||||
5. **Filed #60** (`type/feature`, P1).
|
||||
→ https://git.benstull.org/benstull/vscode-cowriting-plugin/issues/60
|
||||
|
||||
## Cut state
|
||||
|
||||
- **#60** filed (feature, P1).
|
||||
- Draft `issues/show-live-claude-progress.md` left uncommitted in the content repo
|
||||
for the Author to publish (INV-8).
|
||||
- Session repo `vscode-cowriting-plugin`: clean `main`, no commits this session.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_None — operator set the priority directly and approved the framing._
|
||||
|
||||
## Operator plate
|
||||
|
||||
- One issue filed: **#60** (live Claude progress, feature, **P1**).
|
||||
- Implementation is largely "subscribe to SDK events already available + pick the
|
||||
progress surface" — needs a small design first (R3).
|
||||
- One uncommitted capture draft added to the content repo — yours to publish or
|
||||
discard.
|
||||
|
||||
## Next session prompt
|
||||
|
||||
`/goal` — operator to direct. Capture run 0050–0054 filed #57, #58, #59, #60
|
||||
(scroll-sync = existing #32). Two P1s now open (**#59** automation prompt,
|
||||
**#60** live progress) plus standing #57 (review-pane on doc switch, design
|
||||
candidate). Other open: #58 (proposal word-diff, P2), #32 (scroll-sync P3), OQ-2
|
||||
(F11 graduation), #35 (repo rename), #40 (undo provenance P3), #54 (undo E2E
|
||||
infra).
|
||||
@@ -1,105 +0,0 @@
|
||||
# Session 0055.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-22T23-19 (PST)
|
||||
> End: 2026-06-26T04-23 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-brainstorming highest priority ticket`
|
||||
|
||||
## Plan
|
||||
|
||||
Brainstorming session: explore the highest-priority open ticket and write its
|
||||
Solution-Design spec. Highest-priority that fits a brainstorming session (spec
|
||||
output; a feature needs a design per §4.3 R3) was **#60 — Show Claude's live
|
||||
output/progress during the "asking Claude…" status** (P1 feature). #59 (P1 bug)
|
||||
is coding-session work, not brainstorming.
|
||||
|
||||
Output: `specs/coauthoring-live-progress.md` (Solution Design), submitted graduated
|
||||
at finalize to the content repo's `specs/` collection.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Clean, pushed `main` (`24e329e`). No in-flight sessions (next ID 0055).
|
||||
- Two P1 tickets tied for highest priority: **#59** (P1 bug — spurious macOS
|
||||
"control other applications" prompt) and **#60** (P1 feature — live turn
|
||||
progress). Brainstorming produces a spec; a feature needs a design (§4.3 R3),
|
||||
so #60 was the fit. #59 (bug) → a coding session.
|
||||
- Highest INV in use across the content-repo specs: **INV-42**.
|
||||
|
||||
## Session arc (turn-by-turn)
|
||||
|
||||
1. **Gate + classify.** Launch prompt classified as **brainstorming**; ran
|
||||
`wgl-brainstorming`.
|
||||
2. **Claim (peek → gate → claim).** `claim-session-id.sh --dry-run` → no in-flight
|
||||
sessions, next ID **0055**. Claimed `--type brainstorming` (placeholder at
|
||||
`16cca30`). Verified clean pushed `main` baseline.
|
||||
3. **Orient.** Surveyed the open tracker. Picked **#60** (P1 feature, design
|
||||
needed) over #59 (P1 bug, coding-session work). Read #60 in full.
|
||||
4. **Ground in code (read-only).** Read `liveTurn.ts` (`runEditTurn` →
|
||||
`agent.run()` as one opaque promise; module is deliberately vscode-free), both
|
||||
call sites (`extension.ts:232` editSelection, `trackChangesPreview.ts:232`
|
||||
preview `askClaude` — both wrap the turn in `withProgress("asking Claude…")`),
|
||||
the injectable `editTurn` seam (trackChangesPreview.ts:56, for host-E2E
|
||||
stubbing), and the `@cline/sdk` event API: `@cline/agents` `Agent` exposes
|
||||
`subscribe(listener) → unsubscribe`, `run()`, `abort()`; `AgentRuntimeEvent`
|
||||
(in `@cline/shared/dist/agent.d.ts`) carries `assistant-text-delta`
|
||||
(+`accumulatedText`), `tool-started|updated|finished`, `usage-updated`,
|
||||
lifecycle events. The enabler the issue cites is real.
|
||||
5. **Brainstorm forks (superpowers:brainstorming).** Skipped the visual companion
|
||||
(choices are conceptual/native-UI, not visual mockups). Presented three forks
|
||||
via AskUserQuestion; operator picked all three recommendations:
|
||||
- **Surface** → notification activity-line + token count **and** a shared
|
||||
`"Cowriting"` OutputChannel streaming full assistant text. (Rejected: webview
|
||||
relay can't cover the editSelection path; status-bar-only; OutputChannel-
|
||||
primary.)
|
||||
- **Content** → activity + token count (reasoning text not surfaced).
|
||||
- **Cancellation** → `cancellable` notification → `agent.abort()` →
|
||||
"cancelled", proposes nothing (reflect + cancel button).
|
||||
6. **Design presented + approved.** Three-unit architecture (pure
|
||||
`turnProgress.ts` reducer → `runEditTurn` extended with `onProgress` +
|
||||
`AbortSignal` → both call sites relay via a shared `liveProgressUi`); layering
|
||||
rule keeps `liveTurn.ts`/`turnProgress.ts` vscode-free; INV-43..47. Two
|
||||
sub-decisions (OutputChannel auto-reveal gated by a new setting; append-not-
|
||||
clear) confirmed. Operator: "design looks right."
|
||||
7. **Spec written + self-review.** `specs/coauthoring-live-progress.md` authored
|
||||
in house Solution-Design format. Self-review: no placeholders, INV-43..47 (no
|
||||
collision with ≤42; INV-8/21/39 are correct citations), internally consistent,
|
||||
single-feature scope.
|
||||
8. **Operator: "Implement it."** Per the pipeline, that's the brainstorm→coding
|
||||
handoff (one SPEC = one plan = one execution, §4.3). Finalized this
|
||||
brainstorming session (below); a fresh `wgl-planning-and-executing` session
|
||||
plans + builds #60.
|
||||
|
||||
## Cut state (at finalize)
|
||||
|
||||
- **Spec graduated.** `submit-spec.sh --status graduated` →
|
||||
`vscode-cowriting-plugin-content` `specs/coauthoring-live-progress.md` at
|
||||
`3bd1ae5`.
|
||||
- **Code repo:** clean `main`; no code changes this session (brainstorming).
|
||||
A local stray `specs/coauthoring-live-progress.md` remains untracked in the code
|
||||
repo (an `rm` cleanup was permission-denied; harmless — canonical copy is in the
|
||||
content repo).
|
||||
- **Memory:** added `session-0055-live-progress-spec-graduated.md` + index line.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **OutputChannel auto-reveal** (fires every Claude turn): decided to auto-`show(true)`
|
||||
(preserveFocus) on the *first* text delta, gated by a new setting
|
||||
`cowriting.liveProgress.revealOutput` (default `true`). Operator confirmed the
|
||||
design including this.
|
||||
- **OutputChannel history:** decided to *append* per-turn (with a header) rather
|
||||
than clear, so it doubles as a debug log. Operator confirmed.
|
||||
- **Stray local spec file:** `rm -rf specs/` was permission-denied; left the
|
||||
untracked local copy in the code repo (cosmetic; content repo holds the
|
||||
canonical copy).
|
||||
@@ -1,106 +0,0 @@
|
||||
# Session 0056.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T04-24 (PST)
|
||||
> End: 2026-06-26T04-54 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md`
|
||||
|
||||
(Continuation of brainstorming session 0055, which graduated the spec; the
|
||||
operator said "implement it" → this coding session.)
|
||||
|
||||
## Plan
|
||||
|
||||
Plan + execute **#60** (P1 feature — live turn progress) from the graduated
|
||||
Solution Design `coauthoring-live-progress.md`. Four cuts: (1) pure
|
||||
`turnProgress.ts` reducer + tests; (2) `runEditTurn` + `onProgress`/`AbortSignal`;
|
||||
(3) `liveProgressUi` + setting + both call sites; (4) host-E2E + smoke.
|
||||
Branch `s60-live-progress` → PR → main.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Clean `main` at session start (fast-forwarded 13 commits — the sessions/ repo
|
||||
is the code repo itself). Baseline: 222 unit green.
|
||||
- #60 has a graduated Solution Design → §4.3 R2(b) satisfied.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Claim + plan.** Claimed 0056 (no in-flight). Wrote the implementation plan
|
||||
with `superpowers:writing-plans` → `docs/superpowers/plans/2026-06-26-live-turn-progress.md`
|
||||
(self-reviewed: full spec coverage, no placeholders). Grounded in the exact SDK
|
||||
types (`AgentToolCallPart.toolName`, `AgentUsage.inputTokens+outputTokens`), the
|
||||
vitest/host-E2E patterns, and the injectable `editTurn` seam. Branch
|
||||
`s60-live-progress`.
|
||||
2. **Task 1 — pure reducer.** `src/turnProgress.ts` + `test/turnProgress.test.ts`
|
||||
(13 cases). Fixed the type-only event import: `AgentRuntimeEvent` is exported
|
||||
from **`@cline/shared`**, not `@cline/sdk` (which doesn't re-export it) — caught
|
||||
by typecheck; amended the commit.
|
||||
3. **Task 2 — `runEditTurn`.** Added `RunEditTurnOptions {onProgress, signal}`;
|
||||
subscribes to the agent, folds events through the reducer, wires `signal` →
|
||||
`agent.abort()`; `finally` unsubscribes. Mocked-agent unit tests (vitest
|
||||
`doMock` intercepts the dynamic import).
|
||||
4. **Task 3 — host relay.** `src/liveProgressUi.ts` (notification line +
|
||||
"Cowriting: Claude" OutputChannel, reveal gated by setting) + `package.json`
|
||||
`contributes.configuration` `cowriting.liveProgress.revealOutput`.
|
||||
5. **Tasks 4+5 — both call sites.** `editSelection` (extension.ts) and preview
|
||||
`askClaude`/`runEditAndPropose` (trackChangesPreview.ts) made cancellable,
|
||||
relaying via the shared `liveProgressUi`; widened the `EditTurn` seam to accept
|
||||
`opts?` (back-compat). Added `liveProgressUi` to `CowritingApi`.
|
||||
6. **Task 6 — host E2E.** `test/e2e/suite/liveProgress.test.ts`: progress is
|
||||
additive (INV-44), aborted turn proposes nothing (INV-47).
|
||||
7. **Task 7 — verify + smoke.** typecheck + 237 unit + build green; enhanced
|
||||
`scripts/smoke-live-turn.mjs` to log progress.
|
||||
8. **Subagent review.** Found a **Medium** (a pre-aborted `AbortSignal` was a
|
||||
no-op — `agent.abort()` runs before the SDK's AbortController exists, so the
|
||||
turn ran to completion) + a **Low** (a throwing `onProgress` relay could fail
|
||||
the turn). Fixed both: short-circuit (throw) before `run()` on a pre-aborted
|
||||
signal; wrap the relay in try/catch. Strengthened the unit + E2E to prove it.
|
||||
9. **Ship.** Pushed; opened PR #61; operator approved merge despite the
|
||||
concurrency (below). Squash-merged `644885c`; issue #60 auto-closed.
|
||||
|
||||
## ⚠️ Concurrency event (mid-session)
|
||||
|
||||
Partway through, the shared working tree gained **uncommitted changes this session
|
||||
did NOT make** — a `cowriting.edit` / `routeEdit` reach refactor across
|
||||
`src/workspacePath.ts`, `test/workspacePath.test.ts`, and the menu/`routeEdit`
|
||||
parts of `package.json` + `src/extension.ts`. It broke 3 E2E (`f12Reach` ×2,
|
||||
`f11Toolbar` ×1) that still expect the old `editSelection`/`editDocument` command
|
||||
surface. Handling: committed **nothing** of it, discarded **nothing**; verified
|
||||
#60 green in isolation by stashing it (by path, reversible) — 237 unit + both #60
|
||||
E2E pass, only the documented `F10 #38` undo-sandbox flake remains — then popped
|
||||
the stash to restore it. Surfaced to the operator, who chose to merge #60 (its 7
|
||||
commits are isolated; the PR diff contains none of the foreign WIP).
|
||||
|
||||
## Cut state (at finalize)
|
||||
|
||||
- **#60 merged** to main (PR #61 squash `644885c`); issue #60 closed.
|
||||
- **Plan archived:** `submit-plan.sh` → content repo `plans/2026-06-26-live-turn-progress.md` (`22ec57e`).
|
||||
- **Foreign refactor WIP:** still uncommitted in the working tree, untouched —
|
||||
for whoever is doing it to continue/commit (it rebases onto the new main).
|
||||
- **Local tree:** left on branch `s60-live-progress` (merged) DELIBERATELY — a
|
||||
`checkout main`/`pull` would have disturbed the foreign uncommitted WIP. Local
|
||||
main not synced; origin/main has #60. Operator should reconcile the local tree
|
||||
+ the in-flight refactor.
|
||||
- **Memory:** added `session-0056-60-live-progress-shipped.md` + index line.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/wgl-planning-and-executing reconcile the local tree (sync main; land/branch the in-flight cowriting.edit/routeEdit refactor), then pick the next item (open: #59 P1 bug, #57, #58, #32, #35, #40, OQ-2)
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Left local on `s60-live-progress` (did not sync main):** to avoid disturbing
|
||||
the concurrent uncommitted refactor in the shared tree. Low confidence this is
|
||||
the tidiest end state, but it is the safest for the foreign WIP. Operator to
|
||||
reconcile.
|
||||
- **Token field for the activity line:** used `inputTokens + outputTokens` from
|
||||
`AgentUsage` (no `totalTokens` field exists). Cache tokens excluded.
|
||||
- **OutputChannel append-not-clear + auto-reveal gating:** per spec §3.5; carried
|
||||
from session 0055's confirmed sub-decisions.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Session 0057.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T04-35 (PST)
|
||||
> Type: executing-plans
|
||||
> Posture: yolo
|
||||
> Claude-Session: 3d66a467-8026-472d-9693-52a37939d493
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0057 for vscode-cowriting-plugin. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0057.0-TRANSCRIPT-2026-06-26T04-35--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
_(launch prompt not captured at claim time)_
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Appended as the session runs; surfaced at
|
||||
finalize. Empty if none._
|
||||
@@ -1,85 +0,0 @@
|
||||
# Session 0058.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T05-12 (PST)
|
||||
> End: 2026-06-26T05-58 (PST)
|
||||
> Type: brainstorming
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Operator opened by asking what was in the Gitea backlog, then directed a
|
||||
brainstorming session for a new feature: *"We should also show the proposed change
|
||||
in the Markdown file in the same way. The proposed change in the markdown file
|
||||
should be human-editable, show the diff just like it would after the change is
|
||||
completed in both the markdown file and the webview as the change is made. The
|
||||
diff in both the markdown file and preview webview should be exactly as they will
|
||||
be shown if the edit is accepted. Create a new working tree in a directory other
|
||||
than main for this work."* — extended mid-session with: *"replace the checkbox
|
||||
icon and 'x' icon on the proposed change as 'Accept' and 'Reject'. Each should
|
||||
have an arrow next to them for a dropdown that is 'Accept All' or 'Reject All'.
|
||||
One should be able to accept, reject, accept all, or reject all from either the
|
||||
markdown file or the web preview file."*
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Branch `main`, clean/pushed baseline (after FF). Latest shipped work: #60 live
|
||||
turn progress (session 0056, PR #61). Session 0057 (a `cowriting.edit`/`routeEdit`
|
||||
refactor) still **in-flight** sharing the checkout.
|
||||
- Backlog at session start (8 open): #59 (P1 bug, Apple-Events prompt), #54/#57/#58
|
||||
(P2), #32/#35/#40 (P3), #62 (untriaged).
|
||||
|
||||
## Turn-by-turn arc
|
||||
|
||||
1. **Backlog query** — listed the 8 open Gitea issues for `benstull/vscode-cowriting-plugin`
|
||||
by priority; flagged #62 as the only untriaged item.
|
||||
2. **Session routing** — operator's feature request was feature-shaped *and*
|
||||
reversed a locked invariant (F10/INV-32 "clean editor"), so the session type
|
||||
was ambiguous. Asked; operator chose **brainstorm a spec first**. Routed via
|
||||
`wgl-brainstorming`; claimed session **0058**.
|
||||
3. **Isolation** — session 0057 still in-flight on the shared checkout → created an
|
||||
isolated worktree `vscode-cowriting-plugin-s58` on branch
|
||||
`s58-inline-editor-diff` off `origin/main` (§5.4).
|
||||
4. **Exploration** — dispatched an Explore agent that mapped five areas: webview
|
||||
render engine (`trackChangesModel.ts`), F4 proposal model + seam, the F10
|
||||
clean-editor decision (zero editor decorations remain), controllers/wiring, and
|
||||
commands/toolbar. Key constraint surfaced: **VS Code text editors cannot host
|
||||
editable phantom text** — anything editable in the editor is buffer content;
|
||||
decorations only style existing text or inject *non-editable* before/after
|
||||
content.
|
||||
5. **Four design forks (AskUserQuestion, with ASCII previews):**
|
||||
- Editor model → **optimistic apply + decorations**.
|
||||
- Timing → **on proposal** (not live token-stream into the editor).
|
||||
- Editor affordance → **CodeLens `Accept ▾ / Reject ▾` + QuickPick dropdown**.
|
||||
- (Operator-added) Control parity → Accept/Reject/Accept-all/Reject-all from
|
||||
both surfaces; ✓/✗ superseded.
|
||||
6. **Design presented** section-by-section; operator approved with "keep going"
|
||||
(nodded the two flagged sub-decisions: dedicated `EditorProposalController`;
|
||||
allow save of pending text).
|
||||
7. **Anchor + spec** — filed Feature **#64** (`type/feature`, `priority/P2`) since
|
||||
no tracker anchor existed; wrote `specs/coauthoring-inline-editor-diff.md`
|
||||
(INV-48..54, reversing INV-10/INV-32). Self-review clean. Operator **approved**.
|
||||
|
||||
## Cut state
|
||||
|
||||
- Spec **graduated** → content repo `vscode-cowriting-plugin-content` at `80d5d1a`
|
||||
(`specs/coauthoring-inline-editor-diff.md`).
|
||||
- Feature **#64** filed + labelled.
|
||||
- Worktree `vscode-cowriting-plugin-s58` (branch `s58-inline-editor-diff`) stands
|
||||
ready for the downstream planning-and-executing session; no code committed this
|
||||
session (brainstorming output is the spec only).
|
||||
- Memory updated: `f12-inline-editor-diff-spec-graduated.md` + MEMORY.md index.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
No autonomous low-confidence calls — every fork was decided live with the operator
|
||||
via AskUserQuestion, and the spec was operator-approved before submission. (The
|
||||
two design sub-decisions — dedicated controller, allow-save-of-pending — were
|
||||
flagged in the presented design and nodded by the operator.)
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/goal Plan-and-execute #64 (F12 inline editable proposed-change diff in the Markdown editor) from specs/coauthoring-inline-editor-diff.md, in worktree vscode-cowriting-plugin-s58 (branch s58-inline-editor-diff); follow the spec's 5-slice Delivery Plan
|
||||
```
|
||||
@@ -142,38 +142,5 @@
|
||||
},
|
||||
"0048": {
|
||||
"title": ""
|
||||
},
|
||||
"0049": {
|
||||
"title": ""
|
||||
},
|
||||
"0050": {
|
||||
"title": ""
|
||||
},
|
||||
"0051": {
|
||||
"title": ""
|
||||
},
|
||||
"0052": {
|
||||
"title": ""
|
||||
},
|
||||
"0053": {
|
||||
"title": ""
|
||||
},
|
||||
"0054": {
|
||||
"title": ""
|
||||
},
|
||||
"0055": {
|
||||
"title": ""
|
||||
},
|
||||
"0056": {
|
||||
"title": ""
|
||||
},
|
||||
"0057": {
|
||||
"title": ""
|
||||
},
|
||||
"0058": {
|
||||
"title": ""
|
||||
},
|
||||
"0059": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,19 @@ interface DocAttribution {
|
||||
* orphans on reload).
|
||||
*/
|
||||
hadAttributions: boolean;
|
||||
/**
|
||||
* #40: text-keyed attribution snapshots for exact provenance restoration on
|
||||
* undo/redo. Maps a document-text state → the live spans at that state (offsets
|
||||
* valid for that exact text). Captured after every forward edit + at load;
|
||||
* consulted on undo/redo to restore the matching state's spans. Bounded (oldest
|
||||
* evicted) — a far-back/evicted state falls back to the #38 neutral reconcile.
|
||||
*/
|
||||
attrHistory: Map<string, LiveSpan[]>;
|
||||
}
|
||||
|
||||
/** #40: cap on the per-doc attribution-snapshot history (oldest evicted). */
|
||||
const ATTR_HISTORY_MAX = 200;
|
||||
|
||||
export class AttributionController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly docs = new Map<string, DocAttribution>();
|
||||
@@ -90,7 +101,7 @@ export class AttributionController implements vscode.Disposable {
|
||||
private state(docPath: string): DocAttribution {
|
||||
let s = this.docs.get(docPath);
|
||||
if (!s) {
|
||||
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false };
|
||||
s = { docPath, spans: [], orphans: [], records: new Map(), hadAttributions: false, attrHistory: new Map() };
|
||||
this.docs.set(docPath, s);
|
||||
}
|
||||
return s;
|
||||
@@ -125,6 +136,9 @@ export class AttributionController implements vscode.Disposable {
|
||||
s.spans = coalesce(s.spans);
|
||||
if (artifact.attributions.length > 0) s.hadAttributions = true;
|
||||
}
|
||||
// #40: seed the snapshot history with the loaded state so undoing back to it
|
||||
// restores its exact attribution.
|
||||
this.snapshotAttribution(s, document.getText());
|
||||
this.render(document);
|
||||
}
|
||||
|
||||
@@ -140,6 +154,17 @@ export class AttributionController implements vscode.Disposable {
|
||||
|
||||
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
|
||||
|
||||
/** #40: snapshot the current spans keyed by the document's current text. */
|
||||
private snapshotAttribution(s: DocAttribution, text: string): void {
|
||||
s.attrHistory.delete(text); // re-insert at the end (recency order)
|
||||
s.attrHistory.set(text, s.spans.map((sp) => ({ ...sp })));
|
||||
while (s.attrHistory.size > ATTR_HISTORY_MAX) {
|
||||
const oldest = s.attrHistory.keys().next().value as string | undefined;
|
||||
if (oldest === undefined) break;
|
||||
s.attrHistory.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||
const docPath = this.keyOf(e.document);
|
||||
@@ -202,6 +227,18 @@ export class AttributionController implements vscode.Disposable {
|
||||
);
|
||||
}
|
||||
}
|
||||
// #40: exact provenance across history navigation. After a FORWARD edit,
|
||||
// snapshot the new state's spans. On UNDO/REDO, the geometry reconcile above
|
||||
// left re-inserted text neutral (#38); if a snapshot's text equals the current
|
||||
// buffer, restore that state's spans exactly (offsets are valid — identical
|
||||
// text) so Claude's restored text is blue again, the human's green. No match
|
||||
// (far-back/evicted state) keeps the #38 neutral fallback.
|
||||
if (isUndoRedo) {
|
||||
const restored = s.attrHistory.get(e.document.getText());
|
||||
if (restored) s.spans = restored.map((sp) => ({ ...sp }));
|
||||
} else {
|
||||
this.snapshotAttribution(s, e.document.getText());
|
||||
}
|
||||
if (s.spans.length > 0) s.hadAttributions = true;
|
||||
this.render(e.document);
|
||||
}
|
||||
|
||||
+14
-67
@@ -11,9 +11,7 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
|
||||
import { SidecarRouter } from "./sidecarRouter";
|
||||
import { DiffViewController } from "./diffViewController";
|
||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||
import { LiveProgressUi } from "./liveProgressUi";
|
||||
import { InlineAskController } from "./inlineAsk";
|
||||
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
||||
import { isAuthorable, selectionRejection } from "./workspacePath";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||
|
||||
@@ -25,24 +23,12 @@ export interface CowritingApi {
|
||||
diffViewController: DiffViewController;
|
||||
trackChangesPreviewController: TrackChangesPreviewController;
|
||||
sidecarRouter: SidecarRouter;
|
||||
liveProgressUi: LiveProgressUi;
|
||||
inlineAsk: InlineAskController;
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||
// --- POC command (Feature #2), unchanged ---
|
||||
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
|
||||
context.subscriptions.push(output);
|
||||
|
||||
// #60: shared live-progress UI (notification activity line + "Cowriting: Claude"
|
||||
// OutputChannel) for both Ask-Claude entry points.
|
||||
const liveProgressUi = new LiveProgressUi();
|
||||
context.subscriptions.push(liveProgressUi);
|
||||
|
||||
// The inline "Ask Claude to Edit" prompt — rendered at the selection/cursor via
|
||||
// the Comments API rather than the top-center QuickInput (see inlineAsk.ts).
|
||||
// Shared by both Ask-Claude entry points (editSelection + the preview's askClaude).
|
||||
const inlineAsk = new InlineAskController(context.subscriptions);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
||||
try {
|
||||
@@ -118,8 +104,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
context.extensionUri,
|
||||
attributionController,
|
||||
proposalController,
|
||||
liveProgressUi,
|
||||
inlineAsk,
|
||||
);
|
||||
context.subscriptions.push(trackChangesPreviewController);
|
||||
|
||||
@@ -225,12 +209,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
return;
|
||||
}
|
||||
if (!editor) return; // unreachable once reason is null, but narrows the type
|
||||
const document = editor.document;
|
||||
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
|
||||
// The instruction prompt opens INLINE at the selection (inlineAsk), not the
|
||||
// top-center QuickInput — anchored to the text Claude will edit.
|
||||
const instruction = await inlineAsk.prompt(document.uri, selection);
|
||||
const instruction = await vscode.window.showInputBox({
|
||||
prompt: "What should Claude do with the selection?",
|
||||
placeHolder: "e.g. tighten this paragraph",
|
||||
});
|
||||
if (!instruction) return;
|
||||
if (editor.selection.isEmpty) {
|
||||
void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first.");
|
||||
return;
|
||||
}
|
||||
const document = editor.document;
|
||||
const selection = editor.selection;
|
||||
const selectedText = document.getText(selection);
|
||||
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
|
||||
// can't skew it — the proposal renders wherever the target re-resolves.
|
||||
@@ -241,28 +230,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
const turnId = `turn-${Date.now().toString(36)}`;
|
||||
try {
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Cowriting: asking Claude…",
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
|
||||
async () => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
const ui = liveProgressUi.begin(instruction, progress, token);
|
||||
let turn;
|
||||
try {
|
||||
turn = await runEditTurn(instruction, selectedText, {
|
||||
onProgress: ui.onProgress,
|
||||
signal: ui.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
|
||||
if (token.isCancellationRequested) {
|
||||
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const turn = await runEditTurn(instruction, selectedText);
|
||||
if (turn.replacement === "") {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
|
||||
@@ -302,28 +273,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
}),
|
||||
);
|
||||
|
||||
// The single user-facing "Ask Claude to Edit" gesture (one command, one
|
||||
// keybinding, one menu entry). It routes to the selection flow (editSelection)
|
||||
// or the whole-document flow (editDocument) by selection/context — see
|
||||
// routeEdit. The two underlying commands stay registered for the host E2E
|
||||
// harness and the internal seams, but are hidden from the palette.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
const route = routeEdit({
|
||||
hasUri: !!uri,
|
||||
uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(),
|
||||
hasActiveEditor: !!editor,
|
||||
selectionEmpty: editor?.selection.isEmpty ?? true,
|
||||
});
|
||||
if (route === "selection") {
|
||||
await vscode.commands.executeCommand("cowriting.editSelection");
|
||||
} else {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument", uri);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Render threads + attributions for already-open editors, and on future opens.
|
||||
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||
if (isAuthorable(doc.uri.scheme)) {
|
||||
@@ -343,8 +292,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
diffViewController,
|
||||
trackChangesPreviewController,
|
||||
sidecarRouter,
|
||||
liveProgressUi,
|
||||
inlineAsk,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import * as vscode from "vscode";
|
||||
|
||||
/** Trim the reply text; an empty/whitespace-only instruction is "no instruction". */
|
||||
export function normalizeInstruction(text: string | undefined): string | undefined {
|
||||
const t = text?.trim();
|
||||
return t ? t : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Ask Claude to Edit" prompt, rendered INLINE at the selection (or cursor)
|
||||
* instead of the top-center QuickInput. VS Code's native pattern for "ask AI to
|
||||
* edit this" is Inline Chat — an input anchored in the editor at the target. The
|
||||
* stable-API equivalent for a third-party extension is the Comments API: a
|
||||
* transient comment thread whose reply box renders right at the range. (The
|
||||
* coauthoring THREADS feature already uses the Comments API via its own
|
||||
* controller — this is a SEPARATE, dedicated `cowriting.askClaude` controller so
|
||||
* the two never collide.)
|
||||
*
|
||||
* One prompt is live at a time; opening a new one cancels the previous. The
|
||||
* thread carries no comments — it is purely the inline input — and is disposed
|
||||
* as soon as the user submits or cancels.
|
||||
*/
|
||||
export class InlineAskController {
|
||||
private readonly controller: vscode.CommentController;
|
||||
private pending?: { resolve: (v: string | undefined) => void; thread: vscode.CommentThread };
|
||||
|
||||
constructor(disposables: vscode.Disposable[]) {
|
||||
this.controller = vscode.comments.createCommentController("cowriting.askClaude", "Ask Claude to Edit");
|
||||
// The reply box's prompt + placeholder (Comments API options).
|
||||
this.controller.options = {
|
||||
prompt: "Ask Claude to Edit",
|
||||
placeHolder: "e.g. tighten this paragraph",
|
||||
};
|
||||
disposables.push(
|
||||
this.controller,
|
||||
vscode.commands.registerCommand("cowriting.askClaude.submit", (r: vscode.CommentReply) =>
|
||||
this.finish(normalizeInstruction(r?.text)),
|
||||
),
|
||||
vscode.commands.registerCommand("cowriting.askClaude.cancel", () => this.finish(undefined)),
|
||||
// The controller itself disposes the live thread on extension teardown.
|
||||
new vscode.Disposable(() => this.finish(undefined)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the inline input anchored at `range` in `uri`'s editor and resolve with
|
||||
* the typed instruction (or `undefined` if cancelled / left empty). Replaces
|
||||
* any prompt already open.
|
||||
*/
|
||||
prompt(uri: vscode.Uri, range: vscode.Range): Promise<string | undefined> {
|
||||
// A fresh prompt supersedes any prior one (resolve it as cancelled).
|
||||
this.finish(undefined);
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
const thread = this.controller.createCommentThread(uri, range, []);
|
||||
thread.label = "Ask Claude to Edit";
|
||||
thread.canReply = true;
|
||||
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
|
||||
thread.contextValue = "askClaude";
|
||||
this.pending = { resolve, thread };
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve the live prompt (if any) and tear its thread down. */
|
||||
private finish(value: string | undefined): void {
|
||||
const p = this.pending;
|
||||
this.pending = undefined;
|
||||
if (!p) return;
|
||||
p.thread.dispose();
|
||||
p.resolve(value);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* liveProgressUi.ts — host-side relay of TurnProgress snapshots to VS Code UI
|
||||
* (#60, spec coauthoring-live-progress.md §3.4). The ONLY surfaces are the
|
||||
* existing withProgress notification (an activity line) and a dedicated
|
||||
* OutputChannel streaming the full assistant text — no new network/webview
|
||||
* surface (INV-45). vscode-only; all pure logic lives in turnProgress.ts.
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
import { formatProgressLine, type TurnProgressSnapshot } from "./turnProgress";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting: Claude";
|
||||
|
||||
export interface TurnUi {
|
||||
/** Pass to runEditTurn's opts.onProgress. */
|
||||
onProgress: (snapshot: TurnProgressSnapshot) => void;
|
||||
/** Pass to runEditTurn's opts.signal — fired when the user cancels the notification. */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export class LiveProgressUi {
|
||||
readonly channel: vscode.OutputChannel;
|
||||
|
||||
constructor() {
|
||||
this.channel = vscode.window.createOutputChannel(CHANNEL_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin one turn's UI. Writes the per-turn header to the channel and returns
|
||||
* the onProgress relay + an AbortSignal linked to the notification's cancel
|
||||
* token. The channel APPENDS (it doubles as a debug log of recent turns, spec
|
||||
* §3.5); it auto-reveals (without stealing focus) on the first streamed text,
|
||||
* gated by `cowriting.liveProgress.revealOutput`.
|
||||
*/
|
||||
begin(
|
||||
instruction: string,
|
||||
progress: vscode.Progress<{ message?: string }>,
|
||||
token: vscode.CancellationToken,
|
||||
): TurnUi {
|
||||
const controller = new AbortController();
|
||||
token.onCancellationRequested(() => controller.abort());
|
||||
this.channel.appendLine(`── asking: ${instruction} ──`);
|
||||
|
||||
let revealed = false;
|
||||
const reveal = (): void => {
|
||||
if (revealed) return;
|
||||
revealed = true;
|
||||
const cfg = vscode.workspace.getConfiguration("cowriting");
|
||||
if (cfg.get<boolean>("liveProgress.revealOutput", true)) this.channel.show(true);
|
||||
};
|
||||
|
||||
const onProgress = (s: TurnProgressSnapshot): void => {
|
||||
progress.report({ message: formatProgressLine(s) });
|
||||
if (s.textDelta) {
|
||||
this.channel.append(s.textDelta);
|
||||
reveal();
|
||||
}
|
||||
};
|
||||
|
||||
return { onProgress, signal: controller.signal };
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.channel.dispose();
|
||||
}
|
||||
}
|
||||
+11
-61
@@ -9,9 +9,6 @@
|
||||
* never bundled (esbuild keeps it external).
|
||||
*/
|
||||
|
||||
import type { TurnProgressSnapshot } from "./turnProgress";
|
||||
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
|
||||
|
||||
export interface EditTurnResult {
|
||||
replacement: string;
|
||||
model: string;
|
||||
@@ -19,19 +16,6 @@ export interface EditTurnResult {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for runEditTurn. Both new fields are purely additive observability /
|
||||
* control over the existing turn (INV-44): `onProgress` streams reduced progress
|
||||
* snapshots out; `signal` cancels the turn in. Neither touches the result path,
|
||||
* and neither pulls `vscode` into this module (INV-43 — `AbortSignal` is a web
|
||||
* standard, the progress snapshot is a domain type).
|
||||
*/
|
||||
export interface RunEditTurnOptions {
|
||||
modelId?: string;
|
||||
onProgress?: (snapshot: TurnProgressSnapshot) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
"You are a precise text editor embedded in VS Code.",
|
||||
"You will be given a piece of text and an instruction.",
|
||||
@@ -56,7 +40,7 @@ export function extractReplacement(outputText: string, selectedText: string): st
|
||||
export async function runEditTurn(
|
||||
instruction: string,
|
||||
selectedText: string,
|
||||
opts?: RunEditTurnOptions,
|
||||
opts?: { modelId?: string },
|
||||
): Promise<EditTurnResult> {
|
||||
const sdk = await import("@cline/sdk");
|
||||
const modelId = opts?.modelId ?? "sonnet";
|
||||
@@ -65,50 +49,16 @@ export async function runEditTurn(
|
||||
modelId,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
});
|
||||
|
||||
// Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
|
||||
// in via the AbortSignal (INV-47). agent.subscribe returns its unsubscribe fn.
|
||||
let state = createTurnProgressState();
|
||||
const unsubscribe = opts?.onProgress
|
||||
? agent.subscribe((event) => {
|
||||
const next = reduceTurnProgress(state, event);
|
||||
state = next.state;
|
||||
// Observability must never affect the result (INV-44): a throwing relay
|
||||
// is swallowed, not allowed to propagate into the SDK and fail the turn.
|
||||
if (next.snapshot) {
|
||||
try {
|
||||
opts.onProgress!(next.snapshot);
|
||||
} catch {
|
||||
/* progress is best-effort */
|
||||
}
|
||||
}
|
||||
})
|
||||
: undefined;
|
||||
const onAbort = () => agent.abort();
|
||||
opts?.signal?.addEventListener("abort", onAbort);
|
||||
|
||||
try {
|
||||
// A signal already aborted before the turn starts can't be honored by
|
||||
// agent.abort() (the SDK's AbortController isn't created until run()), so
|
||||
// short-circuit to the same aborted outcome the call site reflects (INV-47).
|
||||
if (opts?.signal?.aborted) {
|
||||
throw new Error("claude-code turn aborted: cancelled before start");
|
||||
}
|
||||
const result = await agent.run(
|
||||
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
|
||||
const result = await agent.run(
|
||||
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
|
||||
);
|
||||
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
|
||||
// (@cline/shared agent.d.ts) — "completed" is the success status.
|
||||
if (result.status !== "completed") {
|
||||
throw new Error(
|
||||
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
|
||||
"(is Claude Code installed and signed in?)",
|
||||
);
|
||||
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
|
||||
// (@cline/shared agent.d.ts) — "completed" is the success status. An aborted
|
||||
// turn (user cancel) falls into this throw; the call site reflects "cancelled".
|
||||
if (result.status !== "completed") {
|
||||
throw new Error(
|
||||
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
|
||||
"(is Claude Code installed and signed in?)",
|
||||
);
|
||||
}
|
||||
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
|
||||
} finally {
|
||||
unsubscribe?.();
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
|
||||
}
|
||||
|
||||
@@ -97,14 +97,6 @@ export interface Proposal {
|
||||
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
|
||||
*/
|
||||
granularity?: "block" | "single";
|
||||
/**
|
||||
* F12/#64 (INV-48): the pre-apply text, captured when a proposal is
|
||||
* optimistically applied to the buffer. `replacement` is now in the buffer and
|
||||
* `fp.text` re-anchors to it, so `original` is the only record of what to revert
|
||||
* to (revert-in-place) and what to show struck in the `<del>` half. Absent on a
|
||||
* proposal created but not yet optimistically applied (or older sidecars).
|
||||
*/
|
||||
original?: string;
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
@@ -260,8 +252,6 @@ export function serializeArtifact(a: Artifact): string {
|
||||
createdAt: p.createdAt,
|
||||
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
|
||||
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
|
||||
...(p.original !== undefined ? { original: p.original } : {}),
|
||||
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
|
||||
},
|
||||
p,
|
||||
),
|
||||
|
||||
+16
-56
@@ -16,16 +16,10 @@ import type { ProposalController } from "./proposalController";
|
||||
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
import type { LiveProgressUi } from "./liveProgressUi";
|
||||
import type { InlineAskController } from "./inlineAsk";
|
||||
import type { EditTurnResult } from "./liveTurn";
|
||||
|
||||
/**
|
||||
* F11: a host edit turn (selection/document text + instruction → rewrite).
|
||||
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
|
||||
* the arg is optional so existing test stubs that ignore it stay valid.
|
||||
*/
|
||||
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
|
||||
type EditTurn = (instruction: string, text: string) => Promise<EditTurnResult>;
|
||||
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
|
||||
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
|
||||
|
||||
@@ -59,9 +53,9 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
|
||||
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
|
||||
*/
|
||||
private editTurn: EditTurn = async (instruction, text, opts) => {
|
||||
private editTurn: EditTurn = async (instruction, text) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
return runEditTurn(instruction, text, opts);
|
||||
return runEditTurn(instruction, text);
|
||||
};
|
||||
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||
private turnSeq = 0;
|
||||
@@ -74,8 +68,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly proposals: ProposalController,
|
||||
private readonly liveProgressUi: LiveProgressUi,
|
||||
private readonly inlineAsk: InlineAskController,
|
||||
) {
|
||||
this.disposables.push(
|
||||
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
||||
@@ -222,55 +214,24 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the inline Ask-Claude input anchors: a range edit pins to its selection;
|
||||
* a whole-document edit pins to the editor's cursor line if this document is the
|
||||
* active editor, else to the document start.
|
||||
*/
|
||||
private editTargetAnchor(document: vscode.TextDocument, target: EditTarget): vscode.Range {
|
||||
if (target.kind === "range") {
|
||||
return new vscode.Range(document.positionAt(target.start), document.positionAt(target.end));
|
||||
}
|
||||
const active = vscode.window.activeTextEditor;
|
||||
if (active && active.document.uri.toString() === document.uri.toString()) {
|
||||
const line = active.selection.active.line;
|
||||
return new vscode.Range(line, 0, line, 0);
|
||||
}
|
||||
return new vscode.Range(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
||||
*/
|
||||
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
||||
// The instruction prompt opens INLINE (inlineAsk) at the target: the selected
|
||||
// range for a range edit, or the editor's cursor line / the document start for
|
||||
// a whole-document edit — never the top-center QuickInput.
|
||||
const anchor = this.editTargetAnchor(document, target);
|
||||
const instruction = await this.inlineAsk.prompt(document.uri, anchor);
|
||||
const instruction = await vscode.window.showInputBox({
|
||||
prompt:
|
||||
target.kind === "document"
|
||||
? "What should Claude do with the document?"
|
||||
: "What should Claude do with the selection?",
|
||||
placeHolder: "e.g. tighten the prose",
|
||||
});
|
||||
if (!instruction) return;
|
||||
try {
|
||||
const ids = await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Cowriting: asking Claude…",
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
const ui = this.liveProgressUi.begin(instruction, progress, token);
|
||||
try {
|
||||
return await this.runEditAndPropose(document, target, instruction, {
|
||||
onProgress: ui.onProgress,
|
||||
signal: ui.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
|
||||
if (token.isCancellationRequested) return [] as string[];
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
|
||||
() => this.runEditAndPropose(document, target, instruction),
|
||||
);
|
||||
if (ids.length === 0) {
|
||||
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
|
||||
@@ -297,7 +258,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
document: vscode.TextDocument,
|
||||
target: EditTarget,
|
||||
instruction: string,
|
||||
opts?: RunEditTurnOptions,
|
||||
): Promise<string[]> {
|
||||
const full = document.getText();
|
||||
// One turnId per gesture — the document case's N hunk-proposals all share it,
|
||||
@@ -307,13 +267,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
||||
if (target.kind === "range") {
|
||||
const selected = full.slice(target.start, target.end);
|
||||
const turn = await this.editTurn(instruction, selected, opts);
|
||||
const turn = await this.editTurn(instruction, selected);
|
||||
if (turn.replacement === "" || turn.replacement === selected) return [];
|
||||
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
||||
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
||||
return id ? [id] : [];
|
||||
}
|
||||
const turn = await this.editTurn(instruction, full, opts);
|
||||
const turn = await this.editTurn(instruction, full);
|
||||
const ids: string[] = [];
|
||||
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
|
||||
// granularity — one proposal per changed block (the unit a human reviews) —
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* turnProgress.ts — pure reduction of @cline/sdk Agent runtime events into a
|
||||
* small UI-facing progress snapshot (#60, spec coauthoring-live-progress.md §3.2).
|
||||
*
|
||||
* INV-43: vscode-free. INV-46: a pure function — no vscode, no SDK runtime
|
||||
* dependency (`AgentRuntimeEvent` is imported TYPE-only, so it is erased at
|
||||
* compile and never pulls the ESM SDK into the bundle). All event→state logic
|
||||
* lives here so it is unit-tested in isolation; the UI call sites only format and
|
||||
* relay snapshots.
|
||||
*/
|
||||
import type { AgentRuntimeEvent } from "@cline/shared";
|
||||
|
||||
export type TurnPhase = "thinking" | "writing" | "tool";
|
||||
|
||||
export interface TurnProgressSnapshot {
|
||||
phase: TurnPhase;
|
||||
/** present iff phase === "tool" — the running tool's name. */
|
||||
tool?: string;
|
||||
/** accumulated assistant-text length so far. */
|
||||
chars: number;
|
||||
/** running total tokens (input+output); undefined until the first usage event. */
|
||||
tokens?: number;
|
||||
/** the new assistant-text chunk since the last snapshot (for the OutputChannel). */
|
||||
textDelta?: string;
|
||||
}
|
||||
|
||||
export interface TurnProgressState {
|
||||
phase: TurnPhase;
|
||||
chars: number;
|
||||
tokens?: number;
|
||||
/** stack of tool names currently running (depth-tracked for overlap). */
|
||||
activeTools: string[];
|
||||
/** true once any assistant text has streamed (tool-finish then reverts to writing). */
|
||||
sawText: boolean;
|
||||
}
|
||||
|
||||
export function createTurnProgressState(): TurnProgressState {
|
||||
return { phase: "thinking", chars: 0, tokens: undefined, activeTools: [], sawText: false };
|
||||
}
|
||||
|
||||
function restingPhase(state: TurnProgressState): TurnPhase {
|
||||
if (state.activeTools.length) return "tool";
|
||||
return state.sawText ? "writing" : "thinking";
|
||||
}
|
||||
|
||||
function toSnapshot(state: TurnProgressState, textDelta?: string): TurnProgressSnapshot {
|
||||
return {
|
||||
phase: state.phase,
|
||||
tool: state.phase === "tool" ? state.activeTools[state.activeTools.length - 1] : undefined,
|
||||
chars: state.chars,
|
||||
tokens: state.tokens,
|
||||
textDelta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one SDK event into the state, returning the next state and the snapshot to
|
||||
* emit (snapshot undefined for events that don't change the surface).
|
||||
*/
|
||||
export function reduceTurnProgress(
|
||||
state: TurnProgressState,
|
||||
event: AgentRuntimeEvent,
|
||||
): { state: TurnProgressState; snapshot?: TurnProgressSnapshot } {
|
||||
switch (event.type) {
|
||||
case "run-started":
|
||||
case "turn-started": {
|
||||
const next: TurnProgressState = { ...state, phase: restingPhase(state) };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "assistant-text-delta": {
|
||||
const next: TurnProgressState = {
|
||||
...state,
|
||||
phase: state.activeTools.length ? "tool" : "writing",
|
||||
chars: event.accumulatedText.length,
|
||||
sawText: true,
|
||||
};
|
||||
return { state: next, snapshot: toSnapshot(next, event.text) };
|
||||
}
|
||||
case "assistant-reasoning-delta": {
|
||||
// Reasoning TEXT is not surfaced (operator fork); collapse to motion only.
|
||||
const next: TurnProgressState = { ...state, phase: state.activeTools.length ? "tool" : "thinking" };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "tool-started": {
|
||||
const activeTools = [...state.activeTools, event.toolCall.toolName];
|
||||
const next: TurnProgressState = { ...state, phase: "tool", activeTools };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "tool-updated": {
|
||||
const next: TurnProgressState = { ...state, phase: "tool" };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "tool-finished": {
|
||||
const name = event.toolCall.toolName;
|
||||
const idx = state.activeTools.lastIndexOf(name);
|
||||
const activeTools = idx >= 0 ? state.activeTools.filter((_, i) => i !== idx) : state.activeTools.slice(0, -1);
|
||||
const next: TurnProgressState = { ...state, activeTools, phase: "thinking" };
|
||||
next.phase = restingPhase(next);
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
case "usage-updated": {
|
||||
const tokens = event.usage.inputTokens + event.usage.outputTokens || undefined;
|
||||
const next: TurnProgressState = { ...state, tokens };
|
||||
return { state: next, snapshot: toSnapshot(next) };
|
||||
}
|
||||
default:
|
||||
return { state };
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the notification activity line from a snapshot (pure; spec §2.1). */
|
||||
export function formatProgressLine(s: TurnProgressSnapshot): string {
|
||||
let head: string;
|
||||
if (s.phase === "tool") head = `running ${s.tool ?? "tool"}…`;
|
||||
else if (s.phase === "writing") head = `writing… (${s.chars} chars)`;
|
||||
else head = "thinking…";
|
||||
return s.tokens ? `${head} · ${formatTokens(s.tokens)} tokens` : head;
|
||||
}
|
||||
|
||||
export function formatTokens(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
}
|
||||
@@ -60,29 +60,3 @@ export function selectionRejection(ctx: SelectionContext): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface EditRouteContext {
|
||||
/** Was the command invoked on a specific resource (a tab right-click)? */
|
||||
hasUri: boolean;
|
||||
/** Does that resource match the focused editor's document? */
|
||||
uriMatchesActiveEditor: boolean;
|
||||
/** Is there a focused text editor at all? */
|
||||
hasActiveEditor: boolean;
|
||||
/** Is the focused editor's selection empty (no highlight)? */
|
||||
selectionEmpty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the
|
||||
* selection or whole-document flow. One conceptual command, two destinations:
|
||||
*
|
||||
* - A tab right-click on a document that ISN'T the focused editor has no
|
||||
* selection to act on → edit the whole document.
|
||||
* - Otherwise a non-empty selection in the focused editor → edit the selection;
|
||||
* an empty selection (or no editor) → edit the whole document.
|
||||
*/
|
||||
export function routeEdit(ctx: EditRouteContext): "selection" | "document" {
|
||||
if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document";
|
||||
if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection";
|
||||
return "document";
|
||||
}
|
||||
|
||||
@@ -28,10 +28,7 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
||||
"cowriting.reply",
|
||||
"cowriting.resolveThread",
|
||||
"cowriting.reopenThread",
|
||||
"cowriting.edit",
|
||||
"cowriting.editSelection",
|
||||
"cowriting.askClaude.submit",
|
||||
"cowriting.askClaude.cancel",
|
||||
"cowriting.applyAgentEdit",
|
||||
"cowriting.proposeAgentEdit",
|
||||
]) {
|
||||
|
||||
@@ -174,22 +174,16 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
||||
});
|
||||
|
||||
// SLICE-3: the document-scoped command stays registered for #42 reuse (now behind
|
||||
// the unified `cowriting.edit`). editDocument is hidden from the palette; the
|
||||
// markdown-guarded user-facing palette command is `cowriting.edit`.
|
||||
test("cowriting.editDocument stays registered; cowriting.edit is the markdown-guarded palette command", async () => {
|
||||
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
|
||||
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
|
||||
const all = await vscode.commands.getCommands(true);
|
||||
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
|
||||
assert.ok(all.includes("cowriting.edit"), "unified edit command registered");
|
||||
const palette = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")).contributes
|
||||
.menus.commandPalette as Array<{ command: string; when?: string }>;
|
||||
// editDocument is hidden (routed via cowriting.edit).
|
||||
const docEntry = palette.find((m) => m.command === "cowriting.editDocument");
|
||||
assert.strictEqual(docEntry?.when, "false", "editDocument hidden from the palette");
|
||||
// cowriting.edit is the user-facing, markdown-guarded entry.
|
||||
const editEntry = palette.find((m) => m.command === "cowriting.edit");
|
||||
assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
|
||||
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
|
||||
(m) => m.command === "cowriting.editDocument",
|
||||
);
|
||||
assert.ok(entry, "editDocument has a commandPalette entry");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
});
|
||||
|
||||
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
|
||||
|
||||
@@ -31,37 +31,43 @@ function menu(id: string): Array<{ command: string; when?: string; group?: strin
|
||||
}
|
||||
|
||||
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
|
||||
// the editor TAB. The two split commands (editSelection / editDocument) were
|
||||
// unified behind ONE user-facing command `cowriting.edit` that auto-routes by
|
||||
// selection at runtime (routeEdit: selection → editSelection, none → editDocument)
|
||||
// — so the menus carry a SINGLE selection-agnostic entry, both markdown/authorable
|
||||
// -gated. The routing itself is unit-tested (routeEdit) and exercised through the
|
||||
// command below; here we assert the declarative menu `when` clauses.
|
||||
// the editor TAB, selection-aware (selection → editSelection; no selection →
|
||||
// editDocument), both markdown/authorable-gated, both routing through the single
|
||||
// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we
|
||||
// assert them directly; the tab-targeting behavior is exercised through the command.
|
||||
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
// PUC-1/2: editor BODY (editor/context) offers the single unified entry,
|
||||
// markdown + authorable gated, NOT selection-gated (it routes both cases).
|
||||
test("editor/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown+authorable", () => {
|
||||
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
|
||||
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
|
||||
const m = menu("editor/context");
|
||||
const edit = m.find((e) => e.command === "cowriting.edit");
|
||||
assert.ok(edit, "cowriting.edit is in editor/context");
|
||||
assert.match(edit!.when ?? "", /editorLangId == markdown/, "gated on markdown");
|
||||
assert.match(edit!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "gated authorable");
|
||||
assert.ok(!/editorHasSelection/.test(edit!.when ?? ""), "not selection-gated — one entry routes both cases");
|
||||
// The old split pair is gone from the menu (the commands stay registered/hidden).
|
||||
assert.ok(!m.some((e) => e.command === "cowriting.editSelection"), "old editSelection menu entry removed");
|
||||
assert.ok(!m.some((e) => e.command === "cowriting.editDocument"), "old editDocument menu entry removed");
|
||||
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
||||
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
||||
assert.ok(sel, "editSelection is in editor/context");
|
||||
assert.ok(doc, "editDocument is in editor/context");
|
||||
|
||||
assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection");
|
||||
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection");
|
||||
assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown");
|
||||
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
|
||||
|
||||
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
|
||||
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
|
||||
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
|
||||
});
|
||||
|
||||
// PUC-3: editor TAB (editor/title/context) carries the same single entry.
|
||||
test("editor/title/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown-gated", () => {
|
||||
// PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair.
|
||||
test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => {
|
||||
const m = menu("editor/title/context");
|
||||
const edit = m.find((e) => e.command === "cowriting.edit");
|
||||
assert.ok(edit, "cowriting.edit is in editor/title/context");
|
||||
assert.match(edit!.when ?? "", /resourceLangId == markdown/, "tab entry gated on markdown");
|
||||
assert.ok(
|
||||
!m.some((e) => e.command === "cowriting.editSelection" || e.command === "cowriting.editDocument"),
|
||||
"old split pair removed from the tab menu",
|
||||
);
|
||||
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
||||
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
||||
assert.ok(sel, "editSelection is in editor/title/context");
|
||||
assert.ok(doc, "editDocument is in editor/title/context");
|
||||
|
||||
assert.match(sel!.when ?? "", /editorHasSelection/, "tab editSelection shows only with a selection");
|
||||
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "tab editSelection is not gated on NO selection");
|
||||
assert.match(sel!.when ?? "", /resourceLangId == markdown/, "tab editSelection gated on markdown");
|
||||
|
||||
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "tab editDocument shows only without a selection");
|
||||
assert.match(doc!.when ?? "", /resourceLangId == markdown/, "tab editDocument gated on markdown");
|
||||
});
|
||||
|
||||
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
|
||||
@@ -77,8 +83,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await settle();
|
||||
|
||||
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
|
||||
const origPrompt = api.inlineAsk.prompt;
|
||||
(api.inlineAsk as any).prompt = async () => "rewrite it";
|
||||
const origInput = vscode.window.showInputBox;
|
||||
(vscode.window as any).showInputBox = async () => "rewrite it";
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
||||
model: "sonnet",
|
||||
@@ -88,7 +94,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
||||
await settle();
|
||||
} finally {
|
||||
(api.inlineAsk as any).prompt = origPrompt;
|
||||
(vscode.window as any).showInputBox = origInput;
|
||||
}
|
||||
|
||||
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
|
||||
@@ -110,8 +116,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await vscode.window.showTextDocument(a.doc);
|
||||
await settle();
|
||||
|
||||
const origPrompt = api.inlineAsk.prompt;
|
||||
(api.inlineAsk as any).prompt = async () => "rewrite it";
|
||||
const origInput = vscode.window.showInputBox;
|
||||
(vscode.window as any).showInputBox = async () => "rewrite it";
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
|
||||
model: "sonnet",
|
||||
@@ -121,7 +127,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument");
|
||||
await settle();
|
||||
} finally {
|
||||
(api.inlineAsk as any).prompt = origPrompt;
|
||||
(vscode.window as any).showInputBox = origInput;
|
||||
}
|
||||
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
||||
});
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import * as assert from "assert";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
||||
return api;
|
||||
}
|
||||
|
||||
async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return doc;
|
||||
}
|
||||
|
||||
// #60 host E2E (no LLM): live progress is purely additive observability. The
|
||||
// harness can't read notification subtitles, so it asserts the CONTRACT —
|
||||
// progress events don't change the proposals (INV-44), and an aborted turn
|
||||
// proposes nothing (INV-47). The live notification/OutputChannel UI itself is
|
||||
// covered by the turnProgress unit tests + the manual smoke.
|
||||
suite("#60 live turn progress (additive + cancel)", () => {
|
||||
test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
|
||||
const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns
|
||||
// the same rewrite — proposals must be unaffected by progress events.
|
||||
ctl.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
opts?.onProgress?.({ phase: "writing", chars: 5 });
|
||||
opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." });
|
||||
return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" };
|
||||
});
|
||||
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
|
||||
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
|
||||
assert.ok(view, "the proposal is live");
|
||||
assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)");
|
||||
});
|
||||
|
||||
test("an aborted turn proposes nothing (INV-47)", async () => {
|
||||
const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
|
||||
await settle();
|
||||
|
||||
// The stub throws ONLY when the aborted signal reached it — so if opts.signal
|
||||
// failed to thread through runEditAndPropose, the stub would instead return a
|
||||
// rewrite and create a proposal, failing this test. That proves propagation.
|
||||
ctl.setEditTurnForTest(async (_i, _text, opts) => {
|
||||
if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
|
||||
return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" };
|
||||
});
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
|
||||
} catch {
|
||||
ids = [];
|
||||
}
|
||||
await settle();
|
||||
assert.strictEqual(ids.length, 0, "aborted turn must create no proposals");
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no pending proposals after abort");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as assert from "assert";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
const AGENT = { kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "e2e-s40" } };
|
||||
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.attributionController, "exports attribution");
|
||||
return api;
|
||||
}
|
||||
|
||||
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
||||
const abs = path.join(WS, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body, "utf8");
|
||||
const uri = vscode.Uri.file(abs);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await settle();
|
||||
return { doc, key: uri.toString() };
|
||||
}
|
||||
|
||||
// #40 (follow-up to #38): on undo/redo, restore each re-inserted char's EXACT
|
||||
// prior author attribution rather than leaving it neutral (#38). Driven mid-edit
|
||||
// (buffer dirty) so the attribution branch runs, not the disk-sync one.
|
||||
suite("F3 #40 — undo/redo restores exact author attribution (host E2E, no LLM)", () => {
|
||||
test("undo of a deletion of AGENT text restores its agent span (not neutral, not human)", async () => {
|
||||
const { doc } = await freshDoc("docs/s40agent.md", "Human start. xxxx end.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.attributionController;
|
||||
|
||||
// Make "xxxx" agent-authored via the seam → an agent span over "ROBOT".
|
||||
const t = "xxxx";
|
||||
const start = doc.getText().indexOf(t);
|
||||
const ok = await ctl.applyAgentEdit(
|
||||
doc,
|
||||
new vscode.Range(doc.positionAt(start), doc.positionAt(start + t.length)),
|
||||
"ROBOT",
|
||||
AGENT,
|
||||
{ turnId: "turn-s40" },
|
||||
);
|
||||
assert.strictEqual(ok, true, "seam edit applies");
|
||||
await settle();
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
assert.ok(
|
||||
ctl.getSpans(key).some((s) => s.authorKind === "agent" && doc.getText().slice(s.range.start, s.range.end).includes("ROBOT")),
|
||||
"ROBOT is agent-attributed after the seam edit",
|
||||
);
|
||||
|
||||
// Human deletes "ROBOT" (buffer stays dirty: it already diverged from disk).
|
||||
const rs = doc.getText().indexOf("ROBOT");
|
||||
const del = new vscode.WorkspaceEdit();
|
||||
del.delete(doc.uri, new vscode.Range(doc.positionAt(rs), doc.positionAt(rs + "ROBOT".length)));
|
||||
assert.ok(await vscode.workspace.applyEdit(del), "delete applied");
|
||||
await settle();
|
||||
assert.ok(!doc.getText().includes("ROBOT"), "ROBOT deleted");
|
||||
|
||||
// Undo the deletion → ROBOT re-inserted. #40: its AGENT span is restored.
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("undo");
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("ROBOT"), "undo restored ROBOT");
|
||||
assert.ok(doc.isDirty, "buffer still dirty → attribution branch ran");
|
||||
|
||||
const r2 = doc.getText().indexOf("ROBOT");
|
||||
const spans = ctl.getSpans(key);
|
||||
const over = spans.filter((s) => s.range.start < r2 + 5 && s.range.end > r2);
|
||||
assert.ok(over.length > 0, "restored ROBOT carries a span");
|
||||
assert.ok(over.every((s) => s.authorKind === "agent"), `restored ROBOT is agent-attributed, got ${JSON.stringify(over)}`);
|
||||
});
|
||||
|
||||
test("edit → undo → redo round-trips attribution to identical state", async () => {
|
||||
const { doc } = await freshDoc("docs/s40roundtrip.md", "Base alpha. yyyy omega.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.attributionController;
|
||||
const key = api.proposalController.keyFor(doc);
|
||||
|
||||
const start = doc.getText().indexOf("yyyy");
|
||||
await ctl.applyAgentEdit(
|
||||
doc,
|
||||
new vscode.Range(doc.positionAt(start), doc.positionAt(start + 4)),
|
||||
"BLUEWORD",
|
||||
AGENT,
|
||||
{ turnId: "turn-s40b" },
|
||||
);
|
||||
await settle();
|
||||
const norm = (k: string) =>
|
||||
ctl
|
||||
.getSpans(k)
|
||||
.map((s) => ({ a: s.authorKind, t: doc.getText().slice(s.range.start, s.range.end) }))
|
||||
.sort((x, y) => (x.t < y.t ? -1 : 1));
|
||||
const afterAgent = JSON.stringify(norm(key));
|
||||
assert.ok(afterAgent.includes("BLUEWORD") && afterAgent.includes("agent"), "agent span present after the edit");
|
||||
|
||||
// Delete BLUEWORD, then undo (restore) then redo (re-delete) then undo again.
|
||||
const bs = doc.getText().indexOf("BLUEWORD");
|
||||
const del = new vscode.WorkspaceEdit();
|
||||
del.delete(doc.uri, new vscode.Range(doc.positionAt(bs), doc.positionAt(bs + "BLUEWORD".length)));
|
||||
await vscode.workspace.applyEdit(del);
|
||||
await settle();
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("undo"); // restore BLUEWORD
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("BLUEWORD"), "undo restored BLUEWORD");
|
||||
assert.strictEqual(JSON.stringify(norm(key)), afterAgent, "attribution after undo matches the pre-deletion state exactly");
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
|
||||
/**
|
||||
* #54: `vscode.commands.executeCommand("undo")` is non-functional in some headless
|
||||
* `.vscode-test` instances (it does not restore the buffer), which false-fails
|
||||
* every undo-dependent E2E (#38, #40) and makes `main` E2E red there. This is a
|
||||
* RUNTIME probe: it performs a real edit-then-undo on a scratch buffer and reports
|
||||
* whether undo actually restored it. Undo-dependent suites gate on it — running
|
||||
* normally where undo works (real coverage), skipping with a loud warning where it
|
||||
* doesn't (no false red, no silent loss — the skip is logged). Memoized per run.
|
||||
*/
|
||||
let cached: boolean | undefined;
|
||||
|
||||
export async function undoWorks(): Promise<boolean> {
|
||||
if (cached !== undefined) return cached;
|
||||
const abs = path.join(process.env.E2E_WORKSPACE!, "docs/.undo-probe.md");
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, "undo probe baseline\n", "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "PROBE-EDIT-MARKER");
|
||||
await vscode.workspace.applyEdit(edit);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
if (!doc.getText().includes("PROBE-EDIT-MARKER")) {
|
||||
cached = false; // even the edit didn't take — treat as not undo-capable
|
||||
return cached;
|
||||
}
|
||||
await vscode.commands.executeCommand("undo");
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
cached = !doc.getText().includes("PROBE-EDIT-MARKER"); // undo removed the marker → undo works
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Loud, single-line reason logged when an undo suite skips (no silent loss — #54). */
|
||||
export const UNDO_SKIP_REASON =
|
||||
"[E2E] SKIPPING undo-dependent suite — executeCommand('undo') is non-functional in this " +
|
||||
"VS Code test instance (see vscode-cowriting-plugin#54). These tests run where undo works.";
|
||||
@@ -3,7 +3,6 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import { undoWorks, UNDO_SKIP_REASON } from "./undoCapable";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 400));
|
||||
@@ -35,14 +34,6 @@ suite("F10 #38 — undo does not mis-attribute restored text (host E2E, no LLM)"
|
||||
const DOC_REL = "docs/undo38.md";
|
||||
const BASE = "Alpha bravo charlie.\n";
|
||||
|
||||
// #54: skip (loudly) where executeCommand("undo") is non-functional; run where it works.
|
||||
suiteSetup(async function () {
|
||||
if (!(await undoWorks())) {
|
||||
console.warn(UNDO_SKIP_REASON);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("undo of a deletion of baseline text leaves it unattributed (not human)", async () => {
|
||||
const { doc, key } = await freshDoc(DOC_REL, BASE);
|
||||
const api = await getApi();
|
||||
|
||||
+2
-59
@@ -1,32 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { extractReplacement, runEditTurn } from "../src/liveTurn";
|
||||
|
||||
// A fake Agent that replays a scripted event list to subscribers, supports abort,
|
||||
// and resolves agent.run() with a completed (or aborted) result.
|
||||
const runs = { count: 0 };
|
||||
function fakeSdk(events: any[]) {
|
||||
return {
|
||||
Agent: class {
|
||||
private listeners: ((e: any) => void)[] = [];
|
||||
constructor(_cfg: unknown) {}
|
||||
subscribe(fn: (e: any) => void) {
|
||||
this.listeners.push(fn);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== fn);
|
||||
};
|
||||
}
|
||||
// The real SDK's abort() is a no-op before run() creates its AbortController,
|
||||
// so this fake does NOT cooperate with a pre-abort — proving runEditTurn's own
|
||||
// short-circuit, not the fake's leniency.
|
||||
abort() {}
|
||||
async run(_input: string) {
|
||||
runs.count += 1;
|
||||
for (const e of events) for (const l of this.listeners) l(e);
|
||||
return { status: "completed", outputText: "EDITED", runId: "r1" };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractReplacement } from "../src/liveTurn";
|
||||
|
||||
describe("extractReplacement", () => {
|
||||
it("returns plain text untouched", () => {
|
||||
@@ -51,33 +24,3 @@ describe("extractReplacement", () => {
|
||||
expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runEditTurn progress + cancel", () => {
|
||||
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@cline/sdk", () =>
|
||||
fakeSdk([
|
||||
{ type: "assistant-text-delta", text: "ED", accumulatedText: "ED" },
|
||||
{ type: "usage-updated", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
]),
|
||||
);
|
||||
const seen: string[] = [];
|
||||
const turn = await runEditTurn("do it", "old", { onProgress: (s) => seen.push(s.phase) });
|
||||
expect(turn.replacement).toBe("EDITED");
|
||||
expect(seen).toContain("writing");
|
||||
vi.doUnmock("@cline/sdk");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("a pre-aborted AbortSignal short-circuits before run() and throws (INV-47)", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@cline/sdk", () => fakeSdk([]));
|
||||
runs.count = 0;
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await expect(runEditTurn("do it", "old", { signal: ac.signal })).rejects.toThrow(/aborted/);
|
||||
expect(runs.count).toBe(0); // the turn never ran — no proposal could be produced
|
||||
vi.doUnmock("@cline/sdk");
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-15
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it, test, expect } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SCHEMA_VERSION,
|
||||
emptyArtifact,
|
||||
@@ -180,19 +179,6 @@ describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("serializeArtifact round-trips Proposal.original", () => {
|
||||
const a = emptyArtifact("doc.md");
|
||||
a.anchors["a_1"] = { fingerprint: { text: "new", before: "", after: "", lineHint: 0 } };
|
||||
a.proposals.push({
|
||||
id: "pr_1", anchorId: "a_1", replacement: "new",
|
||||
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s" } },
|
||||
createdAt: "2026-06-26T00:00:00.000Z", original: "old", granularity: "block",
|
||||
});
|
||||
const json = serializeArtifact(a);
|
||||
assert.match(json, /"original": "old"/);
|
||||
assert.match(json, /"granularity": "block"/);
|
||||
});
|
||||
|
||||
describe("attributions[] (F3 SLICE-1)", () => {
|
||||
it("round-trips an attribution record through serialize → parse", () => {
|
||||
const a = emptyArtifact("docs/x.md");
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createTurnProgressState,
|
||||
reduceTurnProgress,
|
||||
formatProgressLine,
|
||||
formatTokens,
|
||||
type TurnProgressSnapshot,
|
||||
} from "../src/turnProgress";
|
||||
|
||||
// Minimal event factories — structurally match the @cline/sdk AgentRuntimeEvent
|
||||
// members the reducer reads. `as any` because we only supply the fields used.
|
||||
const ev = (e: any) => e as any;
|
||||
const textDelta = (text: string, accumulatedText: string) =>
|
||||
ev({ type: "assistant-text-delta", text, accumulatedText });
|
||||
const toolStarted = (toolName: string) => ev({ type: "tool-started", toolCall: { toolName } });
|
||||
const toolFinished = (toolName: string) => ev({ type: "tool-finished", toolCall: { toolName } });
|
||||
const usage = (inputTokens: number, outputTokens: number) =>
|
||||
ev({ type: "usage-updated", usage: { inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0 } });
|
||||
|
||||
// Drive a sequence of events, returning every emitted snapshot.
|
||||
function run(events: any[]): TurnProgressSnapshot[] {
|
||||
let state = createTurnProgressState();
|
||||
const out: TurnProgressSnapshot[] = [];
|
||||
for (const e of events) {
|
||||
const r = reduceTurnProgress(state, e);
|
||||
state = r.state;
|
||||
if (r.snapshot) out.push(r.snapshot);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("reduceTurnProgress", () => {
|
||||
it("starts in thinking", () => {
|
||||
const s = run([ev({ type: "run-started" })]);
|
||||
expect(s.at(-1)!.phase).toBe("thinking");
|
||||
expect(s.at(-1)!.chars).toBe(0);
|
||||
expect(s.at(-1)!.tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("text deltas move to writing and accumulate chars + carry the delta", () => {
|
||||
const s = run([textDelta("Hel", "Hel"), textDelta("lo", "Hello")]);
|
||||
expect(s.map((x) => x.phase)).toEqual(["writing", "writing"]);
|
||||
expect(s.at(-1)!.chars).toBe(5);
|
||||
expect(s.map((x) => x.textDelta)).toEqual(["Hel", "lo"]);
|
||||
});
|
||||
|
||||
it("usage sets a running token total (input+output)", () => {
|
||||
const s = run([textDelta("Hi", "Hi"), usage(1000, 234)]);
|
||||
expect(s.at(-1)!.tokens).toBe(1234);
|
||||
});
|
||||
|
||||
it("tool start shows the tool name; tool finish reverts to writing once text was seen", () => {
|
||||
const s = run([textDelta("x", "x"), toolStarted("read_file"), toolFinished("read_file")]);
|
||||
expect(s[1].phase).toBe("tool");
|
||||
expect(s[1].tool).toBe("read_file");
|
||||
expect(s.at(-1)!.phase).toBe("writing");
|
||||
});
|
||||
|
||||
it("tool finish reverts to thinking when no text was seen", () => {
|
||||
const s = run([toolStarted("grep"), toolFinished("grep")]);
|
||||
expect(s.at(-1)!.phase).toBe("thinking");
|
||||
});
|
||||
|
||||
it("overlapping tools resolve in order", () => {
|
||||
const s = run([toolStarted("a"), toolStarted("b"), toolFinished("b"), toolFinished("a")]);
|
||||
expect(s.map((x) => x.phase)).toEqual(["tool", "tool", "tool", "thinking"]);
|
||||
expect(s[1].tool).toBe("b");
|
||||
expect(s[2].tool).toBe("a");
|
||||
});
|
||||
|
||||
it("reasoning deltas stay thinking and surface no text", () => {
|
||||
const s = run([ev({ type: "assistant-reasoning-delta", text: "secret", accumulatedText: "secret" })]);
|
||||
expect(s.at(-1)!.phase).toBe("thinking");
|
||||
expect(s.at(-1)!.textDelta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores lifecycle/finish events (no snapshot)", () => {
|
||||
const s = run([ev({ type: "turn-finished" }), ev({ type: "run-finished" })]);
|
||||
expect(s).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatProgressLine / formatTokens", () => {
|
||||
it("thinking", () => {
|
||||
expect(formatProgressLine({ phase: "thinking", chars: 0 })).toBe("thinking…");
|
||||
});
|
||||
it("writing with chars", () => {
|
||||
expect(formatProgressLine({ phase: "writing", chars: 412 })).toBe("writing… (412 chars)");
|
||||
});
|
||||
it("writing with chars + tokens", () => {
|
||||
expect(formatProgressLine({ phase: "writing", chars: 412, tokens: 1234 })).toBe(
|
||||
"writing… (412 chars) · 1.2k tokens",
|
||||
);
|
||||
});
|
||||
it("tool with name", () => {
|
||||
expect(formatProgressLine({ phase: "tool", tool: "read_file", chars: 0 })).toBe("running read_file…");
|
||||
});
|
||||
it("formats token magnitudes", () => {
|
||||
expect(formatTokens(950)).toBe("950");
|
||||
expect(formatTokens(1234)).toBe("1.2k");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath";
|
||||
import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath";
|
||||
|
||||
describe("isUnderRoot", () => {
|
||||
const root = "/a/vscode-cowriting-plugin/sandbox";
|
||||
@@ -58,27 +58,3 @@ describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)",
|
||||
expect(msg).not.toMatch(/select some text/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeEdit (single Ask-Claude-to-Edit gesture)", () => {
|
||||
const focused = { hasUri: false, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false };
|
||||
|
||||
it("non-empty selection in the focused editor → selection", () => {
|
||||
expect(routeEdit(focused)).toBe("selection");
|
||||
});
|
||||
it("empty selection in the focused editor → document", () => {
|
||||
expect(routeEdit({ ...focused, selectionEmpty: true })).toBe("document");
|
||||
});
|
||||
it("no active editor → document", () => {
|
||||
expect(routeEdit({ ...focused, hasActiveEditor: false, selectionEmpty: true })).toBe("document");
|
||||
});
|
||||
it("tab right-click on a doc that ISN'T the focused editor → document (no selection to act on)", () => {
|
||||
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false })).toBe(
|
||||
"document",
|
||||
);
|
||||
});
|
||||
it("tab right-click on the focused editor with a selection → selection", () => {
|
||||
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: true, hasActiveEditor: true, selectionEmpty: false })).toBe(
|
||||
"selection",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user