d6b3c6fa5f
Operator feedback on the Ask-Claude ergonomics (relates to #42/#43/#60):
- One user-facing command `cowriting.edit` ("Ask Claude to Edit") routes to the
selection or whole-document flow at runtime via the pure `routeEdit` helper
(selection → editSelection; none / tab right-click → editDocument). The two
underlying commands stay registered for the seams + E2E but are hidden from the
palette, and the split menu pairs collapse to one entry.
- Keybindings with `mac` variants (the missing variant is why ⌃⌥R "didn't work"
on macOS — Option combos are unreliable there): `cmd+alt+e`/`ctrl+alt+e` for
Ask-Claude-to-Edit, `cmd+alt+r`/`ctrl+alt+r` for the review panel.
- The instruction prompt now renders INLINE at the selection/cursor via a
dedicated Comments-API controller (`InlineAskController`, `cowriting.askClaude`)
instead of the top-center QuickInput — VS Code's Inline-Chat-style placement.
Both Ask-Claude entry points (editSelection + the preview's askClaude) share it.
Tests: routeEdit unit tests; E2E menu/palette assertions updated for the unified
command; E2E drives the inline prompt by stubbing `api.inlineAsk.prompt`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
6.1 KiB
TypeScript
129 lines
6.1 KiB
TypeScript
import * as assert from "assert";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import * as vscode from "vscode";
|
|
import type { CowritingApi } from "../../../src/extension";
|
|
|
|
const WS = process.env.E2E_WORKSPACE!;
|
|
const settle = () => new Promise((r) => setTimeout(r, 400));
|
|
|
|
async function getApi(): Promise<CowritingApi> {
|
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
|
const api = (await ext.activate()) as CowritingApi;
|
|
assert.ok(api?.trackChangesPreviewController, "exports preview controller");
|
|
return api;
|
|
}
|
|
|
|
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
|
|
const abs = path.join(WS, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, body, "utf8");
|
|
const uri = vscode.Uri.file(abs);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
return { doc, key: uri.toString() };
|
|
}
|
|
|
|
function pkg(): any {
|
|
return JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
|
|
}
|
|
function menu(id: string): Array<{ command: string; when?: string; group?: string }> {
|
|
return pkg().contributes.menus[id] ?? [];
|
|
}
|
|
|
|
// 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.
|
|
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", () => {
|
|
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");
|
|
});
|
|
|
|
// 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 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,
|
|
// not whatever editor happens to be active (mirrors #41's clicked-doc resolution).
|
|
test("editDocument(uri) targets the clicked tab's document, not the active editor", async () => {
|
|
const api = await getApi();
|
|
const ctl = api.trackChangesPreviewController;
|
|
|
|
// Doc A is the active editor; Doc B is the "clicked tab" we pass by URI.
|
|
const a = await freshDoc("docs/f12-active.md", "# Active\n\nThe active editor paragraph.\n");
|
|
const b = await freshDoc("docs/f12-tab.md", "# Tab\n\nThe tab target paragraph to rewrite.\n");
|
|
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";
|
|
ctl.setEditTurnForTest(async () => ({
|
|
replacement: "# Tab\n\nThe REWRITTEN tab paragraph.\n",
|
|
model: "sonnet",
|
|
sessionId: "e2e-f12-tab",
|
|
}));
|
|
try {
|
|
await vscode.commands.executeCommand("cowriting.editDocument", b.doc.uri);
|
|
await settle();
|
|
} finally {
|
|
(api.inlineAsk as any).prompt = origPrompt;
|
|
}
|
|
|
|
// The proposal(s) landed on the TAB doc (B), and the ACTIVE doc (A) has none.
|
|
assert.ok(api.proposalController.listProposals(b.doc).length >= 1, "tab doc B received the document-edit proposal(s)");
|
|
assert.strictEqual(
|
|
api.proposalController.listProposals(a.doc).length,
|
|
0,
|
|
"active doc A was NOT edited — editDocument honored the tab URI",
|
|
);
|
|
// INV-10: proposing never mutates the document.
|
|
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
|
|
});
|
|
|
|
// No URI arg (palette / keybinding) → fall back to the active editor.
|
|
test("editDocument() with no arg targets the active editor", async () => {
|
|
const api = await getApi();
|
|
const ctl = api.trackChangesPreviewController;
|
|
const a = await freshDoc("docs/f12-noarg.md", "# No arg\n\nThe active doc paragraph here.\n");
|
|
await vscode.window.showTextDocument(a.doc);
|
|
await settle();
|
|
|
|
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",
|
|
sessionId: "e2e-f12-noarg",
|
|
}));
|
|
try {
|
|
await vscode.commands.executeCommand("cowriting.editDocument");
|
|
await settle();
|
|
} finally {
|
|
(api.inlineAsk as any).prompt = origPrompt;
|
|
}
|
|
assert.ok(api.proposalController.listProposals(a.doc).length >= 1, "active doc received the proposal(s) on no-arg");
|
|
});
|
|
});
|