8 Commits

Author SHA1 Message Date
BenStullsBets bea9fd5148 feat(model): add Proposal.original for F12 optimistic-apply revert (#64) 2026-06-26 06:15:36 -07:00
BenStullsBets 56cc9eb6ad claim vscode-cowriting-plugin session 0059 (placeholder) + sessions.json entry 2026-06-26 06:01:30 -07:00
BenStullsBets 2069029d73 add sessions/0058/SESSION-0058.0-TRANSCRIPT-2026-06-26T05-12--2026-06-26T05-58.md + replace placeholder/variant SESSION-0058.0-TRANSCRIPT-2026-06-26T05-12--INPROGRESS.md 2026-06-26 05:58:49 -07:00
BenStullsBets a99430c247 claim vscode-cowriting-plugin session 0058 (placeholder) + sessions.json entry 2026-06-26 05:13:09 -07:00
benstull 9432300e3c feat(ux): unify "Ask Claude to Edit" + inline prompt at selection; fix keybindings (#62) 2026-06-26 12:11:43 +00:00
BenStullsBets a42e5f145d add sessions/0056/SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--2026-06-26T04-54.md + replace placeholder/variant SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--INPROGRESS.md 2026-06-26 04:55:04 -07:00
BenStullsBets 26628c0dbe claim vscode-cowriting-plugin session 0057 (placeholder) + sessions.json entry 2026-06-26 04:53:46 -07:00
benstull 644885c6ec #60: live turn progress (activity line + token count + OutputChannel stream + cancel) (#61)
Surface Claude live output/progress during the asking-Claude status: notification activity line + token count, a Cowriting: Claude OutputChannel streaming assistant text, and a cancellable turn. Pure turnProgress reducer + runEditTurn onProgress/AbortSignal (vscode-free) + both call sites. INV-43..47.

Fixes #60
2026-06-26 11:53:10 +00:00
23 changed files with 1109 additions and 111 deletions
+63 -15
View File
@@ -18,6 +18,16 @@
"onStartupFinished"
],
"contributes": {
"configuration": {
"title": "Cowriting",
"properties": {
"cowriting.liveProgress.revealOutput": {
"type": "boolean",
"default": true,
"description": "When Claude is editing, reveal the \"Cowriting: Claude\" output channel (without stealing focus) as soon as Claude starts producing text, so you can read the output as it streams."
}
}
},
"commands": [
{
"command": "cowriting.showClineSdkInfo",
@@ -49,6 +59,21 @@
"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",
@@ -108,14 +133,30 @@
"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.editDocument",
"command": "cowriting.edit",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.editSelection",
"when": "false"
},
{
"command": "cowriting.editDocument",
"when": "false"
},
{
"command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown"
@@ -130,13 +171,8 @@
],
"editor/title/context": [
{
"command": "cowriting.editSelection",
"when": "editorHasSelection && resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
"command": "cowriting.editDocument",
"when": "!editorHasSelection && resourceLangId == markdown",
"command": "cowriting.edit",
"when": "resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
@@ -154,13 +190,8 @@
],
"editor/context": [
{
"command": "cowriting.editSelection",
"when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"group": "1_cowriting@1"
},
{
"command": "cowriting.editDocument",
"when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"command": "cowriting.edit",
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"group": "1_cowriting@1"
},
{
@@ -174,6 +205,11 @@
"command": "cowriting.reply",
"group": "inline",
"when": "commentController == cowriting.threads"
},
{
"command": "cowriting.askClaude.submit",
"group": "inline",
"when": "commentController == cowriting.askClaude"
}
],
"comments/commentThread/title": [
@@ -186,6 +222,11 @@
"command": "cowriting.reopenThread",
"group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
},
{
"command": "cowriting.askClaude.cancel",
"group": "inline",
"when": "commentController == cowriting.askClaude"
}
]
},
@@ -193,7 +234,14 @@
{
"command": "cowriting.showTrackChangesPreview",
"key": "ctrl+alt+r",
"mac": "cmd+alt+r",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.edit",
"key": "ctrl+alt+e",
"mac": "cmd+alt+e",
"when": "editorTextFocus && editorLangId == markdown"
}
]
},
+9 -1
View File
@@ -10,7 +10,15 @@ console.log(`instruction: ${instruction}`);
console.log(`text: ${text}`);
try {
const t0 = Date.now();
const result = await runEditTurn(instruction, text);
// #60: exercise the live-progress path against the real SDK — log each reduced
// snapshot so the smoke shows streaming/activity/tokens, not just the result.
const result = await runEditTurn(instruction, text, {
onProgress: (s) => {
const bits = [s.phase === "tool" ? `tool:${s.tool}` : s.phase, `${s.chars}c`];
if (s.tokens) bits.push(`${s.tokens}tok`);
console.log(` progress: ${bits.join(" ")}`);
},
});
console.log(`replacement: ${JSON.stringify(result.replacement)}`);
console.log(`model: ${result.model}`);
console.log(`sessionId: ${result.sessionId}`);
@@ -0,0 +1,106 @@
# Session 0056.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T04-24 (PST)
> End: 2026-06-26T04-54 (PST)
> Type: planning-and-executing
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
`/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md`
(Continuation of brainstorming session 0055, which graduated the spec; the
operator said "implement it" → this coding session.)
## Plan
Plan + execute **#60** (P1 feature — live turn progress) from the graduated
Solution Design `coauthoring-live-progress.md`. Four cuts: (1) pure
`turnProgress.ts` reducer + tests; (2) `runEditTurn` + `onProgress`/`AbortSignal`;
(3) `liveProgressUi` + setting + both call sites; (4) host-E2E + smoke.
Branch `s60-live-progress` → PR → main.
## Pre-state
- Clean `main` at session start (fast-forwarded 13 commits — the sessions/ repo
is the code repo itself). Baseline: 222 unit green.
- #60 has a graduated Solution Design → §4.3 R2(b) satisfied.
## Session arc
1. **Claim + plan.** Claimed 0056 (no in-flight). Wrote the implementation plan
with `superpowers:writing-plans``docs/superpowers/plans/2026-06-26-live-turn-progress.md`
(self-reviewed: full spec coverage, no placeholders). Grounded in the exact SDK
types (`AgentToolCallPart.toolName`, `AgentUsage.inputTokens+outputTokens`), the
vitest/host-E2E patterns, and the injectable `editTurn` seam. Branch
`s60-live-progress`.
2. **Task 1 — pure reducer.** `src/turnProgress.ts` + `test/turnProgress.test.ts`
(13 cases). Fixed the type-only event import: `AgentRuntimeEvent` is exported
from **`@cline/shared`**, not `@cline/sdk` (which doesn't re-export it) — caught
by typecheck; amended the commit.
3. **Task 2 — `runEditTurn`.** Added `RunEditTurnOptions {onProgress, signal}`;
subscribes to the agent, folds events through the reducer, wires `signal`
`agent.abort()`; `finally` unsubscribes. Mocked-agent unit tests (vitest
`doMock` intercepts the dynamic import).
4. **Task 3 — host relay.** `src/liveProgressUi.ts` (notification line +
"Cowriting: Claude" OutputChannel, reveal gated by setting) + `package.json`
`contributes.configuration` `cowriting.liveProgress.revealOutput`.
5. **Tasks 4+5 — both call sites.** `editSelection` (extension.ts) and preview
`askClaude`/`runEditAndPropose` (trackChangesPreview.ts) made cancellable,
relaying via the shared `liveProgressUi`; widened the `EditTurn` seam to accept
`opts?` (back-compat). Added `liveProgressUi` to `CowritingApi`.
6. **Task 6 — host E2E.** `test/e2e/suite/liveProgress.test.ts`: progress is
additive (INV-44), aborted turn proposes nothing (INV-47).
7. **Task 7 — verify + smoke.** typecheck + 237 unit + build green; enhanced
`scripts/smoke-live-turn.mjs` to log progress.
8. **Subagent review.** Found a **Medium** (a pre-aborted `AbortSignal` was a
no-op — `agent.abort()` runs before the SDK's AbortController exists, so the
turn ran to completion) + a **Low** (a throwing `onProgress` relay could fail
the turn). Fixed both: short-circuit (throw) before `run()` on a pre-aborted
signal; wrap the relay in try/catch. Strengthened the unit + E2E to prove it.
9. **Ship.** Pushed; opened PR #61; operator approved merge despite the
concurrency (below). Squash-merged `644885c`; issue #60 auto-closed.
## ⚠️ Concurrency event (mid-session)
Partway through, the shared working tree gained **uncommitted changes this session
did NOT make** — a `cowriting.edit` / `routeEdit` reach refactor across
`src/workspacePath.ts`, `test/workspacePath.test.ts`, and the menu/`routeEdit`
parts of `package.json` + `src/extension.ts`. It broke 3 E2E (`f12Reach` ×2,
`f11Toolbar` ×1) that still expect the old `editSelection`/`editDocument` command
surface. Handling: committed **nothing** of it, discarded **nothing**; verified
#60 green in isolation by stashing it (by path, reversible) — 237 unit + both #60
E2E pass, only the documented `F10 #38` undo-sandbox flake remains — then popped
the stash to restore it. Surfaced to the operator, who chose to merge #60 (its 7
commits are isolated; the PR diff contains none of the foreign WIP).
## Cut state (at finalize)
- **#60 merged** to main (PR #61 squash `644885c`); issue #60 closed.
- **Plan archived:** `submit-plan.sh` → content repo `plans/2026-06-26-live-turn-progress.md` (`22ec57e`).
- **Foreign refactor WIP:** still uncommitted in the working tree, untouched —
for whoever is doing it to continue/commit (it rebases onto the new main).
- **Local tree:** left on branch `s60-live-progress` (merged) DELIBERATELY — a
`checkout main`/`pull` would have disturbed the foreign uncommitted WIP. Local
main not synced; origin/main has #60. Operator should reconcile the local tree
+ the in-flight refactor.
- **Memory:** added `session-0056-60-live-progress-shipped.md` + index line.
## Next-session prompt
```
/wgl-planning-and-executing reconcile the local tree (sync main; land/branch the in-flight cowriting.edit/routeEdit refactor), then pick the next item (open: #59 P1 bug, #57, #58, #32, #35, #40, OQ-2)
```
## Deferred decisions
- **Left local on `s60-live-progress` (did not sync main):** to avoid disturbing
the concurrent uncommitted refactor in the shared tree. Low confidence this is
the tidiest end state, but it is the safest for the foreign WIP. Operator to
reconcile.
- **Token field for the activity line:** used `inputTokens + outputTokens` from
`AgentUsage` (no `totalTokens` field exists). Cache tokens excluded.
- **OutputChannel append-not-clear + auto-reveal gating:** per spec §3.5; carried
from session 0055's confirmed sub-decisions.
@@ -0,0 +1,22 @@
# 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._
@@ -0,0 +1,85 @@
# Session 0058.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T05-12 (PST)
> End: 2026-06-26T05-58 (PST)
> Type: brainstorming
> Posture: autonomous (yolo)
> Status: **FINALIZED**
## Launch prompt
Operator opened by asking what was in the Gitea backlog, then directed a
brainstorming session for a new feature: *"We should also show the proposed change
in the Markdown file in the same way. The proposed change in the markdown file
should be human-editable, show the diff just like it would after the change is
completed in both the markdown file and the webview as the change is made. The
diff in both the markdown file and preview webview should be exactly as they will
be shown if the edit is accepted. Create a new working tree in a directory other
than main for this work."* — extended mid-session with: *"replace the checkbox
icon and 'x' icon on the proposed change as 'Accept' and 'Reject'. Each should
have an arrow next to them for a dropdown that is 'Accept All' or 'Reject All'.
One should be able to accept, reject, accept all, or reject all from either the
markdown file or the web preview file."*
## Pre-state
- Branch `main`, clean/pushed baseline (after FF). Latest shipped work: #60 live
turn progress (session 0056, PR #61). Session 0057 (a `cowriting.edit`/`routeEdit`
refactor) still **in-flight** sharing the checkout.
- Backlog at session start (8 open): #59 (P1 bug, Apple-Events prompt), #54/#57/#58
(P2), #32/#35/#40 (P3), #62 (untriaged).
## Turn-by-turn arc
1. **Backlog query** — listed the 8 open Gitea issues for `benstull/vscode-cowriting-plugin`
by priority; flagged #62 as the only untriaged item.
2. **Session routing** — operator's feature request was feature-shaped *and*
reversed a locked invariant (F10/INV-32 "clean editor"), so the session type
was ambiguous. Asked; operator chose **brainstorm a spec first**. Routed via
`wgl-brainstorming`; claimed session **0058**.
3. **Isolation** — session 0057 still in-flight on the shared checkout → created an
isolated worktree `vscode-cowriting-plugin-s58` on branch
`s58-inline-editor-diff` off `origin/main` (§5.4).
4. **Exploration** — dispatched an Explore agent that mapped five areas: webview
render engine (`trackChangesModel.ts`), F4 proposal model + seam, the F10
clean-editor decision (zero editor decorations remain), controllers/wiring, and
commands/toolbar. Key constraint surfaced: **VS Code text editors cannot host
editable phantom text** — anything editable in the editor is buffer content;
decorations only style existing text or inject *non-editable* before/after
content.
5. **Four design forks (AskUserQuestion, with ASCII previews):**
- Editor model → **optimistic apply + decorations**.
- Timing → **on proposal** (not live token-stream into the editor).
- Editor affordance → **CodeLens `Accept ▾ / Reject ▾` + QuickPick dropdown**.
- (Operator-added) Control parity → Accept/Reject/Accept-all/Reject-all from
both surfaces; ✓/✗ superseded.
6. **Design presented** section-by-section; operator approved with "keep going"
(nodded the two flagged sub-decisions: dedicated `EditorProposalController`;
allow save of pending text).
7. **Anchor + spec** — filed Feature **#64** (`type/feature`, `priority/P2`) since
no tracker anchor existed; wrote `specs/coauthoring-inline-editor-diff.md`
(INV-48..54, reversing INV-10/INV-32). Self-review clean. Operator **approved**.
## Cut state
- Spec **graduated** → content repo `vscode-cowriting-plugin-content` at `80d5d1a`
(`specs/coauthoring-inline-editor-diff.md`).
- Feature **#64** filed + labelled.
- Worktree `vscode-cowriting-plugin-s58` (branch `s58-inline-editor-diff`) stands
ready for the downstream planning-and-executing session; no code committed this
session (brainstorming output is the spec only).
- Memory updated: `f12-inline-editor-diff-spec-graduated.md` + MEMORY.md index.
## Deferred decisions
No autonomous low-confidence calls — every fork was decided live with the operator
via AskUserQuestion, and the spec was operator-approved before submission. (The
two design sub-decisions — dedicated controller, allow-save-of-pending — were
flagged in the presented design and nodded by the operator.)
## Next-session prompt
```
/goal Plan-and-execute #64 (F12 inline editable proposed-change diff in the Markdown editor) from specs/coauthoring-inline-editor-diff.md, in worktree vscode-cowriting-plugin-s58 (branch s58-inline-editor-diff); follow the spec's 5-slice Delivery Plan
```
@@ -1,13 +1,13 @@
# Session 0056.0 — Transcript
# Session 0059.0 — Transcript
> App: vscode-cowriting-plugin
> Start: 2026-06-26T04-24 (PST)
> Start: 2026-06-26T06-01 (PST)
> Type: planning-and-executing
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0056 for vscode-cowriting-plugin. The driver replaces this
> This file reserves session ID 0059 for vscode-cowriting-plugin. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--<end>.md form at session end.
> SESSION-0059.0-TRANSCRIPT-2026-06-26T06-01--<end>.md form at session end.
## Launch prompt
+9
View File
@@ -166,5 +166,14 @@
},
"0056": {
"title": ""
},
"0057": {
"title": ""
},
"0058": {
"title": ""
},
"0059": {
"title": ""
}
}
+67 -14
View File
@@ -11,7 +11,9 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
import { isAuthorable, selectionRejection } from "./workspacePath";
import { LiveProgressUi } from "./liveProgressUi";
import { InlineAskController } from "./inlineAsk";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -23,12 +25,24 @@ 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 {
@@ -104,6 +118,8 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
context.extensionUri,
attributionController,
proposalController,
liveProgressUi,
inlineAsk,
);
context.subscriptions.push(trackChangesPreviewController);
@@ -209,17 +225,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
return;
}
if (!editor) return; // unreachable once reason is null, but narrows the type
const instruction = await vscode.window.showInputBox({
prompt: "What should Claude do with the selection?",
placeHolder: "e.g. tighten this paragraph",
});
if (!instruction) return;
if (editor.selection.isEmpty) {
void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first.");
return;
}
const document = editor.document;
const selection = editor.selection;
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
// 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);
if (!instruction) return;
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.
@@ -230,10 +241,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const turnId = `turn-${Date.now().toString(36)}`;
try {
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
async () => {
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const { runEditTurn } = await import("./liveTurn");
const turn = await runEditTurn(instruction, selectedText);
const ui = liveProgressUi.begin(instruction, progress, token);
let turn;
try {
turn = await runEditTurn(instruction, selectedText, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
if (token.isCancellationRequested) {
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
return;
}
throw err;
}
if (turn.replacement === "") {
void vscode.window.showWarningMessage(
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
@@ -273,6 +302,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
}),
);
// The single user-facing "Ask Claude to Edit" gesture (one command, one
// keybinding, one menu entry). It routes to the selection flow (editSelection)
// or the whole-document flow (editDocument) by selection/context — see
// routeEdit. The two underlying commands stay registered for the host E2E
// harness and the internal seams, but are hidden from the palette.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
const editor = vscode.window.activeTextEditor;
const route = routeEdit({
hasUri: !!uri,
uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(),
hasActiveEditor: !!editor,
selectionEmpty: editor?.selection.isEmpty ?? true,
});
if (route === "selection") {
await vscode.commands.executeCommand("cowriting.editSelection");
} else {
await vscode.commands.executeCommand("cowriting.editDocument", uri);
}
}),
);
// Render threads + attributions for already-open editors, and on future opens.
const renderIfOpen = (doc: vscode.TextDocument) => {
if (isAuthorable(doc.uri.scheme)) {
@@ -292,6 +343,8 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
diffViewController,
trackChangesPreviewController,
sidecarRouter,
liveProgressUi,
inlineAsk,
};
}
+71
View File
@@ -0,0 +1,71 @@
import * as vscode from "vscode";
/** Trim the reply text; an empty/whitespace-only instruction is "no instruction". */
export function normalizeInstruction(text: string | undefined): string | undefined {
const t = text?.trim();
return t ? t : undefined;
}
/**
* The "Ask Claude to Edit" prompt, rendered INLINE at the selection (or cursor)
* instead of the top-center QuickInput. VS Code's native pattern for "ask AI to
* edit this" is Inline Chat — an input anchored in the editor at the target. The
* stable-API equivalent for a third-party extension is the Comments API: a
* transient comment thread whose reply box renders right at the range. (The
* coauthoring THREADS feature already uses the Comments API via its own
* controller — this is a SEPARATE, dedicated `cowriting.askClaude` controller so
* the two never collide.)
*
* One prompt is live at a time; opening a new one cancels the previous. The
* thread carries no comments — it is purely the inline input — and is disposed
* as soon as the user submits or cancels.
*/
export class InlineAskController {
private readonly controller: vscode.CommentController;
private pending?: { resolve: (v: string | undefined) => void; thread: vscode.CommentThread };
constructor(disposables: vscode.Disposable[]) {
this.controller = vscode.comments.createCommentController("cowriting.askClaude", "Ask Claude to Edit");
// The reply box's prompt + placeholder (Comments API options).
this.controller.options = {
prompt: "Ask Claude to Edit",
placeHolder: "e.g. tighten this paragraph",
};
disposables.push(
this.controller,
vscode.commands.registerCommand("cowriting.askClaude.submit", (r: vscode.CommentReply) =>
this.finish(normalizeInstruction(r?.text)),
),
vscode.commands.registerCommand("cowriting.askClaude.cancel", () => this.finish(undefined)),
// The controller itself disposes the live thread on extension teardown.
new vscode.Disposable(() => this.finish(undefined)),
);
}
/**
* Open the inline input anchored at `range` in `uri`'s editor and resolve with
* the typed instruction (or `undefined` if cancelled / left empty). Replaces
* any prompt already open.
*/
prompt(uri: vscode.Uri, range: vscode.Range): Promise<string | undefined> {
// A fresh prompt supersedes any prior one (resolve it as cancelled).
this.finish(undefined);
return new Promise<string | undefined>((resolve) => {
const thread = this.controller.createCommentThread(uri, range, []);
thread.label = "Ask Claude to Edit";
thread.canReply = true;
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
thread.contextValue = "askClaude";
this.pending = { resolve, thread };
});
}
/** Resolve the live prompt (if any) and tear its thread down. */
private finish(value: string | undefined): void {
const p = this.pending;
this.pending = undefined;
if (!p) return;
p.thread.dispose();
p.resolve(value);
}
}
+65
View File
@@ -0,0 +1,65 @@
/**
* liveProgressUi.ts — host-side relay of TurnProgress snapshots to VS Code UI
* (#60, spec coauthoring-live-progress.md §3.4). The ONLY surfaces are the
* existing withProgress notification (an activity line) and a dedicated
* OutputChannel streaming the full assistant text — no new network/webview
* surface (INV-45). vscode-only; all pure logic lives in turnProgress.ts.
*/
import * as vscode from "vscode";
import { formatProgressLine, type TurnProgressSnapshot } from "./turnProgress";
const CHANNEL_NAME = "Cowriting: Claude";
export interface TurnUi {
/** Pass to runEditTurn's opts.onProgress. */
onProgress: (snapshot: TurnProgressSnapshot) => void;
/** Pass to runEditTurn's opts.signal — fired when the user cancels the notification. */
signal: AbortSignal;
}
export class LiveProgressUi {
readonly channel: vscode.OutputChannel;
constructor() {
this.channel = vscode.window.createOutputChannel(CHANNEL_NAME);
}
/**
* Begin one turn's UI. Writes the per-turn header to the channel and returns
* the onProgress relay + an AbortSignal linked to the notification's cancel
* token. The channel APPENDS (it doubles as a debug log of recent turns, spec
* §3.5); it auto-reveals (without stealing focus) on the first streamed text,
* gated by `cowriting.liveProgress.revealOutput`.
*/
begin(
instruction: string,
progress: vscode.Progress<{ message?: string }>,
token: vscode.CancellationToken,
): TurnUi {
const controller = new AbortController();
token.onCancellationRequested(() => controller.abort());
this.channel.appendLine(`── asking: ${instruction} ──`);
let revealed = false;
const reveal = (): void => {
if (revealed) return;
revealed = true;
const cfg = vscode.workspace.getConfiguration("cowriting");
if (cfg.get<boolean>("liveProgress.revealOutput", true)) this.channel.show(true);
};
const onProgress = (s: TurnProgressSnapshot): void => {
progress.report({ message: formatProgressLine(s) });
if (s.textDelta) {
this.channel.append(s.textDelta);
reveal();
}
};
return { onProgress, signal: controller.signal };
}
dispose(): void {
this.channel.dispose();
}
}
+61 -11
View File
@@ -9,6 +9,9 @@
* never bundled (esbuild keeps it external).
*/
import type { TurnProgressSnapshot } from "./turnProgress";
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
export interface EditTurnResult {
replacement: string;
model: string;
@@ -16,6 +19,19 @@ export interface EditTurnResult {
sessionId: string;
}
/**
* Options for runEditTurn. Both new fields are purely additive observability /
* control over the existing turn (INV-44): `onProgress` streams reduced progress
* snapshots out; `signal` cancels the turn in. Neither touches the result path,
* and neither pulls `vscode` into this module (INV-43 — `AbortSignal` is a web
* standard, the progress snapshot is a domain type).
*/
export interface RunEditTurnOptions {
modelId?: string;
onProgress?: (snapshot: TurnProgressSnapshot) => void;
signal?: AbortSignal;
}
const SYSTEM_PROMPT = [
"You are a precise text editor embedded in VS Code.",
"You will be given a piece of text and an instruction.",
@@ -40,7 +56,7 @@ export function extractReplacement(outputText: string, selectedText: string): st
export async function runEditTurn(
instruction: string,
selectedText: string,
opts?: { modelId?: string },
opts?: RunEditTurnOptions,
): Promise<EditTurnResult> {
const sdk = await import("@cline/sdk");
const modelId = opts?.modelId ?? "sonnet";
@@ -49,16 +65,50 @@ export async function runEditTurn(
modelId,
systemPrompt: SYSTEM_PROMPT,
});
const result = await agent.run(
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
);
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
// (@cline/shared agent.d.ts) — "completed" is the success status.
if (result.status !== "completed") {
throw new Error(
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
"(is Claude Code installed and signed in?)",
// Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
// in via the AbortSignal (INV-47). agent.subscribe returns its unsubscribe fn.
let state = createTurnProgressState();
const unsubscribe = opts?.onProgress
? agent.subscribe((event) => {
const next = reduceTurnProgress(state, event);
state = next.state;
// Observability must never affect the result (INV-44): a throwing relay
// is swallowed, not allowed to propagate into the SDK and fail the turn.
if (next.snapshot) {
try {
opts.onProgress!(next.snapshot);
} catch {
/* progress is best-effort */
}
}
})
: undefined;
const onAbort = () => agent.abort();
opts?.signal?.addEventListener("abort", onAbort);
try {
// A signal already aborted before the turn starts can't be honored by
// agent.abort() (the SDK's AbortController isn't created until run()), so
// short-circuit to the same aborted outcome the call site reflects (INV-47).
if (opts?.signal?.aborted) {
throw new Error("claude-code turn aborted: cancelled before start");
}
const result = await agent.run(
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
);
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
// (@cline/shared agent.d.ts) — "completed" is the success status. An aborted
// turn (user cancel) falls into this throw; the call site reflects "cancelled".
if (result.status !== "completed") {
throw new Error(
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
"(is Claude Code installed and signed in?)",
);
}
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
} finally {
unsubscribe?.();
opts?.signal?.removeEventListener("abort", onAbort);
}
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
}
+10
View File
@@ -97,6 +97,14 @@ export interface Proposal {
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
*/
granularity?: "block" | "single";
/**
* F12/#64 (INV-48): the pre-apply text, captured when a proposal is
* optimistically applied to the buffer. `replacement` is now in the buffer and
* `fp.text` re-anchors to it, so `original` is the only record of what to revert
* to (revert-in-place) and what to show struck in the `<del>` half. Absent on a
* proposal created but not yet optimistically applied (or older sidecars).
*/
original?: string;
}
export interface Artifact {
@@ -252,6 +260,8 @@ export function serializeArtifact(a: Artifact): string {
createdAt: p.createdAt,
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
...(p.original !== undefined ? { original: p.original } : {}),
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
},
p,
),
+56 -16
View File
@@ -16,10 +16,16 @@ 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 } from "./liveTurn";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi";
import type { InlineAskController } from "./inlineAsk";
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
type EditTurn = (instruction: string, text: string) => Promise<EditTurnResult>;
/**
* F11: a host edit turn (selection/document text + instruction → rewrite).
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
* the arg is optional so existing test stubs that ignore it stay valid.
*/
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
@@ -53,9 +59,9 @@ export class TrackChangesPreviewController implements vscode.Disposable {
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/
private editTurn: EditTurn = async (instruction, text) => {
private editTurn: EditTurn = async (instruction, text, opts) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text);
return runEditTurn(instruction, text, opts);
};
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
@@ -68,6 +74,8 @@ 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;
@@ -214,24 +222,55 @@ 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> {
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",
});
// 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);
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
() => this.runEditAndPropose(document, target, instruction),
{
location: vscode.ProgressLocation.Notification,
title: "Cowriting: asking Claude…",
cancellable: true,
},
async (progress, token) => {
const ui = this.liveProgressUi.begin(instruction, progress, token);
try {
return await this.runEditAndPropose(document, target, instruction, {
onProgress: ui.onProgress,
signal: ui.signal,
});
} catch (err) {
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
if (token.isCancellationRequested) return [] as string[];
throw err;
}
},
);
if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
@@ -258,6 +297,7 @@ 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,
@@ -267,13 +307,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);
const turn = await this.editTurn(instruction, selected, opts);
if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full);
const turn = await this.editTurn(instruction, full, opts);
const ids: string[] = [];
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
// granularity — one proposal per changed block (the unit a human reviews) —
+122
View File
@@ -0,0 +1,122 @@
/**
* turnProgress.ts — pure reduction of @cline/sdk Agent runtime events into a
* small UI-facing progress snapshot (#60, spec coauthoring-live-progress.md §3.2).
*
* INV-43: vscode-free. INV-46: a pure function — no vscode, no SDK runtime
* dependency (`AgentRuntimeEvent` is imported TYPE-only, so it is erased at
* compile and never pulls the ESM SDK into the bundle). All event→state logic
* lives here so it is unit-tested in isolation; the UI call sites only format and
* relay snapshots.
*/
import type { AgentRuntimeEvent } from "@cline/shared";
export type TurnPhase = "thinking" | "writing" | "tool";
export interface TurnProgressSnapshot {
phase: TurnPhase;
/** present iff phase === "tool" — the running tool's name. */
tool?: string;
/** accumulated assistant-text length so far. */
chars: number;
/** running total tokens (input+output); undefined until the first usage event. */
tokens?: number;
/** the new assistant-text chunk since the last snapshot (for the OutputChannel). */
textDelta?: string;
}
export interface TurnProgressState {
phase: TurnPhase;
chars: number;
tokens?: number;
/** stack of tool names currently running (depth-tracked for overlap). */
activeTools: string[];
/** true once any assistant text has streamed (tool-finish then reverts to writing). */
sawText: boolean;
}
export function createTurnProgressState(): TurnProgressState {
return { phase: "thinking", chars: 0, tokens: undefined, activeTools: [], sawText: false };
}
function restingPhase(state: TurnProgressState): TurnPhase {
if (state.activeTools.length) return "tool";
return state.sawText ? "writing" : "thinking";
}
function toSnapshot(state: TurnProgressState, textDelta?: string): TurnProgressSnapshot {
return {
phase: state.phase,
tool: state.phase === "tool" ? state.activeTools[state.activeTools.length - 1] : undefined,
chars: state.chars,
tokens: state.tokens,
textDelta,
};
}
/**
* Fold one SDK event into the state, returning the next state and the snapshot to
* emit (snapshot undefined for events that don't change the surface).
*/
export function reduceTurnProgress(
state: TurnProgressState,
event: AgentRuntimeEvent,
): { state: TurnProgressState; snapshot?: TurnProgressSnapshot } {
switch (event.type) {
case "run-started":
case "turn-started": {
const next: TurnProgressState = { ...state, phase: restingPhase(state) };
return { state: next, snapshot: toSnapshot(next) };
}
case "assistant-text-delta": {
const next: TurnProgressState = {
...state,
phase: state.activeTools.length ? "tool" : "writing",
chars: event.accumulatedText.length,
sawText: true,
};
return { state: next, snapshot: toSnapshot(next, event.text) };
}
case "assistant-reasoning-delta": {
// Reasoning TEXT is not surfaced (operator fork); collapse to motion only.
const next: TurnProgressState = { ...state, phase: state.activeTools.length ? "tool" : "thinking" };
return { state: next, snapshot: toSnapshot(next) };
}
case "tool-started": {
const activeTools = [...state.activeTools, event.toolCall.toolName];
const next: TurnProgressState = { ...state, phase: "tool", activeTools };
return { state: next, snapshot: toSnapshot(next) };
}
case "tool-updated": {
const next: TurnProgressState = { ...state, phase: "tool" };
return { state: next, snapshot: toSnapshot(next) };
}
case "tool-finished": {
const name = event.toolCall.toolName;
const idx = state.activeTools.lastIndexOf(name);
const activeTools = idx >= 0 ? state.activeTools.filter((_, i) => i !== idx) : state.activeTools.slice(0, -1);
const next: TurnProgressState = { ...state, activeTools, phase: "thinking" };
next.phase = restingPhase(next);
return { state: next, snapshot: toSnapshot(next) };
}
case "usage-updated": {
const tokens = event.usage.inputTokens + event.usage.outputTokens || undefined;
const next: TurnProgressState = { ...state, tokens };
return { state: next, snapshot: toSnapshot(next) };
}
default:
return { state };
}
}
/** Render the notification activity line from a snapshot (pure; spec §2.1). */
export function formatProgressLine(s: TurnProgressSnapshot): string {
let head: string;
if (s.phase === "tool") head = `running ${s.tool ?? "tool"}`;
else if (s.phase === "writing") head = `writing… (${s.chars} chars)`;
else head = "thinking…";
return s.tokens ? `${head} · ${formatTokens(s.tokens)} tokens` : head;
}
export function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
+26
View File
@@ -60,3 +60,29 @@ export function selectionRejection(ctx: SelectionContext): string | null {
}
return null;
}
export interface EditRouteContext {
/** Was the command invoked on a specific resource (a tab right-click)? */
hasUri: boolean;
/** Does that resource match the focused editor's document? */
uriMatchesActiveEditor: boolean;
/** Is there a focused text editor at all? */
hasActiveEditor: boolean;
/** Is the focused editor's selection empty (no highlight)? */
selectionEmpty: boolean;
}
/**
* Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the
* selection or whole-document flow. One conceptual command, two destinations:
*
* - A tab right-click on a document that ISN'T the focused editor has no
* selection to act on → edit the whole document.
* - Otherwise a non-empty selection in the focused editor → edit the selection;
* an empty selection (or no editor) → edit the whole document.
*/
export function routeEdit(ctx: EditRouteContext): "selection" | "document" {
if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document";
if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection";
return "document";
}
@@ -28,7 +28,10 @@ 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",
]) {
+14 -8
View File
@@ -174,16 +174,22 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
});
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
// SLICE-3: the document-scoped command stays registered for #42 reuse (now behind
// the unified `cowriting.edit`). editDocument is hidden from the palette; the
// markdown-guarded user-facing palette command is `cowriting.edit`.
test("cowriting.editDocument stays registered; cowriting.edit is the markdown-guarded palette command", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.editDocument",
);
assert.ok(entry, "editDocument has a commandPalette entry");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
assert.ok(all.includes("cowriting.edit"), "unified edit command registered");
const palette = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")).contributes
.menus.commandPalette as Array<{ command: string; when?: string }>;
// editDocument is hidden (routed via cowriting.edit).
const docEntry = palette.find((m) => m.command === "cowriting.editDocument");
assert.strictEqual(docEntry?.when, "false", "editDocument hidden from the palette");
// cowriting.edit is the user-facing, markdown-guarded entry.
const editEntry = palette.find((m) => m.command === "cowriting.edit");
assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
+32 -38
View File
@@ -31,43 +31,37 @@ function menu(id: string): Array<{ command: string; when?: string; group?: strin
}
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
// the editor TAB, selection-aware (selection → editSelection; no selection →
// editDocument), both markdown/authorable-gated, both routing through the single
// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we
// assert them directly; the tab-targeting behavior is exercised through the command.
// the editor TAB. The two split commands (editSelection / editDocument) were
// unified behind ONE user-facing command `cowriting.edit` that auto-routes by
// selection at runtime (routeEdit: selection → editSelection, none → editDocument)
// — so the menus carry a SINGLE selection-agnostic entry, both markdown/authorable
// -gated. The routing itself is unit-tested (routeEdit) and exercised through the
// command below; here we assert the declarative menu `when` clauses.
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
// PUC-1/2: editor BODY (editor/context) offers the single unified entry,
// markdown + authorable gated, NOT selection-gated (it routes both cases).
test("editor/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown+authorable", () => {
const m = menu("editor/context");
const sel = m.find((e) => e.command === "cowriting.editSelection");
const doc = m.find((e) => e.command === "cowriting.editDocument");
assert.ok(sel, "editSelection is in editor/context");
assert.ok(doc, "editDocument is in editor/context");
assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection");
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection");
assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown");
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
const edit = m.find((e) => e.command === "cowriting.edit");
assert.ok(edit, "cowriting.edit is in editor/context");
assert.match(edit!.when ?? "", /editorLangId == markdown/, "gated on markdown");
assert.match(edit!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "gated authorable");
assert.ok(!/editorHasSelection/.test(edit!.when ?? ""), "not selection-gated — one entry routes both cases");
// The old split pair is gone from the menu (the commands stay registered/hidden).
assert.ok(!m.some((e) => e.command === "cowriting.editSelection"), "old editSelection menu entry removed");
assert.ok(!m.some((e) => e.command === "cowriting.editDocument"), "old editDocument menu entry removed");
});
// PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair.
test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => {
// PUC-3: editor TAB (editor/title/context) carries the same single entry.
test("editor/title/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown-gated", () => {
const m = menu("editor/title/context");
const sel = m.find((e) => e.command === "cowriting.editSelection");
const doc = m.find((e) => e.command === "cowriting.editDocument");
assert.ok(sel, "editSelection is in editor/title/context");
assert.ok(doc, "editDocument is in editor/title/context");
assert.match(sel!.when ?? "", /editorHasSelection/, "tab editSelection shows only with a selection");
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "tab editSelection is not gated on NO selection");
assert.match(sel!.when ?? "", /resourceLangId == markdown/, "tab editSelection gated on markdown");
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "tab editDocument shows only without a selection");
assert.match(doc!.when ?? "", /resourceLangId == markdown/, "tab editDocument gated on markdown");
const edit = m.find((e) => e.command === "cowriting.edit");
assert.ok(edit, "cowriting.edit is in editor/title/context");
assert.match(edit!.when ?? "", /resourceLangId == markdown/, "tab entry gated on markdown");
assert.ok(
!m.some((e) => e.command === "cowriting.editSelection" || e.command === "cowriting.editDocument"),
"old split pair removed from the tab menu",
);
});
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
@@ -83,8 +77,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 origInput = vscode.window.showInputBox;
(vscode.window as any).showInputBox = async () => "rewrite it";
const origPrompt = api.inlineAsk.prompt;
(api.inlineAsk as any).prompt = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
model: "sonnet",
@@ -94,7 +88,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle();
} finally {
(vscode.window as any).showInputBox = origInput;
(api.inlineAsk as any).prompt = origPrompt;
}
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
@@ -116,8 +110,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.window.showTextDocument(a.doc);
await settle();
const origInput = vscode.window.showInputBox;
(vscode.window as any).showInputBox = async () => "rewrite it";
const origPrompt = api.inlineAsk.prompt;
(api.inlineAsk as any).prompt = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
model: "sonnet",
@@ -127,7 +121,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument");
await settle();
} finally {
(vscode.window as any).showInputBox = origInput;
(api.inlineAsk as any).prompt = origPrompt;
}
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
});
+83
View File
@@ -0,0 +1,83 @@
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400));
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
return api;
}
async function freshDoc(rel: string, body: string): Promise<vscode.TextDocument> {
const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body, "utf8");
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(abs));
await vscode.window.showTextDocument(doc);
await settle();
return doc;
}
// #60 host E2E (no LLM): live progress is purely additive observability. The
// harness can't read notification subtitles, so it asserts the CONTRACT —
// progress events don't change the proposals (INV-44), and an aborted turn
// proposes nothing (INV-47). The live notification/OutputChannel UI itself is
// covered by the turnProgress unit tests + the manual smoke.
suite("#60 live turn progress (additive + cancel)", () => {
test("a stub that emits progress still produces the same proposals (INV-44)", async () => {
const doc = await freshDoc("docs/live60-additive.md", "# Title\n\nOld paragraph.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// The stub honors opts.onProgress (emitting synthetic snapshots) but returns
// the same rewrite — proposals must be unaffected by progress events.
ctl.setEditTurnForTest(async (_i, _text, opts) => {
opts?.onProgress?.({ phase: "writing", chars: 5 });
opts?.onProgress?.({ phase: "writing", chars: 13, tokens: 1234, textDelta: "New paragraph." });
return { replacement: "# Title\n\nNew paragraph.\n", model: "sonnet", sessionId: "e2e-live60" };
});
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite the paragraph");
await settle();
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
assert.ok(view, "the proposal is live");
assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)");
});
test("an aborted turn proposes nothing (INV-47)", async () => {
const doc = await freshDoc("docs/live60-cancel.md", "# Title\n\nOld paragraph.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// The stub throws ONLY when the aborted signal reached it — so if opts.signal
// failed to thread through runEditAndPropose, the stub would instead return a
// rewrite and create a proposal, failing this test. That proves propagation.
ctl.setEditTurnForTest(async (_i, _text, opts) => {
if (opts?.signal?.aborted) throw new Error("claude-code turn aborted");
return { replacement: "# Title\n\nSHOULD NOT BE PROPOSED.\n", model: "sonnet", sessionId: "e2e-live60-nope" };
});
const ac = new AbortController();
ac.abort();
let ids: string[] = [];
try {
ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "rewrite", { signal: ac.signal });
} catch {
ids = [];
}
await settle();
assert.strictEqual(ids.length, 0, "aborted turn must create no proposals");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no pending proposals after abort");
});
});
+59 -2
View File
@@ -1,5 +1,32 @@
import { describe, it, expect } from "vitest";
import { extractReplacement } from "../src/liveTurn";
import { describe, it, expect, vi } from "vitest";
import { extractReplacement, runEditTurn } from "../src/liveTurn";
// A fake Agent that replays a scripted event list to subscribers, supports abort,
// and resolves agent.run() with a completed (or aborted) result.
const runs = { count: 0 };
function fakeSdk(events: any[]) {
return {
Agent: class {
private listeners: ((e: any) => void)[] = [];
constructor(_cfg: unknown) {}
subscribe(fn: (e: any) => void) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((l) => l !== fn);
};
}
// The real SDK's abort() is a no-op before run() creates its AbortController,
// so this fake does NOT cooperate with a pre-abort — proving runEditTurn's own
// short-circuit, not the fake's leniency.
abort() {}
async run(_input: string) {
runs.count += 1;
for (const e of events) for (const l of this.listeners) l(e);
return { status: "completed", outputText: "EDITED", runId: "r1" };
}
},
};
}
describe("extractReplacement", () => {
it("returns plain text untouched", () => {
@@ -24,3 +51,33 @@ describe("extractReplacement", () => {
expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain");
});
});
describe("runEditTurn progress + cancel", () => {
it("emits progress snapshots and returns the replacement unchanged (INV-44)", async () => {
vi.resetModules();
vi.doMock("@cline/sdk", () =>
fakeSdk([
{ type: "assistant-text-delta", text: "ED", accumulatedText: "ED" },
{ type: "usage-updated", usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 } },
]),
);
const seen: string[] = [];
const turn = await runEditTurn("do it", "old", { onProgress: (s) => seen.push(s.phase) });
expect(turn.replacement).toBe("EDITED");
expect(seen).toContain("writing");
vi.doUnmock("@cline/sdk");
vi.resetModules();
});
it("a pre-aborted AbortSignal short-circuits before run() and throws (INV-47)", async () => {
vi.resetModules();
vi.doMock("@cline/sdk", () => fakeSdk([]));
runs.count = 0;
const ac = new AbortController();
ac.abort();
await expect(runEditTurn("do it", "old", { signal: ac.signal })).rejects.toThrow(/aborted/);
expect(runs.count).toBe(0); // the turn never ran — no proposal could be produced
vi.doUnmock("@cline/sdk");
vi.resetModules();
});
});
+15 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import assert from "node:assert";
import { describe, it, test, expect } from "vitest";
import {
SCHEMA_VERSION,
emptyArtifact,
@@ -179,6 +180,19 @@ describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => {
});
});
test("serializeArtifact round-trips Proposal.original", () => {
const a = emptyArtifact("doc.md");
a.anchors["a_1"] = { fingerprint: { text: "new", before: "", after: "", lineHint: 0 } };
a.proposals.push({
id: "pr_1", anchorId: "a_1", replacement: "new",
author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "s" } },
createdAt: "2026-06-26T00:00:00.000Z", original: "old", granularity: "block",
});
const json = serializeArtifact(a);
assert.match(json, /"original": "old"/);
assert.match(json, /"granularity": "block"/);
});
describe("attributions[] (F3 SLICE-1)", () => {
it("round-trips an attribution record through serialize → parse", () => {
const a = emptyArtifact("docs/x.md");
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from "vitest";
import {
createTurnProgressState,
reduceTurnProgress,
formatProgressLine,
formatTokens,
type TurnProgressSnapshot,
} from "../src/turnProgress";
// Minimal event factories — structurally match the @cline/sdk AgentRuntimeEvent
// members the reducer reads. `as any` because we only supply the fields used.
const ev = (e: any) => e as any;
const textDelta = (text: string, accumulatedText: string) =>
ev({ type: "assistant-text-delta", text, accumulatedText });
const toolStarted = (toolName: string) => ev({ type: "tool-started", toolCall: { toolName } });
const toolFinished = (toolName: string) => ev({ type: "tool-finished", toolCall: { toolName } });
const usage = (inputTokens: number, outputTokens: number) =>
ev({ type: "usage-updated", usage: { inputTokens, outputTokens, cacheReadTokens: 0, cacheWriteTokens: 0 } });
// Drive a sequence of events, returning every emitted snapshot.
function run(events: any[]): TurnProgressSnapshot[] {
let state = createTurnProgressState();
const out: TurnProgressSnapshot[] = [];
for (const e of events) {
const r = reduceTurnProgress(state, e);
state = r.state;
if (r.snapshot) out.push(r.snapshot);
}
return out;
}
describe("reduceTurnProgress", () => {
it("starts in thinking", () => {
const s = run([ev({ type: "run-started" })]);
expect(s.at(-1)!.phase).toBe("thinking");
expect(s.at(-1)!.chars).toBe(0);
expect(s.at(-1)!.tokens).toBeUndefined();
});
it("text deltas move to writing and accumulate chars + carry the delta", () => {
const s = run([textDelta("Hel", "Hel"), textDelta("lo", "Hello")]);
expect(s.map((x) => x.phase)).toEqual(["writing", "writing"]);
expect(s.at(-1)!.chars).toBe(5);
expect(s.map((x) => x.textDelta)).toEqual(["Hel", "lo"]);
});
it("usage sets a running token total (input+output)", () => {
const s = run([textDelta("Hi", "Hi"), usage(1000, 234)]);
expect(s.at(-1)!.tokens).toBe(1234);
});
it("tool start shows the tool name; tool finish reverts to writing once text was seen", () => {
const s = run([textDelta("x", "x"), toolStarted("read_file"), toolFinished("read_file")]);
expect(s[1].phase).toBe("tool");
expect(s[1].tool).toBe("read_file");
expect(s.at(-1)!.phase).toBe("writing");
});
it("tool finish reverts to thinking when no text was seen", () => {
const s = run([toolStarted("grep"), toolFinished("grep")]);
expect(s.at(-1)!.phase).toBe("thinking");
});
it("overlapping tools resolve in order", () => {
const s = run([toolStarted("a"), toolStarted("b"), toolFinished("b"), toolFinished("a")]);
expect(s.map((x) => x.phase)).toEqual(["tool", "tool", "tool", "thinking"]);
expect(s[1].tool).toBe("b");
expect(s[2].tool).toBe("a");
});
it("reasoning deltas stay thinking and surface no text", () => {
const s = run([ev({ type: "assistant-reasoning-delta", text: "secret", accumulatedText: "secret" })]);
expect(s.at(-1)!.phase).toBe("thinking");
expect(s.at(-1)!.textDelta).toBeUndefined();
});
it("ignores lifecycle/finish events (no snapshot)", () => {
const s = run([ev({ type: "turn-finished" }), ev({ type: "run-finished" })]);
expect(s).toEqual([]);
});
});
describe("formatProgressLine / formatTokens", () => {
it("thinking", () => {
expect(formatProgressLine({ phase: "thinking", chars: 0 })).toBe("thinking…");
});
it("writing with chars", () => {
expect(formatProgressLine({ phase: "writing", chars: 412 })).toBe("writing… (412 chars)");
});
it("writing with chars + tokens", () => {
expect(formatProgressLine({ phase: "writing", chars: 412, tokens: 1234 })).toBe(
"writing… (412 chars) · 1.2k tokens",
);
});
it("tool with name", () => {
expect(formatProgressLine({ phase: "tool", tool: "read_file", chars: 0 })).toBe("running read_file…");
});
it("formats token magnitudes", () => {
expect(formatTokens(950)).toBe("950");
expect(formatTokens(1234)).toBe("1.2k");
});
});
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath";
import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath";
describe("isUnderRoot", () => {
const root = "/a/vscode-cowriting-plugin/sandbox";
@@ -58,3 +58,27 @@ describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)",
expect(msg).not.toMatch(/select some text/i);
});
});
describe("routeEdit (single Ask-Claude-to-Edit gesture)", () => {
const focused = { hasUri: false, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false };
it("non-empty selection in the focused editor → selection", () => {
expect(routeEdit(focused)).toBe("selection");
});
it("empty selection in the focused editor → document", () => {
expect(routeEdit({ ...focused, selectionEmpty: true })).toBe("document");
});
it("no active editor → document", () => {
expect(routeEdit({ ...focused, hasActiveEditor: false, selectionEmpty: true })).toBe("document");
});
it("tab right-click on a doc that ISN'T the focused editor → document (no selection to act on)", () => {
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false })).toBe(
"document",
);
});
it("tab right-click on the focused editor with a selection → selection", () => {
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: true, hasActiveEditor: true, selectionEmpty: false })).toBe(
"selection",
);
});
});