feat(ux): unify "Ask Claude to Edit" + inline prompt at selection; fix keybindings (#62)
This commit was merged in pull request #62.
This commit is contained in:
+37
-11
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user