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
16 changed files with 536 additions and 85 deletions
+53 -15
View File
@@ -59,6 +59,21 @@
"title": "Apply Agent Edit (internal seam)", "title": "Apply Agent Edit (internal seam)",
"category": "Cowriting" "category": "Cowriting"
}, },
{
"command": "cowriting.edit",
"title": "Ask Claude to Edit",
"category": "Cowriting"
},
{
"command": "cowriting.askClaude.submit",
"title": "Ask Claude to Edit",
"category": "Cowriting"
},
{
"command": "cowriting.askClaude.cancel",
"title": "Cancel",
"category": "Cowriting"
},
{ {
"command": "cowriting.editSelection", "command": "cowriting.editSelection",
"title": "Ask Claude to Edit Selection", "title": "Ask Claude to Edit Selection",
@@ -118,14 +133,30 @@
"command": "cowriting.rejectProposal", "command": "cowriting.rejectProposal",
"when": "false" "when": "false"
}, },
{
"command": "cowriting.askClaude.submit",
"when": "false"
},
{
"command": "cowriting.askClaude.cancel",
"when": "false"
},
{ {
"command": "cowriting.pinDiffBaseline", "command": "cowriting.pinDiffBaseline",
"when": "editorLangId == markdown" "when": "editorLangId == markdown"
}, },
{ {
"command": "cowriting.editDocument", "command": "cowriting.edit",
"when": "editorLangId == markdown" "when": "editorLangId == markdown"
}, },
{
"command": "cowriting.editSelection",
"when": "false"
},
{
"command": "cowriting.editDocument",
"when": "false"
},
{ {
"command": "cowriting.acceptAllProposals", "command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown" "when": "editorLangId == markdown"
@@ -140,13 +171,8 @@
], ],
"editor/title/context": [ "editor/title/context": [
{ {
"command": "cowriting.editSelection", "command": "cowriting.edit",
"when": "editorHasSelection && resourceLangId == markdown", "when": "resourceLangId == markdown",
"group": "1_cowriting@1"
},
{
"command": "cowriting.editDocument",
"when": "!editorHasSelection && resourceLangId == markdown",
"group": "1_cowriting@1" "group": "1_cowriting@1"
}, },
{ {
@@ -164,13 +190,8 @@
], ],
"editor/context": [ "editor/context": [
{ {
"command": "cowriting.editSelection", "command": "cowriting.edit",
"when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)", "when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"group": "1_cowriting@1"
},
{
"command": "cowriting.editDocument",
"when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
"group": "1_cowriting@1" "group": "1_cowriting@1"
}, },
{ {
@@ -184,6 +205,11 @@
"command": "cowriting.reply", "command": "cowriting.reply",
"group": "inline", "group": "inline",
"when": "commentController == cowriting.threads" "when": "commentController == cowriting.threads"
},
{
"command": "cowriting.askClaude.submit",
"group": "inline",
"when": "commentController == cowriting.askClaude"
} }
], ],
"comments/commentThread/title": [ "comments/commentThread/title": [
@@ -196,6 +222,11 @@
"command": "cowriting.reopenThread", "command": "cowriting.reopenThread",
"group": "inline", "group": "inline",
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
},
{
"command": "cowriting.askClaude.cancel",
"group": "inline",
"when": "commentController == cowriting.askClaude"
} }
] ]
}, },
@@ -203,7 +234,14 @@
{ {
"command": "cowriting.showTrackChangesPreview", "command": "cowriting.showTrackChangesPreview",
"key": "ctrl+alt+r", "key": "ctrl+alt+r",
"mac": "cmd+alt+r",
"when": "editorLangId == markdown" "when": "editorLangId == markdown"
},
{
"command": "cowriting.edit",
"key": "ctrl+alt+e",
"mac": "cmd+alt+e",
"when": "editorTextFocus && editorLangId == markdown"
} }
] ]
}, },
@@ -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 > App: vscode-cowriting-plugin
> Start: 2026-06-26T04-24 (PST) > Start: 2026-06-26T06-01 (PST)
> Type: planning-and-executing > Type: planning-and-executing
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.** > Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
> >
> This file reserves session ID 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 > 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 ## Launch prompt
+9
View File
@@ -166,5 +166,14 @@
}, },
"0056": { "0056": {
"title": "" "title": ""
},
"0057": {
"title": ""
},
"0058": {
"title": ""
},
"0059": {
"title": ""
} }
} }
+37 -11
View File
@@ -12,7 +12,8 @@ import { SidecarRouter } from "./sidecarRouter";
import { DiffViewController } from "./diffViewController"; import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview"; import { TrackChangesPreviewController } from "./trackChangesPreview";
import { LiveProgressUi } from "./liveProgressUi"; import { LiveProgressUi } from "./liveProgressUi";
import { isAuthorable, selectionRejection } from "./workspacePath"; import { InlineAskController } from "./inlineAsk";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
const CHANNEL_NAME = "Cowriting (Cline SDK)"; const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -25,6 +26,7 @@ export interface CowritingApi {
trackChangesPreviewController: TrackChangesPreviewController; trackChangesPreviewController: TrackChangesPreviewController;
sidecarRouter: SidecarRouter; sidecarRouter: SidecarRouter;
liveProgressUi: LiveProgressUi; liveProgressUi: LiveProgressUi;
inlineAsk: InlineAskController;
} }
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -36,6 +38,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
// OutputChannel) for both Ask-Claude entry points. // OutputChannel) for both Ask-Claude entry points.
const liveProgressUi = new LiveProgressUi(); const liveProgressUi = new LiveProgressUi();
context.subscriptions.push(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( context.subscriptions.push(
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => { vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
try { try {
@@ -112,6 +119,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
attributionController, attributionController,
proposalController, proposalController,
liveProgressUi, liveProgressUi,
inlineAsk,
); );
context.subscriptions.push(trackChangesPreviewController); context.subscriptions.push(trackChangesPreviewController);
@@ -217,17 +225,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
return; return;
} }
if (!editor) return; // unreachable once reason is null, but narrows the type if (!editor) return; // unreachable once reason is null, but narrows the type
const instruction = await vscode.window.showInputBox({
prompt: "What should Claude do with the selection?",
placeHolder: "e.g. tighten this paragraph",
});
if (!instruction) return;
if (editor.selection.isEmpty) {
void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first.");
return;
}
const document = editor.document; const document = editor.document;
const selection = editor.selection; 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); const selectedText = document.getText(selection);
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits // 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. // can't skew it — the proposal renders wherever the target re-resolves.
@@ -299,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. // Render threads + attributions for already-open editors, and on future opens.
const renderIfOpen = (doc: vscode.TextDocument) => { const renderIfOpen = (doc: vscode.TextDocument) => {
if (isAuthorable(doc.uri.scheme)) { if (isAuthorable(doc.uri.scheme)) {
@@ -319,6 +344,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
trackChangesPreviewController, trackChangesPreviewController,
sidecarRouter, sidecarRouter,
liveProgressUi, 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);
}
}
+10
View File
@@ -97,6 +97,14 @@ export interface Proposal {
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole. * back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
*/ */
granularity?: "block" | "single"; granularity?: "block" | "single";
/**
* F12/#64 (INV-48): the pre-apply text, captured when a proposal is
* optimistically applied to the buffer. `replacement` is now in the buffer and
* `fp.text` re-anchors to it, so `original` is the only record of what to revert
* to (revert-in-place) and what to show struck in the `<del>` half. Absent on a
* proposal created but not yet optimistically applied (or older sidecars).
*/
original?: string;
} }
export interface Artifact { export interface Artifact {
@@ -252,6 +260,8 @@ export function serializeArtifact(a: Artifact): string {
createdAt: p.createdAt, createdAt: p.createdAt,
...(p.turnId !== undefined ? { turnId: p.turnId } : {}), ...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
...(p.instruction !== undefined ? { instruction: p.instruction } : {}), ...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
...(p.original !== undefined ? { original: p.original } : {}),
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
}, },
p, p,
), ),
+24 -7
View File
@@ -18,6 +18,7 @@ import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath"; import { isAuthorable } from "./workspacePath";
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
import type { LiveProgressUi } from "./liveProgressUi"; import type { LiveProgressUi } from "./liveProgressUi";
import type { InlineAskController } from "./inlineAsk";
/** /**
* F11: a host edit turn (selection/document text + instruction → rewrite). * F11: a host edit turn (selection/document text + instruction → rewrite).
@@ -74,6 +75,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly attribution: AttributionController, private readonly attribution: AttributionController,
private readonly proposals: ProposalController, private readonly proposals: ProposalController,
private readonly liveProgressUi: LiveProgressUi, private readonly liveProgressUi: LiveProgressUi,
private readonly inlineAsk: InlineAskController,
) { ) {
this.disposables.push( this.disposables.push(
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
@@ -220,19 +222,34 @@ 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 * F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface * surface out of the sealed webview, INV-8/35), run the edit turn, and surface
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`. * the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
*/ */
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> { private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
const instruction = await vscode.window.showInputBox({ // The instruction prompt opens INLINE (inlineAsk) at the target: the selected
prompt: // range for a range edit, or the editor's cursor line / the document start for
target.kind === "document" // a whole-document edit — never the top-center QuickInput.
? "What should Claude do with the document?" const anchor = this.editTargetAnchor(document, target);
: "What should Claude do with the selection?", const instruction = await this.inlineAsk.prompt(document.uri, anchor);
placeHolder: "e.g. tighten the prose",
});
if (!instruction) return; if (!instruction) return;
try { try {
const ids = await vscode.window.withProgress( const ids = await vscode.window.withProgress(
+26
View File
@@ -60,3 +60,29 @@ export function selectionRejection(ctx: SelectionContext): string | null {
} }
return null; return null;
} }
export interface EditRouteContext {
/** Was the command invoked on a specific resource (a tab right-click)? */
hasUri: boolean;
/** Does that resource match the focused editor's document? */
uriMatchesActiveEditor: boolean;
/** Is there a focused text editor at all? */
hasActiveEditor: boolean;
/** Is the focused editor's selection empty (no highlight)? */
selectionEmpty: boolean;
}
/**
* Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the
* selection or whole-document flow. One conceptual command, two destinations:
*
* - A tab right-click on a document that ISN'T the focused editor has no
* selection to act on → edit the whole document.
* - Otherwise a non-empty selection in the focused editor → edit the selection;
* an empty selection (or no editor) → edit the whole document.
*/
export function routeEdit(ctx: EditRouteContext): "selection" | "document" {
if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document";
if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection";
return "document";
}
@@ -28,7 +28,10 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
"cowriting.reply", "cowriting.reply",
"cowriting.resolveThread", "cowriting.resolveThread",
"cowriting.reopenThread", "cowriting.reopenThread",
"cowriting.edit",
"cowriting.editSelection", "cowriting.editSelection",
"cowriting.askClaude.submit",
"cowriting.askClaude.cancel",
"cowriting.applyAgentEdit", "cowriting.applyAgentEdit",
"cowriting.proposeAgentEdit", "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"); 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. // SLICE-3: the document-scoped command stays registered for #42 reuse (now behind
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => { // 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); const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered"); assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); assert.ok(all.includes("cowriting.edit"), "unified edit command registered");
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( const palette = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")).contributes
(m) => m.command === "cowriting.editDocument", .menus.commandPalette as Array<{ command: string; when?: string }>;
); // editDocument is hidden (routed via cowriting.edit).
assert.ok(entry, "editDocument has a commandPalette entry"); const docEntry = palette.find((m) => m.command === "cowriting.editDocument");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); 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). // 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 // SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
// the editor TAB, selection-aware (selection → editSelection; no selection → // the editor TAB. The two split commands (editSelection / editDocument) were
// editDocument), both markdown/authorable-gated, both routing through the single // unified behind ONE user-facing command `cowriting.edit` that auto-routes by
// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we // selection at runtime (routeEdit: selection → editSelection, none → editDocument)
// assert them directly; the tab-targeting behavior is exercised through the command. // — 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)", () => { suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated. // PUC-1/2: editor BODY (editor/context) offers the single unified entry,
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => { // 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 m = menu("editor/context");
const sel = m.find((e) => e.command === "cowriting.editSelection"); const edit = m.find((e) => e.command === "cowriting.edit");
const doc = m.find((e) => e.command === "cowriting.editDocument"); assert.ok(edit, "cowriting.edit is in editor/context");
assert.ok(sel, "editSelection is in editor/context"); assert.match(edit!.when ?? "", /editorLangId == markdown/, "gated on markdown");
assert.ok(doc, "editDocument is in editor/context"); assert.match(edit!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "gated authorable");
assert.ok(!/editorHasSelection/.test(edit!.when ?? ""), "not selection-gated — one entry routes both cases");
assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection"); // The old split pair is gone from the menu (the commands stay registered/hidden).
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection"); assert.ok(!m.some((e) => e.command === "cowriting.editSelection"), "old editSelection menu entry removed");
assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown"); assert.ok(!m.some((e) => e.command === "cowriting.editDocument"), "old editDocument menu entry removed");
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
}); });
// PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair. // PUC-3: editor TAB (editor/title/context) carries the same single entry.
test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => { test("editor/title/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown-gated", () => {
const m = menu("editor/title/context"); const m = menu("editor/title/context");
const sel = m.find((e) => e.command === "cowriting.editSelection"); const edit = m.find((e) => e.command === "cowriting.edit");
const doc = m.find((e) => e.command === "cowriting.editDocument"); assert.ok(edit, "cowriting.edit is in editor/title/context");
assert.ok(sel, "editSelection is in editor/title/context"); assert.match(edit!.when ?? "", /resourceLangId == markdown/, "tab entry gated on markdown");
assert.ok(doc, "editDocument is in editor/title/context"); assert.ok(
!m.some((e) => e.command === "cowriting.editSelection" || e.command === "cowriting.editDocument"),
assert.match(sel!.when ?? "", /editorHasSelection/, "tab editSelection shows only with a selection"); "old split pair removed from the tab menu",
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "tab editSelection is not gated on NO selection"); );
assert.match(sel!.when ?? "", /resourceLangId == markdown/, "tab editSelection gated on markdown");
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "tab editDocument shows only without a selection");
assert.match(doc!.when ?? "", /resourceLangId == markdown/, "tab editDocument gated on markdown");
}); });
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document, // PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
@@ -83,8 +77,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await settle(); await settle();
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn. // Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
const origInput = vscode.window.showInputBox; const origPrompt = api.inlineAsk.prompt;
(vscode.window as any).showInputBox = async () => "rewrite it"; (api.inlineAsk as any).prompt = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({ ctl.setEditTurnForTest(async () => ({
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n", replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
model: "sonnet", model: "sonnet",
@@ -94,7 +88,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri); await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
await settle(); await settle();
} finally { } finally {
(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. // 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 vscode.window.showTextDocument(a.doc);
await settle(); await settle();
const origInput = vscode.window.showInputBox; const origPrompt = api.inlineAsk.prompt;
(vscode.window as any).showInputBox = async () => "rewrite it"; (api.inlineAsk as any).prompt = async () => "rewrite it";
ctl.setEditTurnForTest(async () => ({ ctl.setEditTurnForTest(async () => ({
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n", replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
model: "sonnet", model: "sonnet",
@@ -127,7 +121,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
await vscode.commands.executeCommand("cowriting.editDocument"); await vscode.commands.executeCommand("cowriting.editDocument");
await settle(); await settle();
} finally { } finally {
(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"); assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
}); });
+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 { import {
SCHEMA_VERSION, SCHEMA_VERSION,
emptyArtifact, 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)", () => { describe("attributions[] (F3 SLICE-1)", () => {
it("round-trips an attribution record through serialize → parse", () => { it("round-trips an attribution record through serialize → parse", () => {
const a = emptyArtifact("docs/x.md"); const a = emptyArtifact("docs/x.md");
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath"; import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath";
describe("isUnderRoot", () => { describe("isUnderRoot", () => {
const root = "/a/vscode-cowriting-plugin/sandbox"; 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); 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",
);
});
});