From 687f644ee57cee011d175b544a03a33a492fe732 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 06:49:49 -0700 Subject: [PATCH] feat(ux): unify Ask-Claude-to-Edit on a split-below multi-line webview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator feedback iteration on the Ask-Claude input. Replaces the inline comment-thread box (and the top-center QuickInput before it) with ONE input for both scopes: a multi-line webview in a split pane below the document. - New `editInstructionInput.ts` (`promptEditInstruction`): a tall, resizable textarea + Send, opened via `newGroupBelow` so it sits under the document, not over it. Auto-focused; ⌘↵/Ctrl↵ sends; Esc cancels. Cancel/Esc confirms ONLY when there's text to lose (a webview can read its own textarea); closing the tab is an explicit dismiss. On submit/cancel the split collapses AND focus is handed back to the document, so the bottom panel (Output/Debug Console) no longer pops. - Selection edits use the same box; the document above keeps the selection highlighted (inactive-selection style) so the user sees what Claude will edit — the selection is captured before the prompt, never touched. - Removes the whole inline-comment mechanism: deletes `inlineAsk.ts` (InlineAskController), the `cowriting.askClaude.submit`/`.cancel` commands, their comment-thread menus, and the Escape keybinding — net deletion. One overridable seam `TrackChangesPreviewController.askEditInstruction` (used by editSelection and the preview), stubbed by the host E2E. Sealed webview (INV-8/35): collects text only, no SDK/secret surface, no network. 242 unit + typecheck (src + e2e) + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 28 ---- src/editInstructionInput.ts | 147 ++++++++++++++++++ src/extension.ts | 27 ++-- src/inlineAsk.ts | 71 --------- src/trackChangesPreview.ts | 38 ++--- .../suite-no-workspace/noWorkspace.test.ts | 2 - test/e2e/suite/f12Reach.test.ts | 14 +- 7 files changed, 180 insertions(+), 147 deletions(-) create mode 100644 src/editInstructionInput.ts delete mode 100644 src/inlineAsk.ts diff --git a/package.json b/package.json index d020108..e8c2e90 100644 --- a/package.json +++ b/package.json @@ -64,16 +64,6 @@ "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,14 +123,6 @@ "command": "cowriting.rejectProposal", "when": "false" }, - { - "command": "cowriting.askClaude.submit", - "when": "false" - }, - { - "command": "cowriting.askClaude.cancel", - "when": "false" - }, { "command": "cowriting.pinDiffBaseline", "when": "editorLangId == markdown" @@ -205,11 +187,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 +199,6 @@ "command": "cowriting.reopenThread", "group": "inline", "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" - }, - { - "command": "cowriting.askClaude.cancel", - "group": "inline", - "when": "commentController == cowriting.askClaude" } ] }, diff --git a/src/editInstructionInput.ts b/src/editInstructionInput.ts new file mode 100644 index 0000000..12cb29c --- /dev/null +++ b/src/editInstructionInput.ts @@ -0,0 +1,147 @@ +import * as vscode from "vscode"; +import { randomBytes } from "crypto"; + +/** + * The multi-line instruction input for "Ask Claude to Edit" — BOTH the selection + * and the whole-document case. A small focused webview (a tall, resizable + * textarea + Send) opened in a split pane BELOW the document — not a comment + * thread, and not the top QuickInput (which is single-line only; VS Code has no + * multi-line input at the command-palette location). For a selection edit the + * document stays in the pane above with the selection still highlighted (VS + * Code's inactive-selection style), so the user can see exactly what Claude will + * edit; the caller has already captured the selection, so we never touch it. + * + * The split collapses when the input is submitted or cancelled, and focus is + * handed back to the document so the collapsing split doesn't reveal the bottom + * panel. `header` names the scope ("Ask Claude to Edit This Selection" / + * "…This Document"). + * + * The webview only collects text and posts it to the host — no SDK or secret + * surface lives in it (INV-8/35); the sealed CSP allows no network. Because a + * webview CAN read its own textarea, Cancel / Escape confirms ONLY when there is + * text to lose (closing the tab is an explicit dismiss, no prompt). Resolves with + * the typed instruction, or `undefined` if cancelled / closed / left empty. + */ +export async function promptEditInstruction(header: string): Promise { + // Remember the document editor we came from so we can hand focus back to it + // when the input closes — otherwise, when the empty split group below collapses, + // focus falls into the bottom panel (Output / Debug Console / …) and it pops + // open. Restoring editor focus leaves whatever panel state the user had untouched. + const source = vscode.window.activeTextEditor; + // Open a split editor group BELOW the current one and host the input there, so + // it sits under the document instead of covering it as a tab. The new group is + // empty, so disposing the panel on submit/cancel leaves it empty and VS Code + // collapses the split (the `workbench.editor.closeEmptyGroups` default). Falls + // back to a tab in the active group if the split command is unavailable. + try { + await vscode.commands.executeCommand("workbench.action.newGroupBelow"); + } catch { + /* no split — the panel opens as a tab in the active group instead */ + } + return new Promise((resolve) => { + const panel = vscode.window.createWebviewPanel( + "cowriting.askClaudeInput", + header, + { viewColumn: vscode.ViewColumn.Active, preserveFocus: false }, + { enableScripts: true, retainContextWhenHidden: false }, + ); + + let settled = false; + const done = (value: string | undefined): void => { + if (settled) return; + settled = true; + resolve(value); + panel.dispose(); + // Hand focus back to the originating document so the collapsing split + // doesn't leave focus in (and reveal) the bottom panel. + if (source) { + void vscode.window.showTextDocument(source.document, { + viewColumn: source.viewColumn ?? vscode.ViewColumn.One, + preserveFocus: false, + }); + } + }; + + panel.webview.onDidReceiveMessage((m: { type?: string; text?: string }) => { + const text = (m?.text ?? "").trim(); + if (m?.type === "submit") { + done(text ? text : undefined); + } else if (m?.type === "cancel") { + // Confirm only if there's something to lose (we can read the textarea here). + if (!text) { + done(undefined); + return; + } + void vscode.window + .showWarningMessage("Discard your Ask-Claude instruction?", { modal: true }, "Discard") + .then((pick) => { + if (pick === "Discard") done(undefined); + // else: leave the panel open so the operator can keep editing. + }); + } + }); + // Closing the tab is an explicit dismiss — cancel without a prompt. + panel.onDidDispose(() => done(undefined)); + panel.webview.html = htmlFor(header); + }); +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); +} + +function htmlFor(header: string): string { + const nonce = randomBytes(16).toString("base64"); + // Sealed CSP: no network; inline style only; the one script is nonce-gated. + const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';`; + const title = escapeHtml(header); + return ` + + + + + ${title} + + + +

${title}

+ +
+ ⌘↵ / Ctrl+↵ to send · Esc to cancel + +
+ + +`; +} diff --git a/src/extension.ts b/src/extension.ts index 08ec9b1..ec36c22 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,7 +12,6 @@ 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"; 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); @@ -227,17 +219,23 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef 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); - if (!instruction) return; + // Capture the selection text + anchor BEFORE prompting — the inline prompt + // moves the cursor to the document top (where its box anchors), which would + // otherwise collapse the selection we act on (spec §6.5 PUC-1: anchor + // captured pre-turn so mid-turn edits can't skew it). 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. const fp = buildFingerprint(document.getText(), { start: document.offsetAt(selection.start), end: document.offsetAt(selection.end), }); + // The instruction prompt is the multi-line split-below webview box (shared + // with the document case, via the preview controller); the document above + // keeps the selection highlighted while it's open. selectedText/fp were + // captured above, so moving focus to the box doesn't affect what we edit. + const instruction = await trackChangesPreviewController.askEditInstruction( + "Ask Claude to Edit This Selection", + ); + if (!instruction) return; const turnId = `turn-${Date.now().toString(36)}`; try { await vscode.window.withProgress( @@ -344,7 +342,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef trackChangesPreviewController, sidecarRouter, liveProgressUi, - inlineAsk, }; } diff --git a/src/inlineAsk.ts b/src/inlineAsk.ts deleted file mode 100644 index 40c3953..0000000 --- a/src/inlineAsk.ts +++ /dev/null @@ -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 { - // 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 9456ec0..45c9244 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -18,7 +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"; +import { promptEditInstruction } from "./editInstructionInput"; /** * F11: a host edit turn (selection/document text + instruction → rewrite). @@ -63,6 +63,13 @@ export class TrackChangesPreviewController implements vscode.Disposable { const { runEditTurn } = await import("./liveTurn"); return runEditTurn(instruction, text, opts); }; + /** + * The instruction prompt (the multi-line split-below webview box) for BOTH the + * selection and document cases. A field so host E2E can stub it — the webview + * DOM can't run in CI (mirrors `editTurn`). Also used by the editor's + * `cowriting.editSelection` command (via this controller). + */ + askEditInstruction: (header: string) => Promise = promptEditInstruction; /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ private turnSeq = 0; private nextTurnSeq(): number { @@ -75,7 +82,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 +228,18 @@ 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 { - // 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); + // Both scopes use the same multi-line split-below webview box; only the header + // (and the downstream proposal logic) differs. For a selection the document + // above keeps the selection highlighted while the box is open. + const header = + target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection"; + const instruction = await this.askEditInstruction(header); if (!instruction) return; try { const ids = await vscode.window.withProgress( diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index e85682f..7c645e9 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -30,8 +30,6 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => { "cowriting.reopenThread", "cowriting.edit", "cowriting.editSelection", - "cowriting.askClaude.submit", - "cowriting.askClaude.cancel", "cowriting.applyAgentEdit", "cowriting.proposeAgentEdit", ]) { diff --git a/test/e2e/suite/f12Reach.test.ts b/test/e2e/suite/f12Reach.test.ts index f96cda9..7b10bc6 100644 --- a/test/e2e/suite/f12Reach.test.ts +++ b/test/e2e/suite/f12Reach.test.ts @@ -76,9 +76,9 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => { await vscode.window.showTextDocument(a.doc); 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"; + // Stub the document instruction prompt (the webview can't run in CI) + the LLM turn. + const origPrompt = ctl.askEditInstruction; + ctl.askEditInstruction = async () => "rewrite it"; ctl.setEditTurnForTest(async () => ({ replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n", model: "sonnet", @@ -88,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 { - (api.inlineAsk as any).prompt = origPrompt; + ctl.askEditInstruction = origPrompt; } // The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none. @@ -110,8 +110,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 origPrompt = ctl.askEditInstruction; + ctl.askEditInstruction = async () => "rewrite it"; ctl.setEditTurnForTest(async () => ({ replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n", model: "sonnet", @@ -121,7 +121,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; + ctl.askEditInstruction = origPrompt; } assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg"); });