feat(ux): unify Ask-Claude-to-Edit on a split-below multi-line webview (#65)
This commit was merged in pull request #65.
This commit is contained in:
@@ -64,16 +64,6 @@
|
|||||||
"title": "Ask Claude to Edit",
|
"title": "Ask Claude to Edit",
|
||||||
"category": "Cowriting"
|
"category": "Cowriting"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"command": "cowriting.askClaude.submit",
|
|
||||||
"title": "Ask Claude to Edit",
|
|
||||||
"category": "Cowriting"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "cowriting.askClaude.cancel",
|
|
||||||
"title": "Cancel",
|
|
||||||
"category": "Cowriting"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"command": "cowriting.editSelection",
|
"command": "cowriting.editSelection",
|
||||||
"title": "Ask Claude to Edit Selection",
|
"title": "Ask Claude to Edit Selection",
|
||||||
@@ -133,14 +123,6 @@
|
|||||||
"command": "cowriting.rejectProposal",
|
"command": "cowriting.rejectProposal",
|
||||||
"when": "false"
|
"when": "false"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"command": "cowriting.askClaude.submit",
|
|
||||||
"when": "false"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "cowriting.askClaude.cancel",
|
|
||||||
"when": "false"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"command": "cowriting.pinDiffBaseline",
|
"command": "cowriting.pinDiffBaseline",
|
||||||
"when": "editorLangId == markdown"
|
"when": "editorLangId == markdown"
|
||||||
@@ -205,11 +187,6 @@
|
|||||||
"command": "cowriting.reply",
|
"command": "cowriting.reply",
|
||||||
"group": "inline",
|
"group": "inline",
|
||||||
"when": "commentController == cowriting.threads"
|
"when": "commentController == cowriting.threads"
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "cowriting.askClaude.submit",
|
|
||||||
"group": "inline",
|
|
||||||
"when": "commentController == cowriting.askClaude"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"comments/commentThread/title": [
|
"comments/commentThread/title": [
|
||||||
@@ -222,11 +199,6 @@
|
|||||||
"command": "cowriting.reopenThread",
|
"command": "cowriting.reopenThread",
|
||||||
"group": "inline",
|
"group": "inline",
|
||||||
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
"when": "commentController == cowriting.threads && commentThread =~ /^resolved$/"
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "cowriting.askClaude.cancel",
|
|
||||||
"group": "inline",
|
|
||||||
"when": "commentController == cowriting.askClaude"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<string | undefined> {
|
||||||
|
// 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 `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
||||||
|
<title>${title}</title>
|
||||||
|
<style>
|
||||||
|
body { padding: 14px 16px; font-family: var(--vscode-font-family); color: var(--vscode-foreground); }
|
||||||
|
h2 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
|
||||||
|
textarea {
|
||||||
|
width: 100%; min-height: 160px; resize: vertical; box-sizing: border-box;
|
||||||
|
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
||||||
|
border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 8px;
|
||||||
|
font-family: var(--vscode-editor-font-family); font-size: var(--vscode-editor-font-size); line-height: 1.4;
|
||||||
|
}
|
||||||
|
textarea::placeholder { color: var(--vscode-input-placeholderForeground); }
|
||||||
|
textarea:focus { outline: 1px solid var(--vscode-focusBorder); border-color: var(--vscode-focusBorder); }
|
||||||
|
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; gap: 12px; }
|
||||||
|
.hint { color: var(--vscode-descriptionForeground); font-size: 12px; }
|
||||||
|
button {
|
||||||
|
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||||
|
border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>${title}</h2>
|
||||||
|
<textarea id="inst" placeholder="e.g. tighten the intro, add a conclusion, and fix the heading levels" autofocus></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<span class="hint">⌘↵ / Ctrl+↵ to send · Esc to cancel</span>
|
||||||
|
<button id="send">Send to Claude</button>
|
||||||
|
</div>
|
||||||
|
<script nonce="${nonce}">
|
||||||
|
const api = acquireVsCodeApi();
|
||||||
|
const ta = document.getElementById('inst');
|
||||||
|
const focus = () => ta.focus();
|
||||||
|
focus();
|
||||||
|
window.addEventListener('focus', focus);
|
||||||
|
const submit = () => api.postMessage({ type: 'submit', text: ta.value });
|
||||||
|
const cancel = () => api.postMessage({ type: 'cancel', text: ta.value });
|
||||||
|
document.getElementById('send').addEventListener('click', submit);
|
||||||
|
ta.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
+12
-15
@@ -12,7 +12,6 @@ import { SidecarRouter } from "./sidecarRouter";
|
|||||||
import { DiffViewController } from "./diffViewController";
|
import { DiffViewController } from "./diffViewController";
|
||||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||||
import { LiveProgressUi } from "./liveProgressUi";
|
import { LiveProgressUi } from "./liveProgressUi";
|
||||||
import { InlineAskController } from "./inlineAsk";
|
|
||||||
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
@@ -26,7 +25,6 @@ export interface CowritingApi {
|
|||||||
trackChangesPreviewController: TrackChangesPreviewController;
|
trackChangesPreviewController: TrackChangesPreviewController;
|
||||||
sidecarRouter: SidecarRouter;
|
sidecarRouter: SidecarRouter;
|
||||||
liveProgressUi: LiveProgressUi;
|
liveProgressUi: LiveProgressUi;
|
||||||
inlineAsk: InlineAskController;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
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.
|
// OutputChannel) for both Ask-Claude entry points.
|
||||||
const liveProgressUi = new LiveProgressUi();
|
const liveProgressUi = new LiveProgressUi();
|
||||||
context.subscriptions.push(liveProgressUi);
|
context.subscriptions.push(liveProgressUi);
|
||||||
|
|
||||||
// The inline "Ask Claude to Edit" prompt — rendered at the selection/cursor via
|
|
||||||
// the Comments API rather than the top-center QuickInput (see inlineAsk.ts).
|
|
||||||
// Shared by both Ask-Claude entry points (editSelection + the preview's askClaude).
|
|
||||||
const inlineAsk = new InlineAskController(context.subscriptions);
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
||||||
try {
|
try {
|
||||||
@@ -119,7 +112,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
attributionController,
|
attributionController,
|
||||||
proposalController,
|
proposalController,
|
||||||
liveProgressUi,
|
liveProgressUi,
|
||||||
inlineAsk,
|
|
||||||
);
|
);
|
||||||
context.subscriptions.push(trackChangesPreviewController);
|
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
|
if (!editor) return; // unreachable once reason is null, but narrows the type
|
||||||
const document = editor.document;
|
const document = editor.document;
|
||||||
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
|
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
|
||||||
// The instruction prompt opens INLINE at the selection (inlineAsk), not the
|
// Capture the selection text + anchor BEFORE prompting — the inline prompt
|
||||||
// top-center QuickInput — anchored to the text Claude will edit.
|
// moves the cursor to the document top (where its box anchors), which would
|
||||||
const instruction = await inlineAsk.prompt(document.uri, selection);
|
// otherwise collapse the selection we act on (spec §6.5 PUC-1: anchor
|
||||||
if (!instruction) return;
|
// captured pre-turn so mid-turn edits can't skew it).
|
||||||
const selectedText = document.getText(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.
|
|
||||||
const fp = buildFingerprint(document.getText(), {
|
const fp = buildFingerprint(document.getText(), {
|
||||||
start: document.offsetAt(selection.start),
|
start: document.offsetAt(selection.start),
|
||||||
end: document.offsetAt(selection.end),
|
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)}`;
|
const turnId = `turn-${Date.now().toString(36)}`;
|
||||||
try {
|
try {
|
||||||
await vscode.window.withProgress(
|
await vscode.window.withProgress(
|
||||||
@@ -344,7 +342,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
trackChangesPreviewController,
|
trackChangesPreviewController,
|
||||||
sidecarRouter,
|
sidecarRouter,
|
||||||
liveProgressUi,
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-24
@@ -18,7 +18,7 @@ import { buildFingerprint } from "./anchorer";
|
|||||||
import { isAuthorable } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||||
import type { LiveProgressUi } from "./liveProgressUi";
|
import type { LiveProgressUi } from "./liveProgressUi";
|
||||||
import type { InlineAskController } from "./inlineAsk";
|
import { promptEditInstruction } from "./editInstructionInput";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F11: a host edit turn (selection/document text + instruction → rewrite).
|
* 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");
|
const { runEditTurn } = await import("./liveTurn");
|
||||||
return runEditTurn(instruction, text, opts);
|
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<string | undefined> = promptEditInstruction;
|
||||||
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||||
private turnSeq = 0;
|
private turnSeq = 0;
|
||||||
private nextTurnSeq(): number {
|
private nextTurnSeq(): number {
|
||||||
@@ -75,7 +82,6 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
private readonly attribution: AttributionController,
|
private readonly attribution: AttributionController,
|
||||||
private readonly proposals: ProposalController,
|
private readonly proposals: ProposalController,
|
||||||
private readonly liveProgressUi: LiveProgressUi,
|
private readonly liveProgressUi: LiveProgressUi,
|
||||||
private readonly inlineAsk: InlineAskController,
|
|
||||||
) {
|
) {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
||||||
@@ -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
|
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||||
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
||||||
*/
|
*/
|
||||||
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
||||||
// The instruction prompt opens INLINE (inlineAsk) at the target: the selected
|
// Both scopes use the same multi-line split-below webview box; only the header
|
||||||
// range for a range edit, or the editor's cursor line / the document start for
|
// (and the downstream proposal logic) differs. For a selection the document
|
||||||
// a whole-document edit — never the top-center QuickInput.
|
// above keeps the selection highlighted while the box is open.
|
||||||
const anchor = this.editTargetAnchor(document, target);
|
const header =
|
||||||
const instruction = await this.inlineAsk.prompt(document.uri, anchor);
|
target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection";
|
||||||
|
const instruction = await this.askEditInstruction(header);
|
||||||
if (!instruction) return;
|
if (!instruction) return;
|
||||||
try {
|
try {
|
||||||
const ids = await vscode.window.withProgress(
|
const ids = await vscode.window.withProgress(
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
|||||||
"cowriting.reopenThread",
|
"cowriting.reopenThread",
|
||||||
"cowriting.edit",
|
"cowriting.edit",
|
||||||
"cowriting.editSelection",
|
"cowriting.editSelection",
|
||||||
"cowriting.askClaude.submit",
|
|
||||||
"cowriting.askClaude.cancel",
|
|
||||||
"cowriting.applyAgentEdit",
|
"cowriting.applyAgentEdit",
|
||||||
"cowriting.proposeAgentEdit",
|
"cowriting.proposeAgentEdit",
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
|||||||
await vscode.window.showTextDocument(a.doc);
|
await vscode.window.showTextDocument(a.doc);
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
// Stub the instruction prompt (sealed input box can't run in CI) + the LLM turn.
|
// Stub the document instruction prompt (the webview can't run in CI) + the LLM turn.
|
||||||
const origPrompt = api.inlineAsk.prompt;
|
const origPrompt = ctl.askEditInstruction;
|
||||||
(api.inlineAsk as any).prompt = async () => "rewrite it";
|
ctl.askEditInstruction = async () => "rewrite it";
|
||||||
ctl.setEditTurnForTest(async () => ({
|
ctl.setEditTurnForTest(async () => ({
|
||||||
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
@@ -88,7 +88,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
|||||||
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
||||||
await settle();
|
await settle();
|
||||||
} finally {
|
} finally {
|
||||||
(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.
|
// 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 vscode.window.showTextDocument(a.doc);
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
const origPrompt = api.inlineAsk.prompt;
|
const origPrompt = ctl.askEditInstruction;
|
||||||
(api.inlineAsk as any).prompt = async () => "rewrite it";
|
ctl.askEditInstruction = async () => "rewrite it";
|
||||||
ctl.setEditTurnForTest(async () => ({
|
ctl.setEditTurnForTest(async () => ({
|
||||||
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
|
replacement: "# No arg\n\nThe REWRITTEN active doc paragraph.\n",
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
@@ -121,7 +121,7 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
|||||||
await vscode.commands.executeCommand("cowriting.editDocument");
|
await vscode.commands.executeCommand("cowriting.editDocument");
|
||||||
await settle();
|
await settle();
|
||||||
} finally {
|
} finally {
|
||||||
(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");
|
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user