Compare commits
4 Commits
97e48ea4f8
...
d6b3c6fa5f
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b3c6fa5f | |||
| a42e5f145d | |||
| 26628c0dbe | |||
| 644885c6ec |
+63
-15
@@ -18,6 +18,16 @@
|
|||||||
"onStartupFinished"
|
"onStartupFinished"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"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": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "cowriting.showClineSdkInfo",
|
"command": "cowriting.showClineSdkInfo",
|
||||||
@@ -49,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",
|
||||||
@@ -108,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"
|
||||||
@@ -130,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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -154,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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -174,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": [
|
||||||
@@ -186,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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -193,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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ console.log(`instruction: ${instruction}`);
|
|||||||
console.log(`text: ${text}`);
|
console.log(`text: ${text}`);
|
||||||
try {
|
try {
|
||||||
const t0 = Date.now();
|
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(`replacement: ${JSON.stringify(result.replacement)}`);
|
||||||
console.log(`model: ${result.model}`);
|
console.log(`model: ${result.model}`);
|
||||||
console.log(`sessionId: ${result.sessionId}`);
|
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.
|
||||||
+7
-5
@@ -1,13 +1,15 @@
|
|||||||
# Session 0056.0 — Transcript
|
# Session 0057.0 — Transcript
|
||||||
|
|
||||||
> App: vscode-cowriting-plugin
|
> App: vscode-cowriting-plugin
|
||||||
> Start: 2026-06-26T04-24 (PST)
|
> Start: 2026-06-26T04-35 (PST)
|
||||||
> Type: planning-and-executing
|
> Type: executing-plans
|
||||||
|
> Posture: yolo
|
||||||
|
> Claude-Session: 3d66a467-8026-472d-9693-52a37939d493
|
||||||
> 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 0057 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-0057.0-TRANSCRIPT-2026-06-26T04-35--<end>.md form at session end.
|
||||||
|
|
||||||
## Launch prompt
|
## Launch prompt
|
||||||
|
|
||||||
@@ -166,5 +166,8 @@
|
|||||||
},
|
},
|
||||||
"0056": {
|
"0056": {
|
||||||
"title": ""
|
"title": ""
|
||||||
|
},
|
||||||
|
"0057": {
|
||||||
|
"title": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-14
@@ -11,7 +11,9 @@ import { GlobalSidecarStore } from "./globalSidecarStore";
|
|||||||
import { SidecarRouter } from "./sidecarRouter";
|
import { SidecarRouter } from "./sidecarRouter";
|
||||||
import { DiffViewController } from "./diffViewController";
|
import { DiffViewController } from "./diffViewController";
|
||||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
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)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
|
|
||||||
@@ -23,12 +25,24 @@ export interface CowritingApi {
|
|||||||
diffViewController: DiffViewController;
|
diffViewController: DiffViewController;
|
||||||
trackChangesPreviewController: TrackChangesPreviewController;
|
trackChangesPreviewController: TrackChangesPreviewController;
|
||||||
sidecarRouter: SidecarRouter;
|
sidecarRouter: SidecarRouter;
|
||||||
|
liveProgressUi: LiveProgressUi;
|
||||||
|
inlineAsk: InlineAskController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||||
// --- POC command (Feature #2), unchanged ---
|
// --- POC command (Feature #2), unchanged ---
|
||||||
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
|
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
|
||||||
context.subscriptions.push(output);
|
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(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
||||||
try {
|
try {
|
||||||
@@ -104,6 +118,8 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
context.extensionUri,
|
context.extensionUri,
|
||||||
attributionController,
|
attributionController,
|
||||||
proposalController,
|
proposalController,
|
||||||
|
liveProgressUi,
|
||||||
|
inlineAsk,
|
||||||
);
|
);
|
||||||
context.subscriptions.push(trackChangesPreviewController);
|
context.subscriptions.push(trackChangesPreviewController);
|
||||||
|
|
||||||
@@ -209,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.
|
||||||
@@ -230,10 +241,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
const turnId = `turn-${Date.now().toString(36)}`;
|
const turnId = `turn-${Date.now().toString(36)}`;
|
||||||
try {
|
try {
|
||||||
await vscode.window.withProgress(
|
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 { 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 === "") {
|
if (turn.replacement === "") {
|
||||||
void vscode.window.showWarningMessage(
|
void vscode.window.showWarningMessage(
|
||||||
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
|
"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.
|
// 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)) {
|
||||||
@@ -292,6 +343,8 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
diffViewController,
|
diffViewController,
|
||||||
trackChangesPreviewController,
|
trackChangesPreviewController,
|
||||||
sidecarRouter,
|
sidecarRouter,
|
||||||
|
liveProgressUi,
|
||||||
|
inlineAsk,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-2
@@ -9,6 +9,9 @@
|
|||||||
* never bundled (esbuild keeps it external).
|
* never bundled (esbuild keeps it external).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { TurnProgressSnapshot } from "./turnProgress";
|
||||||
|
import { createTurnProgressState, reduceTurnProgress } from "./turnProgress";
|
||||||
|
|
||||||
export interface EditTurnResult {
|
export interface EditTurnResult {
|
||||||
replacement: string;
|
replacement: string;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -16,6 +19,19 @@ export interface EditTurnResult {
|
|||||||
sessionId: string;
|
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 = [
|
const SYSTEM_PROMPT = [
|
||||||
"You are a precise text editor embedded in VS Code.",
|
"You are a precise text editor embedded in VS Code.",
|
||||||
"You will be given a piece of text and an instruction.",
|
"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(
|
export async function runEditTurn(
|
||||||
instruction: string,
|
instruction: string,
|
||||||
selectedText: string,
|
selectedText: string,
|
||||||
opts?: { modelId?: string },
|
opts?: RunEditTurnOptions,
|
||||||
): Promise<EditTurnResult> {
|
): Promise<EditTurnResult> {
|
||||||
const sdk = await import("@cline/sdk");
|
const sdk = await import("@cline/sdk");
|
||||||
const modelId = opts?.modelId ?? "sonnet";
|
const modelId = opts?.modelId ?? "sonnet";
|
||||||
@@ -49,11 +65,41 @@ export async function runEditTurn(
|
|||||||
modelId,
|
modelId,
|
||||||
systemPrompt: SYSTEM_PROMPT,
|
systemPrompt: SYSTEM_PROMPT,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stream reduced progress snapshots out (INV-44 additive) and wire cancellation
|
||||||
|
// in via the AbortSignal (INV-47). agent.subscribe returns its unsubscribe fn.
|
||||||
|
let state = createTurnProgressState();
|
||||||
|
const unsubscribe = opts?.onProgress
|
||||||
|
? agent.subscribe((event) => {
|
||||||
|
const next = reduceTurnProgress(state, event);
|
||||||
|
state = next.state;
|
||||||
|
// Observability must never affect the result (INV-44): a throwing relay
|
||||||
|
// is swallowed, not allowed to propagate into the SDK and fail the turn.
|
||||||
|
if (next.snapshot) {
|
||||||
|
try {
|
||||||
|
opts.onProgress!(next.snapshot);
|
||||||
|
} catch {
|
||||||
|
/* progress is best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const onAbort = () => agent.abort();
|
||||||
|
opts?.signal?.addEventListener("abort", onAbort);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// A signal already aborted before the turn starts can't be honored by
|
||||||
|
// agent.abort() (the SDK's AbortController isn't created until run()), so
|
||||||
|
// short-circuit to the same aborted outcome the call site reflects (INV-47).
|
||||||
|
if (opts?.signal?.aborted) {
|
||||||
|
throw new Error("claude-code turn aborted: cancelled before start");
|
||||||
|
}
|
||||||
const result = await agent.run(
|
const result = await agent.run(
|
||||||
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
|
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
|
||||||
);
|
);
|
||||||
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
|
// The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed"
|
||||||
// (@cline/shared agent.d.ts) — "completed" is the success status.
|
// (@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") {
|
if (result.status !== "completed") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
|
`claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` +
|
||||||
@@ -61,4 +107,8 @@ export async function runEditTurn(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
|
return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId };
|
||||||
|
} finally {
|
||||||
|
unsubscribe?.();
|
||||||
|
opts?.signal?.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-16
@@ -16,10 +16,16 @@ import type { ProposalController } from "./proposalController";
|
|||||||
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
|
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
|
||||||
import { buildFingerprint } from "./anchorer";
|
import { buildFingerprint } from "./anchorer";
|
||||||
import { isAuthorable } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
import type { EditTurnResult } 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. */
|
/** 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" };
|
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
|
* 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).
|
* 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");
|
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. */
|
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||||
private turnSeq = 0;
|
private turnSeq = 0;
|
||||||
@@ -68,6 +74,8 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
private readonly extensionUri: vscode.Uri,
|
private readonly extensionUri: vscode.Uri,
|
||||||
private readonly attribution: AttributionController,
|
private readonly attribution: AttributionController,
|
||||||
private readonly proposals: ProposalController,
|
private readonly proposals: ProposalController,
|
||||||
|
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;
|
||||||
@@ -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
|
* 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(
|
||||||
{ 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) {
|
if (ids.length === 0) {
|
||||||
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
|
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
|
||||||
@@ -258,6 +297,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
document: vscode.TextDocument,
|
document: vscode.TextDocument,
|
||||||
target: EditTarget,
|
target: EditTarget,
|
||||||
instruction: string,
|
instruction: string,
|
||||||
|
opts?: RunEditTurnOptions,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const full = document.getText();
|
const full = document.getText();
|
||||||
// One turnId per gesture — the document case's N hunk-proposals all share it,
|
// 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 } });
|
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
||||||
if (target.kind === "range") {
|
if (target.kind === "range") {
|
||||||
const selected = full.slice(target.start, target.end);
|
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 [];
|
if (turn.replacement === "" || turn.replacement === selected) return [];
|
||||||
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
||||||
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
||||||
return id ? [id] : [];
|
return id ? [id] : [];
|
||||||
}
|
}
|
||||||
const turn = await this.editTurn(instruction, full);
|
const turn = await this.editTurn(instruction, full, opts);
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
|
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
|
||||||
// granularity — one proposal per changed block (the unit a human reviews) —
|
// granularity — one proposal per changed block (the unit a human reviews) —
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,32 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { extractReplacement } from "../src/liveTurn";
|
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", () => {
|
describe("extractReplacement", () => {
|
||||||
it("returns plain text untouched", () => {
|
it("returns plain text untouched", () => {
|
||||||
@@ -24,3 +51,33 @@ describe("extractReplacement", () => {
|
|||||||
expect(extractReplacement("```\nplain\n```", "plain old text")).toBe("plain");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user