Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ea6c65ca | |||
| 21bbf6b114 | |||
| 0743cf9a8a | |||
| 946625e899 | |||
| 20e13bba4d | |||
| d2405a2ca8 | |||
| 5d9f7ddaaf |
+15
-53
@@ -59,21 +59,6 @@
|
||||
"title": "Apply Agent Edit (internal seam)",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"title": "Ask Claude to Edit",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.submit",
|
||||
"title": "Ask Claude to Edit",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.cancel",
|
||||
"title": "Cancel",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"title": "Ask Claude to Edit Selection",
|
||||
@@ -133,29 +118,13 @@
|
||||
"command": "cowriting.rejectProposal",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.submit",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.cancel",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "false"
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.acceptAllProposals",
|
||||
@@ -171,8 +140,13 @@
|
||||
],
|
||||
"editor/title/context": [
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"when": "resourceLangId == markdown",
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "editorHasSelection && resourceLangId == markdown",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "!editorHasSelection && resourceLangId == markdown",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
@@ -190,8 +164,13 @@
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"command": "cowriting.editSelection",
|
||||
"when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.editDocument",
|
||||
"when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)",
|
||||
"group": "1_cowriting@1"
|
||||
},
|
||||
{
|
||||
@@ -205,11 +184,6 @@
|
||||
"command": "cowriting.reply",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.threads"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.submit",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.askClaude"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/title": [
|
||||
@@ -222,11 +196,6 @@
|
||||
"command": "cowriting.reopenThread",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.askClaude.cancel",
|
||||
"group": "inline",
|
||||
"when": "commentController == cowriting.askClaude"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -234,14 +203,7 @@
|
||||
{
|
||||
"command": "cowriting.showTrackChangesPreview",
|
||||
"key": "ctrl+alt+r",
|
||||
"mac": "cmd+alt+r",
|
||||
"when": "editorLangId == markdown"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.edit",
|
||||
"key": "ctrl+alt+e",
|
||||
"mac": "cmd+alt+e",
|
||||
"when": "editorTextFocus && editorLangId == markdown"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# Session 0056.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T04-24 (PST)
|
||||
> End: 2026-06-26T04-54 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: autonomous (yolo)
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
`/wgl-planning-and-executing implement #60 (live turn progress) from coauthoring-live-progress.md`
|
||||
|
||||
(Continuation of brainstorming session 0055, which graduated the spec; the
|
||||
operator said "implement it" → this coding session.)
|
||||
|
||||
## Plan
|
||||
|
||||
Plan + execute **#60** (P1 feature — live turn progress) from the graduated
|
||||
Solution Design `coauthoring-live-progress.md`. Four cuts: (1) pure
|
||||
`turnProgress.ts` reducer + tests; (2) `runEditTurn` + `onProgress`/`AbortSignal`;
|
||||
(3) `liveProgressUi` + setting + both call sites; (4) host-E2E + smoke.
|
||||
Branch `s60-live-progress` → PR → main.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Clean `main` at session start (fast-forwarded 13 commits — the sessions/ repo
|
||||
is the code repo itself). Baseline: 222 unit green.
|
||||
- #60 has a graduated Solution Design → §4.3 R2(b) satisfied.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Claim + plan.** Claimed 0056 (no in-flight). Wrote the implementation plan
|
||||
with `superpowers:writing-plans` → `docs/superpowers/plans/2026-06-26-live-turn-progress.md`
|
||||
(self-reviewed: full spec coverage, no placeholders). Grounded in the exact SDK
|
||||
types (`AgentToolCallPart.toolName`, `AgentUsage.inputTokens+outputTokens`), the
|
||||
vitest/host-E2E patterns, and the injectable `editTurn` seam. Branch
|
||||
`s60-live-progress`.
|
||||
2. **Task 1 — pure reducer.** `src/turnProgress.ts` + `test/turnProgress.test.ts`
|
||||
(13 cases). Fixed the type-only event import: `AgentRuntimeEvent` is exported
|
||||
from **`@cline/shared`**, not `@cline/sdk` (which doesn't re-export it) — caught
|
||||
by typecheck; amended the commit.
|
||||
3. **Task 2 — `runEditTurn`.** Added `RunEditTurnOptions {onProgress, signal}`;
|
||||
subscribes to the agent, folds events through the reducer, wires `signal` →
|
||||
`agent.abort()`; `finally` unsubscribes. Mocked-agent unit tests (vitest
|
||||
`doMock` intercepts the dynamic import).
|
||||
4. **Task 3 — host relay.** `src/liveProgressUi.ts` (notification line +
|
||||
"Cowriting: Claude" OutputChannel, reveal gated by setting) + `package.json`
|
||||
`contributes.configuration` `cowriting.liveProgress.revealOutput`.
|
||||
5. **Tasks 4+5 — both call sites.** `editSelection` (extension.ts) and preview
|
||||
`askClaude`/`runEditAndPropose` (trackChangesPreview.ts) made cancellable,
|
||||
relaying via the shared `liveProgressUi`; widened the `EditTurn` seam to accept
|
||||
`opts?` (back-compat). Added `liveProgressUi` to `CowritingApi`.
|
||||
6. **Task 6 — host E2E.** `test/e2e/suite/liveProgress.test.ts`: progress is
|
||||
additive (INV-44), aborted turn proposes nothing (INV-47).
|
||||
7. **Task 7 — verify + smoke.** typecheck + 237 unit + build green; enhanced
|
||||
`scripts/smoke-live-turn.mjs` to log progress.
|
||||
8. **Subagent review.** Found a **Medium** (a pre-aborted `AbortSignal` was a
|
||||
no-op — `agent.abort()` runs before the SDK's AbortController exists, so the
|
||||
turn ran to completion) + a **Low** (a throwing `onProgress` relay could fail
|
||||
the turn). Fixed both: short-circuit (throw) before `run()` on a pre-aborted
|
||||
signal; wrap the relay in try/catch. Strengthened the unit + E2E to prove it.
|
||||
9. **Ship.** Pushed; opened PR #61; operator approved merge despite the
|
||||
concurrency (below). Squash-merged `644885c`; issue #60 auto-closed.
|
||||
|
||||
## ⚠️ Concurrency event (mid-session)
|
||||
|
||||
Partway through, the shared working tree gained **uncommitted changes this session
|
||||
did NOT make** — a `cowriting.edit` / `routeEdit` reach refactor across
|
||||
`src/workspacePath.ts`, `test/workspacePath.test.ts`, and the menu/`routeEdit`
|
||||
parts of `package.json` + `src/extension.ts`. It broke 3 E2E (`f12Reach` ×2,
|
||||
`f11Toolbar` ×1) that still expect the old `editSelection`/`editDocument` command
|
||||
surface. Handling: committed **nothing** of it, discarded **nothing**; verified
|
||||
#60 green in isolation by stashing it (by path, reversible) — 237 unit + both #60
|
||||
E2E pass, only the documented `F10 #38` undo-sandbox flake remains — then popped
|
||||
the stash to restore it. Surfaced to the operator, who chose to merge #60 (its 7
|
||||
commits are isolated; the PR diff contains none of the foreign WIP).
|
||||
|
||||
## Cut state (at finalize)
|
||||
|
||||
- **#60 merged** to main (PR #61 squash `644885c`); issue #60 closed.
|
||||
- **Plan archived:** `submit-plan.sh` → content repo `plans/2026-06-26-live-turn-progress.md` (`22ec57e`).
|
||||
- **Foreign refactor WIP:** still uncommitted in the working tree, untouched —
|
||||
for whoever is doing it to continue/commit (it rebases onto the new main).
|
||||
- **Local tree:** left on branch `s60-live-progress` (merged) DELIBERATELY — a
|
||||
`checkout main`/`pull` would have disturbed the foreign uncommitted WIP. Local
|
||||
main not synced; origin/main has #60. Operator should reconcile the local tree
|
||||
+ the in-flight refactor.
|
||||
- **Memory:** added `session-0056-60-live-progress-shipped.md` + index line.
|
||||
|
||||
## Next-session prompt
|
||||
|
||||
```
|
||||
/wgl-planning-and-executing reconcile the local tree (sync main; land/branch the in-flight cowriting.edit/routeEdit refactor), then pick the next item (open: #59 P1 bug, #57, #58, #32, #35, #40, OQ-2)
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Left local on `s60-live-progress` (did not sync main):** to avoid disturbing
|
||||
the concurrent uncommitted refactor in the shared tree. Low confidence this is
|
||||
the tidiest end state, but it is the safest for the foreign WIP. Operator to
|
||||
reconcile.
|
||||
- **Token field for the activity line:** used `inputTokens + outputTokens` from
|
||||
`AgentUsage` (no `totalTokens` field exists). Cache tokens excluded.
|
||||
- **OutputChannel append-not-clear + auto-reveal gating:** per spec §3.5; carried
|
||||
from session 0055's confirmed sub-decisions.
|
||||
+5
-7
@@ -1,15 +1,13 @@
|
||||
# Session 0057.0 — Transcript
|
||||
# Session 0056.0 — Transcript
|
||||
|
||||
> App: vscode-cowriting-plugin
|
||||
> Start: 2026-06-26T04-35 (PST)
|
||||
> Type: executing-plans
|
||||
> Posture: yolo
|
||||
> Claude-Session: 3d66a467-8026-472d-9693-52a37939d493
|
||||
> Start: 2026-06-26T04-24 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
|
||||
>
|
||||
> This file reserves session ID 0057 for vscode-cowriting-plugin. The driver replaces this
|
||||
> This file reserves session ID 0056 for vscode-cowriting-plugin. The driver replaces this
|
||||
> body with the full transcript and renames the file to its final
|
||||
> SESSION-0057.0-TRANSCRIPT-2026-06-26T04-35--<end>.md form at session end.
|
||||
> SESSION-0056.0-TRANSCRIPT-2026-06-26T04-24--<end>.md form at session end.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
@@ -166,8 +166,5 @@
|
||||
},
|
||||
"0056": {
|
||||
"title": ""
|
||||
},
|
||||
"0057": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
+11
-37
@@ -12,8 +12,7 @@ import { SidecarRouter } from "./sidecarRouter";
|
||||
import { DiffViewController } from "./diffViewController";
|
||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||
import { LiveProgressUi } from "./liveProgressUi";
|
||||
import { InlineAskController } from "./inlineAsk";
|
||||
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
||||
import { isAuthorable, selectionRejection } from "./workspacePath";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||
|
||||
@@ -26,7 +25,6 @@ export interface CowritingApi {
|
||||
trackChangesPreviewController: TrackChangesPreviewController;
|
||||
sidecarRouter: SidecarRouter;
|
||||
liveProgressUi: LiveProgressUi;
|
||||
inlineAsk: InlineAskController;
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||
@@ -38,11 +36,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
// OutputChannel) for both Ask-Claude entry points.
|
||||
const liveProgressUi = new LiveProgressUi();
|
||||
context.subscriptions.push(liveProgressUi);
|
||||
|
||||
// The inline "Ask Claude to Edit" prompt — rendered at the selection/cursor via
|
||||
// the Comments API rather than the top-center QuickInput (see inlineAsk.ts).
|
||||
// Shared by both Ask-Claude entry points (editSelection + the preview's askClaude).
|
||||
const inlineAsk = new InlineAskController(context.subscriptions);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
||||
try {
|
||||
@@ -119,7 +112,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
attributionController,
|
||||
proposalController,
|
||||
liveProgressUi,
|
||||
inlineAsk,
|
||||
);
|
||||
context.subscriptions.push(trackChangesPreviewController);
|
||||
|
||||
@@ -225,12 +217,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
return;
|
||||
}
|
||||
if (!editor) return; // unreachable once reason is null, but narrows the type
|
||||
const document = editor.document;
|
||||
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
|
||||
// The instruction prompt opens INLINE at the selection (inlineAsk), not the
|
||||
// top-center QuickInput — anchored to the text Claude will edit.
|
||||
const instruction = await inlineAsk.prompt(document.uri, selection);
|
||||
const instruction = await vscode.window.showInputBox({
|
||||
prompt: "What should Claude do with the selection?",
|
||||
placeHolder: "e.g. tighten this paragraph",
|
||||
});
|
||||
if (!instruction) return;
|
||||
if (editor.selection.isEmpty) {
|
||||
void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first.");
|
||||
return;
|
||||
}
|
||||
const document = editor.document;
|
||||
const selection = editor.selection;
|
||||
const selectedText = document.getText(selection);
|
||||
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
|
||||
// can't skew it — the proposal renders wherever the target re-resolves.
|
||||
@@ -302,28 +299,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
}),
|
||||
);
|
||||
|
||||
// The single user-facing "Ask Claude to Edit" gesture (one command, one
|
||||
// keybinding, one menu entry). It routes to the selection flow (editSelection)
|
||||
// or the whole-document flow (editDocument) by selection/context — see
|
||||
// routeEdit. The two underlying commands stay registered for the host E2E
|
||||
// harness and the internal seams, but are hidden from the palette.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
const route = routeEdit({
|
||||
hasUri: !!uri,
|
||||
uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(),
|
||||
hasActiveEditor: !!editor,
|
||||
selectionEmpty: editor?.selection.isEmpty ?? true,
|
||||
});
|
||||
if (route === "selection") {
|
||||
await vscode.commands.executeCommand("cowriting.editSelection");
|
||||
} else {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument", uri);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Render threads + attributions for already-open editors, and on future opens.
|
||||
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||
if (isAuthorable(doc.uri.scheme)) {
|
||||
@@ -344,7 +319,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
trackChangesPreviewController,
|
||||
sidecarRouter,
|
||||
liveProgressUi,
|
||||
inlineAsk,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import * as vscode from "vscode";
|
||||
|
||||
/** Trim the reply text; an empty/whitespace-only instruction is "no instruction". */
|
||||
export function normalizeInstruction(text: string | undefined): string | undefined {
|
||||
const t = text?.trim();
|
||||
return t ? t : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Ask Claude to Edit" prompt, rendered INLINE at the selection (or cursor)
|
||||
* instead of the top-center QuickInput. VS Code's native pattern for "ask AI to
|
||||
* edit this" is Inline Chat — an input anchored in the editor at the target. The
|
||||
* stable-API equivalent for a third-party extension is the Comments API: a
|
||||
* transient comment thread whose reply box renders right at the range. (The
|
||||
* coauthoring THREADS feature already uses the Comments API via its own
|
||||
* controller — this is a SEPARATE, dedicated `cowriting.askClaude` controller so
|
||||
* the two never collide.)
|
||||
*
|
||||
* One prompt is live at a time; opening a new one cancels the previous. The
|
||||
* thread carries no comments — it is purely the inline input — and is disposed
|
||||
* as soon as the user submits or cancels.
|
||||
*/
|
||||
export class InlineAskController {
|
||||
private readonly controller: vscode.CommentController;
|
||||
private pending?: { resolve: (v: string | undefined) => void; thread: vscode.CommentThread };
|
||||
|
||||
constructor(disposables: vscode.Disposable[]) {
|
||||
this.controller = vscode.comments.createCommentController("cowriting.askClaude", "Ask Claude to Edit");
|
||||
// The reply box's prompt + placeholder (Comments API options).
|
||||
this.controller.options = {
|
||||
prompt: "Ask Claude to Edit",
|
||||
placeHolder: "e.g. tighten this paragraph",
|
||||
};
|
||||
disposables.push(
|
||||
this.controller,
|
||||
vscode.commands.registerCommand("cowriting.askClaude.submit", (r: vscode.CommentReply) =>
|
||||
this.finish(normalizeInstruction(r?.text)),
|
||||
),
|
||||
vscode.commands.registerCommand("cowriting.askClaude.cancel", () => this.finish(undefined)),
|
||||
// The controller itself disposes the live thread on extension teardown.
|
||||
new vscode.Disposable(() => this.finish(undefined)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the inline input anchored at `range` in `uri`'s editor and resolve with
|
||||
* the typed instruction (or `undefined` if cancelled / left empty). Replaces
|
||||
* any prompt already open.
|
||||
*/
|
||||
prompt(uri: vscode.Uri, range: vscode.Range): Promise<string | undefined> {
|
||||
// A fresh prompt supersedes any prior one (resolve it as cancelled).
|
||||
this.finish(undefined);
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
const thread = this.controller.createCommentThread(uri, range, []);
|
||||
thread.label = "Ask Claude to Edit";
|
||||
thread.canReply = true;
|
||||
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
|
||||
thread.contextValue = "askClaude";
|
||||
this.pending = { resolve, thread };
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve the live prompt (if any) and tear its thread down. */
|
||||
private finish(value: string | undefined): void {
|
||||
const p = this.pending;
|
||||
this.pending = undefined;
|
||||
if (!p) return;
|
||||
p.thread.dispose();
|
||||
p.resolve(value);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { buildFingerprint } from "./anchorer";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
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).
|
||||
@@ -75,7 +74,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly proposals: ProposalController,
|
||||
private readonly liveProgressUi: LiveProgressUi,
|
||||
private readonly inlineAsk: InlineAskController,
|
||||
) {
|
||||
this.disposables.push(
|
||||
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
||||
@@ -222,34 +220,19 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the inline Ask-Claude input anchors: a range edit pins to its selection;
|
||||
* a whole-document edit pins to the editor's cursor line if this document is the
|
||||
* active editor, else to the document start.
|
||||
*/
|
||||
private editTargetAnchor(document: vscode.TextDocument, target: EditTarget): vscode.Range {
|
||||
if (target.kind === "range") {
|
||||
return new vscode.Range(document.positionAt(target.start), document.positionAt(target.end));
|
||||
}
|
||||
const active = vscode.window.activeTextEditor;
|
||||
if (active && active.document.uri.toString() === document.uri.toString()) {
|
||||
const line = active.selection.active.line;
|
||||
return new vscode.Range(line, 0, line, 0);
|
||||
}
|
||||
return new vscode.Range(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
||||
*/
|
||||
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
||||
// The instruction prompt opens INLINE (inlineAsk) at the target: the selected
|
||||
// range for a range edit, or the editor's cursor line / the document start for
|
||||
// a whole-document edit — never the top-center QuickInput.
|
||||
const anchor = this.editTargetAnchor(document, target);
|
||||
const instruction = await this.inlineAsk.prompt(document.uri, anchor);
|
||||
const instruction = await vscode.window.showInputBox({
|
||||
prompt:
|
||||
target.kind === "document"
|
||||
? "What should Claude do with the document?"
|
||||
: "What should Claude do with the selection?",
|
||||
placeHolder: "e.g. tighten the prose",
|
||||
});
|
||||
if (!instruction) return;
|
||||
try {
|
||||
const ids = await vscode.window.withProgress(
|
||||
|
||||
@@ -60,29 +60,3 @@ export function selectionRejection(ctx: SelectionContext): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface EditRouteContext {
|
||||
/** Was the command invoked on a specific resource (a tab right-click)? */
|
||||
hasUri: boolean;
|
||||
/** Does that resource match the focused editor's document? */
|
||||
uriMatchesActiveEditor: boolean;
|
||||
/** Is there a focused text editor at all? */
|
||||
hasActiveEditor: boolean;
|
||||
/** Is the focused editor's selection empty (no highlight)? */
|
||||
selectionEmpty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the
|
||||
* selection or whole-document flow. One conceptual command, two destinations:
|
||||
*
|
||||
* - A tab right-click on a document that ISN'T the focused editor has no
|
||||
* selection to act on → edit the whole document.
|
||||
* - Otherwise a non-empty selection in the focused editor → edit the selection;
|
||||
* an empty selection (or no editor) → edit the whole document.
|
||||
*/
|
||||
export function routeEdit(ctx: EditRouteContext): "selection" | "document" {
|
||||
if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document";
|
||||
if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection";
|
||||
return "document";
|
||||
}
|
||||
|
||||
@@ -28,10 +28,7 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
||||
"cowriting.reply",
|
||||
"cowriting.resolveThread",
|
||||
"cowriting.reopenThread",
|
||||
"cowriting.edit",
|
||||
"cowriting.editSelection",
|
||||
"cowriting.askClaude.submit",
|
||||
"cowriting.askClaude.cancel",
|
||||
"cowriting.applyAgentEdit",
|
||||
"cowriting.proposeAgentEdit",
|
||||
]) {
|
||||
|
||||
@@ -174,22 +174,16 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
||||
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
|
||||
});
|
||||
|
||||
// SLICE-3: the document-scoped command stays registered for #42 reuse (now behind
|
||||
// the unified `cowriting.edit`). editDocument is hidden from the palette; the
|
||||
// markdown-guarded user-facing palette command is `cowriting.edit`.
|
||||
test("cowriting.editDocument stays registered; cowriting.edit is the markdown-guarded palette command", async () => {
|
||||
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
|
||||
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
|
||||
const all = await vscode.commands.getCommands(true);
|
||||
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
|
||||
assert.ok(all.includes("cowriting.edit"), "unified edit command registered");
|
||||
const palette = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")).contributes
|
||||
.menus.commandPalette as Array<{ command: string; when?: string }>;
|
||||
// editDocument is hidden (routed via cowriting.edit).
|
||||
const docEntry = palette.find((m) => m.command === "cowriting.editDocument");
|
||||
assert.strictEqual(docEntry?.when, "false", "editDocument hidden from the palette");
|
||||
// cowriting.edit is the user-facing, markdown-guarded entry.
|
||||
const editEntry = palette.find((m) => m.command === "cowriting.edit");
|
||||
assert.ok(editEntry, "cowriting.edit has a commandPalette entry");
|
||||
assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
||||
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
|
||||
(m) => m.command === "cowriting.editDocument",
|
||||
);
|
||||
assert.ok(entry, "editDocument has a commandPalette entry");
|
||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
||||
});
|
||||
|
||||
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
|
||||
|
||||
@@ -31,37 +31,43 @@ function menu(id: string): Array<{ command: string; when?: string; group?: strin
|
||||
}
|
||||
|
||||
// SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and
|
||||
// the editor TAB. The two split commands (editSelection / editDocument) were
|
||||
// unified behind ONE user-facing command `cowriting.edit` that auto-routes by
|
||||
// selection at runtime (routeEdit: selection → editSelection, none → editDocument)
|
||||
// — so the menus carry a SINGLE selection-agnostic entry, both markdown/authorable
|
||||
// -gated. The routing itself is unit-tested (routeEdit) and exercised through the
|
||||
// command below; here we assert the declarative menu `when` clauses.
|
||||
// the editor TAB, selection-aware (selection → editSelection; no selection →
|
||||
// editDocument), both markdown/authorable-gated, both routing through the single
|
||||
// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we
|
||||
// assert them directly; the tab-targeting behavior is exercised through the command.
|
||||
suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
// PUC-1/2: editor BODY (editor/context) offers the single unified entry,
|
||||
// markdown + authorable gated, NOT selection-gated (it routes both cases).
|
||||
test("editor/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown+authorable", () => {
|
||||
// PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated.
|
||||
test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => {
|
||||
const m = menu("editor/context");
|
||||
const edit = m.find((e) => e.command === "cowriting.edit");
|
||||
assert.ok(edit, "cowriting.edit is in editor/context");
|
||||
assert.match(edit!.when ?? "", /editorLangId == markdown/, "gated on markdown");
|
||||
assert.match(edit!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "gated authorable");
|
||||
assert.ok(!/editorHasSelection/.test(edit!.when ?? ""), "not selection-gated — one entry routes both cases");
|
||||
// The old split pair is gone from the menu (the commands stay registered/hidden).
|
||||
assert.ok(!m.some((e) => e.command === "cowriting.editSelection"), "old editSelection menu entry removed");
|
||||
assert.ok(!m.some((e) => e.command === "cowriting.editDocument"), "old editDocument menu entry removed");
|
||||
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
||||
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
||||
assert.ok(sel, "editSelection is in editor/context");
|
||||
assert.ok(doc, "editDocument is in editor/context");
|
||||
|
||||
assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection");
|
||||
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection");
|
||||
assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown");
|
||||
assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable");
|
||||
|
||||
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection");
|
||||
assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown");
|
||||
assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable");
|
||||
});
|
||||
|
||||
// PUC-3: editor TAB (editor/title/context) carries the same single entry.
|
||||
test("editor/title/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown-gated", () => {
|
||||
// PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair.
|
||||
test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => {
|
||||
const m = menu("editor/title/context");
|
||||
const edit = m.find((e) => e.command === "cowriting.edit");
|
||||
assert.ok(edit, "cowriting.edit is in editor/title/context");
|
||||
assert.match(edit!.when ?? "", /resourceLangId == markdown/, "tab entry gated on markdown");
|
||||
assert.ok(
|
||||
!m.some((e) => e.command === "cowriting.editSelection" || e.command === "cowriting.editDocument"),
|
||||
"old split pair removed from the tab menu",
|
||||
);
|
||||
const sel = m.find((e) => e.command === "cowriting.editSelection");
|
||||
const doc = m.find((e) => e.command === "cowriting.editDocument");
|
||||
assert.ok(sel, "editSelection is in editor/title/context");
|
||||
assert.ok(doc, "editDocument is in editor/title/context");
|
||||
|
||||
assert.match(sel!.when ?? "", /editorHasSelection/, "tab editSelection shows only with a selection");
|
||||
assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "tab editSelection is not gated on NO selection");
|
||||
assert.match(sel!.when ?? "", /resourceLangId == markdown/, "tab editSelection gated on markdown");
|
||||
|
||||
assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "tab editDocument shows only without a selection");
|
||||
assert.match(doc!.when ?? "", /resourceLangId == markdown/, "tab editDocument gated on markdown");
|
||||
});
|
||||
|
||||
// PUC-3 behavior: editDocument invoked with a tab URI targets THAT document,
|
||||
@@ -77,8 +83,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await settle();
|
||||
|
||||
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
|
||||
const origPrompt = api.inlineAsk.prompt;
|
||||
(api.inlineAsk as any).prompt = async () => "rewrite it";
|
||||
const origInput = vscode.window.showInputBox;
|
||||
(vscode.window as any).showInputBox = async () => "rewrite it";
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
||||
model: "sonnet",
|
||||
@@ -88,7 +94,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
||||
await settle();
|
||||
} finally {
|
||||
(api.inlineAsk as any).prompt = origPrompt;
|
||||
(vscode.window as any).showInputBox = origInput;
|
||||
}
|
||||
|
||||
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
|
||||
@@ -110,8 +116,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await vscode.window.showTextDocument(a.doc);
|
||||
await settle();
|
||||
|
||||
const origPrompt = api.inlineAsk.prompt;
|
||||
(api.inlineAsk as any).prompt = async () => "rewrite it";
|
||||
const origInput = vscode.window.showInputBox;
|
||||
(vscode.window as any).showInputBox = async () => "rewrite it";
|
||||
ctl.setEditTurnForTest(async () => ({
|
||||
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
|
||||
model: "sonnet",
|
||||
@@ -121,7 +127,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
||||
await vscode.commands.executeCommand("cowriting.editDocument");
|
||||
await settle();
|
||||
} finally {
|
||||
(api.inlineAsk as any).prompt = origPrompt;
|
||||
(vscode.window as any).showInputBox = origInput;
|
||||
}
|
||||
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath";
|
||||
import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath";
|
||||
|
||||
describe("isUnderRoot", () => {
|
||||
const root = "/a/vscode-cowriting-plugin/sandbox";
|
||||
@@ -58,27 +58,3 @@ describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)",
|
||||
expect(msg).not.toMatch(/select some text/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeEdit (single Ask-Claude-to-Edit gesture)", () => {
|
||||
const focused = { hasUri: false, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false };
|
||||
|
||||
it("non-empty selection in the focused editor → selection", () => {
|
||||
expect(routeEdit(focused)).toBe("selection");
|
||||
});
|
||||
it("empty selection in the focused editor → document", () => {
|
||||
expect(routeEdit({ ...focused, selectionEmpty: true })).toBe("document");
|
||||
});
|
||||
it("no active editor → document", () => {
|
||||
expect(routeEdit({ ...focused, hasActiveEditor: false, selectionEmpty: true })).toBe("document");
|
||||
});
|
||||
it("tab right-click on a doc that ISN'T the focused editor → document (no selection to act on)", () => {
|
||||
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false })).toBe(
|
||||
"document",
|
||||
);
|
||||
});
|
||||
it("tab right-click on the focused editor with a selection → selection", () => {
|
||||
expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: true, hasActiveEditor: true, selectionEmpty: false })).toBe(
|
||||
"selection",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user