From 9432300e3c985ee0031ebe4a856065a36fec1b08 Mon Sep 17 00:00:00 2001 From: benstull <1+benstull@noreply.localhost> Date: Fri, 26 Jun 2026 12:11:43 +0000 Subject: [PATCH] feat(ux): unify "Ask Claude to Edit" + inline prompt at selection; fix keybindings (#62) --- package.json | 68 ++++++++++++++---- src/extension.ts | 48 ++++++++++--- src/inlineAsk.ts | 71 +++++++++++++++++++ src/trackChangesPreview.ts | 31 ++++++-- src/workspacePath.ts | 26 +++++++ .../suite-no-workspace/noWorkspace.test.ts | 3 + test/e2e/suite/f11Toolbar.test.ts | 22 +++--- test/e2e/suite/f12Reach.test.ts | 70 +++++++++--------- test/workspacePath.test.ts | 26 ++++++- 9 files changed, 285 insertions(+), 80 deletions(-) create mode 100644 src/inlineAsk.ts diff --git a/package.json b/package.json index 354d627..d020108 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,21 @@ "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" }, + { + "command": "cowriting.edit", + "title": "Ask Claude to Edit", + "category": "Cowriting" + }, + { + "command": "cowriting.askClaude.submit", + "title": "Ask Claude to Edit", + "category": "Cowriting" + }, + { + "command": "cowriting.askClaude.cancel", + "title": "Cancel", + "category": "Cowriting" + }, { "command": "cowriting.editSelection", "title": "Ask Claude to Edit Selection", @@ -118,14 +133,30 @@ "command": "cowriting.rejectProposal", "when": "false" }, + { + "command": "cowriting.askClaude.submit", + "when": "false" + }, + { + "command": "cowriting.askClaude.cancel", + "when": "false" + }, { "command": "cowriting.pinDiffBaseline", "when": "editorLangId == markdown" }, { - "command": "cowriting.editDocument", + "command": "cowriting.edit", "when": "editorLangId == markdown" }, + { + "command": "cowriting.editSelection", + "when": "false" + }, + { + "command": "cowriting.editDocument", + "when": "false" + }, { "command": "cowriting.acceptAllProposals", "when": "editorLangId == markdown" @@ -140,13 +171,8 @@ ], "editor/title/context": [ { - "command": "cowriting.editSelection", - "when": "editorHasSelection && resourceLangId == markdown", - "group": "1_cowriting@1" - }, - { - "command": "cowriting.editDocument", - "when": "!editorHasSelection && resourceLangId == markdown", + "command": "cowriting.edit", + "when": "resourceLangId == markdown", "group": "1_cowriting@1" }, { @@ -164,13 +190,8 @@ ], "editor/context": [ { - "command": "cowriting.editSelection", - "when": "editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)", - "group": "1_cowriting@1" - }, - { - "command": "cowriting.editDocument", - "when": "!editorHasSelection && editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)", + "command": "cowriting.edit", + "when": "editorLangId == markdown && (resourceScheme == file || resourceScheme == untitled)", "group": "1_cowriting@1" }, { @@ -184,6 +205,11 @@ "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" + }, + { + "command": "cowriting.askClaude.submit", + "group": "inline", + "when": "commentController == cowriting.askClaude" } ], "comments/commentThread/title": [ @@ -196,6 +222,11 @@ "command": "cowriting.reopenThread", "group": "inline", "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" + }, + { + "command": "cowriting.askClaude.cancel", + "group": "inline", + "when": "commentController == cowriting.askClaude" } ] }, @@ -203,7 +234,14 @@ { "command": "cowriting.showTrackChangesPreview", "key": "ctrl+alt+r", + "mac": "cmd+alt+r", "when": "editorLangId == markdown" + }, + { + "command": "cowriting.edit", + "key": "ctrl+alt+e", + "mac": "cmd+alt+e", + "when": "editorTextFocus && editorLangId == markdown" } ] }, diff --git a/src/extension.ts b/src/extension.ts index 12b1006..08ec9b1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,7 +12,8 @@ import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { TrackChangesPreviewController } from "./trackChangesPreview"; import { LiveProgressUi } from "./liveProgressUi"; -import { isAuthorable, selectionRejection } from "./workspacePath"; +import { InlineAskController } from "./inlineAsk"; +import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -25,6 +26,7 @@ export interface CowritingApi { trackChangesPreviewController: TrackChangesPreviewController; sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; + inlineAsk: InlineAskController; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -36,6 +38,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // OutputChannel) for both Ask-Claude entry points. 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 { @@ -112,6 +119,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef attributionController, proposalController, liveProgressUi, + inlineAsk, ); context.subscriptions.push(trackChangesPreviewController); @@ -217,17 +225,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef return; } if (!editor) return; // unreachable once reason is null, but narrows the type - const instruction = await vscode.window.showInputBox({ - prompt: "What should Claude do with the selection?", - placeHolder: "e.g. tighten this paragraph", - }); - if (!instruction) return; - if (editor.selection.isEmpty) { - void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first."); - return; - } const document = editor.document; - const selection = editor.selection; + const selection = editor.selection; // non-empty (selectionRejection guaranteed it) + // The instruction prompt opens INLINE at the selection (inlineAsk), not the + // top-center QuickInput — anchored to the text Claude will edit. + const instruction = await inlineAsk.prompt(document.uri, selection); + if (!instruction) return; const selectedText = document.getText(selection); // Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits // can't skew it — the proposal renders wherever the target re-resolves. @@ -299,6 +302,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); + // The single user-facing "Ask Claude to Edit" gesture (one command, one + // keybinding, one menu entry). It routes to the selection flow (editSelection) + // or the whole-document flow (editDocument) by selection/context — see + // routeEdit. The two underlying commands stay registered for the host E2E + // harness and the internal seams, but are hidden from the palette. + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => { + const editor = vscode.window.activeTextEditor; + const route = routeEdit({ + hasUri: !!uri, + uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(), + hasActiveEditor: !!editor, + selectionEmpty: editor?.selection.isEmpty ?? true, + }); + if (route === "selection") { + await vscode.commands.executeCommand("cowriting.editSelection"); + } else { + await vscode.commands.executeCommand("cowriting.editDocument", uri); + } + }), + ); + // Render threads + attributions for already-open editors, and on future opens. const renderIfOpen = (doc: vscode.TextDocument) => { if (isAuthorable(doc.uri.scheme)) { @@ -319,6 +344,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef trackChangesPreviewController, sidecarRouter, liveProgressUi, + inlineAsk, }; } diff --git a/src/inlineAsk.ts b/src/inlineAsk.ts new file mode 100644 index 0000000..40c3953 --- /dev/null +++ b/src/inlineAsk.ts @@ -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 { + // A fresh prompt supersedes any prior one (resolve it as cancelled). + this.finish(undefined); + return new Promise((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); + } +} diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index 037cf91..9456ec0 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -18,6 +18,7 @@ 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). @@ -74,6 +75,7 @@ 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; @@ -220,19 +222,34 @@ export class TrackChangesPreviewController implements vscode.Disposable { ); } + /** + * Where the inline Ask-Claude input anchors: a range edit pins to its selection; + * a whole-document edit pins to the editor's cursor line if this document is the + * active editor, else to the document start. + */ + private editTargetAnchor(document: vscode.TextDocument, target: EditTarget): vscode.Range { + if (target.kind === "range") { + return new vscode.Range(document.positionAt(target.start), document.positionAt(target.end)); + } + const active = vscode.window.activeTextEditor; + if (active && active.document.uri.toString() === document.uri.toString()) { + const line = active.selection.active.line; + return new vscode.Range(line, 0, line, 0); + } + return new vscode.Range(0, 0, 0, 0); + } + /** * F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret * 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 { - const instruction = await vscode.window.showInputBox({ - prompt: - target.kind === "document" - ? "What should Claude do with the document?" - : "What should Claude do with the selection?", - placeHolder: "e.g. tighten the prose", - }); + // The instruction prompt opens INLINE (inlineAsk) at the target: the selected + // range for a range edit, or the editor's cursor line / the document start for + // a whole-document edit — never the top-center QuickInput. + const anchor = this.editTargetAnchor(document, target); + const instruction = await this.inlineAsk.prompt(document.uri, anchor); if (!instruction) return; try { const ids = await vscode.window.withProgress( diff --git a/src/workspacePath.ts b/src/workspacePath.ts index 9a0e608..f0482d0 100644 --- a/src/workspacePath.ts +++ b/src/workspacePath.ts @@ -60,3 +60,29 @@ export function selectionRejection(ctx: SelectionContext): string | null { } return null; } + +export interface EditRouteContext { + /** Was the command invoked on a specific resource (a tab right-click)? */ + hasUri: boolean; + /** Does that resource match the focused editor's document? */ + uriMatchesActiveEditor: boolean; + /** Is there a focused text editor at all? */ + hasActiveEditor: boolean; + /** Is the focused editor's selection empty (no highlight)? */ + selectionEmpty: boolean; +} + +/** + * Route the single "Ask Claude to Edit" gesture (`cowriting.edit`) to the + * selection or whole-document flow. One conceptual command, two destinations: + * + * - A tab right-click on a document that ISN'T the focused editor has no + * selection to act on → edit the whole document. + * - Otherwise a non-empty selection in the focused editor → edit the selection; + * an empty selection (or no editor) → edit the whole document. + */ +export function routeEdit(ctx: EditRouteContext): "selection" | "document" { + if (ctx.hasUri && !ctx.uriMatchesActiveEditor) return "document"; + if (ctx.hasActiveEditor && !ctx.selectionEmpty) return "selection"; + return "document"; +} diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index 0dd93f1..e85682f 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -28,7 +28,10 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => { "cowriting.reply", "cowriting.resolveThread", "cowriting.reopenThread", + "cowriting.edit", "cowriting.editSelection", + "cowriting.askClaude.submit", + "cowriting.askClaude.cancel", "cowriting.applyAgentEdit", "cowriting.proposeAgentEdit", ]) { diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts index 9b82b2a..7d1034c 100644 --- a/test/e2e/suite/f11Toolbar.test.ts +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -174,16 +174,22 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending"); }); - // SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown. - test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => { + // SLICE-3: the document-scoped command stays registered for #42 reuse (now behind + // the unified `cowriting.edit`). editDocument is hidden from the palette; the + // markdown-guarded user-facing palette command is `cowriting.edit`. + test("cowriting.editDocument stays registered; cowriting.edit is the markdown-guarded palette command", async () => { const all = await vscode.commands.getCommands(true); assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered"); - const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); - const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( - (m) => m.command === "cowriting.editDocument", - ); - assert.ok(entry, "editDocument has a commandPalette entry"); - assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); + assert.ok(all.includes("cowriting.edit"), "unified edit command registered"); + const palette = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")).contributes + .menus.commandPalette as Array<{ command: string; when?: string }>; + // editDocument is hidden (routed via cowriting.edit). + const docEntry = palette.find((m) => m.command === "cowriting.editDocument"); + assert.strictEqual(docEntry?.when, "false", "editDocument hidden from the palette"); + // cowriting.edit is the user-facing, markdown-guarded entry. + const editEntry = palette.find((m) => m.command === "cowriting.edit"); + assert.ok(editEntry, "cowriting.edit has a commandPalette entry"); + assert.match(editEntry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); }); // SLICE-5: the minimal right-click gateway lives in editor/title (markdown only). diff --git a/test/e2e/suite/f12Reach.test.ts b/test/e2e/suite/f12Reach.test.ts index 9e93c10..f96cda9 100644 --- a/test/e2e/suite/f12Reach.test.ts +++ b/test/e2e/suite/f12Reach.test.ts @@ -31,43 +31,37 @@ function menu(id: string): Array<{ command: string; when?: string; group?: strin } // SLICE-1 / #42 (reach): "Ask Claude to Edit" reachable from the editor BODY and -// the editor TAB, selection-aware (selection → editSelection; no selection → -// editDocument), both markdown/authorable-gated, both routing through the single -// runEditAndPropose path (INV-38). The menu `when` clauses are declarative, so we -// assert them directly; the tab-targeting behavior is exercised through the command. +// the editor TAB. The two split commands (editSelection / editDocument) were +// unified behind ONE user-facing command `cowriting.edit` that auto-routes by +// selection at runtime (routeEdit: selection → editSelection, none → editDocument) +// — so the menus carry a SINGLE selection-agnostic entry, both markdown/authorable +// -gated. The routing itself is unit-tested (routeEdit) and exercised through the +// command below; here we assert the declarative menu `when` clauses. suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { - // PUC-1/2: editor BODY (editor/context) is selection-aware + markdown-gated. - test("editor/context offers editSelection (with selection) and editDocument (without), markdown+authorable", () => { + // PUC-1/2: editor BODY (editor/context) offers the single unified entry, + // markdown + authorable gated, NOT selection-gated (it routes both cases). + test("editor/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown+authorable", () => { const m = menu("editor/context"); - const sel = m.find((e) => e.command === "cowriting.editSelection"); - const doc = m.find((e) => e.command === "cowriting.editDocument"); - assert.ok(sel, "editSelection is in editor/context"); - assert.ok(doc, "editDocument is in editor/context"); - - assert.match(sel!.when ?? "", /editorHasSelection/, "editSelection shows only with a selection"); - assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "editSelection is not gated on NO selection"); - assert.match(sel!.when ?? "", /editorLangId == markdown/, "editSelection gated on markdown"); - assert.match(sel!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editSelection gated authorable"); - - assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "editDocument shows only without a selection"); - assert.match(doc!.when ?? "", /editorLangId == markdown/, "editDocument gated on markdown"); - assert.match(doc!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "editDocument gated authorable"); + const edit = m.find((e) => e.command === "cowriting.edit"); + assert.ok(edit, "cowriting.edit is in editor/context"); + assert.match(edit!.when ?? "", /editorLangId == markdown/, "gated on markdown"); + assert.match(edit!.when ?? "", /resourceScheme == file|resourceScheme == untitled/, "gated authorable"); + assert.ok(!/editorHasSelection/.test(edit!.when ?? ""), "not selection-gated — one entry routes both cases"); + // The old split pair is gone from the menu (the commands stay registered/hidden). + assert.ok(!m.some((e) => e.command === "cowriting.editSelection"), "old editSelection menu entry removed"); + assert.ok(!m.some((e) => e.command === "cowriting.editDocument"), "old editDocument menu entry removed"); }); - // PUC-3: editor TAB (editor/title/context) carries the same selection-aware pair. - test("editor/title/context offers editSelection (with selection) and editDocument (without), markdown-gated", () => { + // PUC-3: editor TAB (editor/title/context) carries the same single entry. + test("editor/title/context offers a single 'Ask Claude to Edit' (cowriting.edit), markdown-gated", () => { const m = menu("editor/title/context"); - const sel = m.find((e) => e.command === "cowriting.editSelection"); - const doc = m.find((e) => e.command === "cowriting.editDocument"); - assert.ok(sel, "editSelection is in editor/title/context"); - assert.ok(doc, "editDocument is in editor/title/context"); - - assert.match(sel!.when ?? "", /editorHasSelection/, "tab editSelection shows only with a selection"); - assert.ok(!/!\s*editorHasSelection/.test(sel!.when ?? ""), "tab editSelection is not gated on NO selection"); - assert.match(sel!.when ?? "", /resourceLangId == markdown/, "tab editSelection gated on markdown"); - - assert.match(doc!.when ?? "", /!\s*editorHasSelection/, "tab editDocument shows only without a selection"); - assert.match(doc!.when ?? "", /resourceLangId == markdown/, "tab editDocument gated on markdown"); + const edit = m.find((e) => e.command === "cowriting.edit"); + assert.ok(edit, "cowriting.edit is in editor/title/context"); + assert.match(edit!.when ?? "", /resourceLangId == markdown/, "tab entry gated on markdown"); + assert.ok( + !m.some((e) => e.command === "cowriting.editSelection" || e.command === "cowriting.editDocument"), + "old split pair removed from the tab menu", + ); }); // PUC-3 behavior: editDocument invoked with a tab URI targets THAT document, @@ -83,8 +77,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await settle(); // Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn. - const origInput = vscode.window.showInputBox; - (vscode.window as any).showInputBox = async () => "rewrite it"; + const origPrompt = api.inlineAsk.prompt; + (api.inlineAsk as any).prompt = async () => "rewrite it"; ctl.setEditTurnForTest(async () => ({ replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n", model: "sonnet", @@ -94,7 +88,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri); await settle(); } finally { - (vscode.window as any).showInputBox = origInput; + (api.inlineAsk as any).prompt = origPrompt; } // The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none. @@ -116,8 +110,8 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await vscode.window.showTextDocument(a.doc); await settle(); - const origInput = vscode.window.showInputBox; - (vscode.window as any).showInputBox = async () => "rewrite it"; + const origPrompt = api.inlineAsk.prompt; + (api.inlineAsk as any).prompt = async () => "rewrite it"; ctl.setEditTurnForTest(async () => ({ replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n", model: "sonnet", @@ -127,7 +121,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await vscode.commands.executeCommand("cowriting.editDocument"); await settle(); } finally { - (vscode.window as any).showInputBox = origInput; + (api.inlineAsk as any).prompt = origPrompt; } assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg"); }); diff --git a/test/workspacePath.test.ts b/test/workspacePath.test.ts index 59e11e9..2169464 100644 --- a/test/workspacePath.test.ts +++ b/test/workspacePath.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath"; +import { isAuthorable, isUnderRoot, routeEdit, selectionRejection } from "../src/workspacePath"; describe("isUnderRoot", () => { const root = "/a/vscode-cowriting-plugin/sandbox"; @@ -58,3 +58,27 @@ describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)", expect(msg).not.toMatch(/select some text/i); }); }); + +describe("routeEdit (single Ask-Claude-to-Edit gesture)", () => { + const focused = { hasUri: false, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false }; + + it("non-empty selection in the focused editor → selection", () => { + expect(routeEdit(focused)).toBe("selection"); + }); + it("empty selection in the focused editor → document", () => { + expect(routeEdit({ ...focused, selectionEmpty: true })).toBe("document"); + }); + it("no active editor → document", () => { + expect(routeEdit({ ...focused, hasActiveEditor: false, selectionEmpty: true })).toBe("document"); + }); + it("tab right-click on a doc that ISN'T the focused editor → document (no selection to act on)", () => { + expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: false, hasActiveEditor: true, selectionEmpty: false })).toBe( + "document", + ); + }); + it("tab right-click on the focused editor with a selection → selection", () => { + expect(routeEdit({ hasUri: true, uriMatchesActiveEditor: true, hasActiveEditor: true, selectionEmpty: false })).toBe( + "selection", + ); + }); +});