From c94b9ccfe71296d3aeba5ef2e33196a4c6765f73 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sat, 13 Jun 2026 08:15:55 -0700 Subject: [PATCH] #46 (SLICE-3, accept): Accept all pending proposals in one gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document-edit flow SLICE-3 — completes reach→review→accept (specs/coauthoring-document-edit-flow.md §7.2, INV-42). A single "Accept all" gesture applies every pending proposal on the current document through the existing F4 acceptById seam (block proposals take the INV-40 word-precise path automatically), in descending anchor order so an earlier accept never invalidates a later one, skipping (never force-applying) proposals that can't anchor and reporting applied-vs-skipped. Batched application of the existing accept path — no new mechanism; the webview posts intent only (INV-35). No confirmation dialog (undo restores). - proposalController.ts: acceptAllProposals(document) → {applied, skipped} (descending order, orphan-skip); accept/acceptById gain a silent opt so the batch suppresses N per-proposal orphan warnings in favour of one report. - trackChangesPreview.ts: ToolbarMsg += {type:"acceptAll"}; handleWebviewMessage routes it to a public acceptAll(document) that batches + reports. - extension.ts + package.json: cowriting.acceptAllProposals command (active doc, markdown-gated palette entry) for the non-webview path. - media/preview.ts + shellHtml: "✓✓ Accept all" toolbar button, posting the intent, shown only with ≥2 pending proposals (authorable, on-state). - trackChangesModel.ts: diffToBlockHunks now emits one block-aligned hunk per CHANGED block even when changed blocks are ADJACENT (treats changed blocks as 1:1 anchors alongside unchanged ones; gap-spans only cover add/remove runs between anchors) — fixes adjacent changed blocks collapsing into one proposal. - f12Accept host E2E (apply-all reconstructs; orphan skip + report; single proposal; command registered/gated); MANUAL-SMOKE-F12 §3. 214 unit + 73/5 host E2E green. Completes the document-edit-flow cluster (#42 reach + #47 review + #46 accept). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/MANUAL-SMOKE-F12.md | 27 +++++++ media/preview.ts | 9 +++ package.json | 9 +++ src/extension.ts | 14 ++++ src/proposalController.ts | 52 ++++++++++--- src/trackChangesModel.ts | 59 +++++++------- src/trackChangesPreview.ts | 24 +++++- test/e2e/suite/f12Accept.test.ts | 128 +++++++++++++++++++++++++++++++ 8 files changed, 286 insertions(+), 36 deletions(-) create mode 100644 test/e2e/suite/f12Accept.test.ts diff --git a/docs/MANUAL-SMOKE-F12.md b/docs/MANUAL-SMOKE-F12.md index 516ef7d..b8f1c26 100644 --- a/docs/MANUAL-SMOKE-F12.md +++ b/docs/MANUAL-SMOKE-F12.md @@ -67,3 +67,30 @@ light copy-edit instruction (e.g. "tighten the prose, fix typos"). A document edit yields one in-context proposal per changed block (fences atomic); accepting a block lands the whole block but Claude-attributes only the words it changed; inserted blocks accept; selection edits are unaffected; no console errors. + +## SLICE-3 — #46 (accept): Accept all (INV-42) + +On a markdown doc, **Ask Claude to Edit Document** with a pass that changes +**several** blocks, so the preview shows **≥ 2** pending proposals. + +1. **Button appears at ≥2 (PUC-6).** The preview toolbar shows **✓✓ Accept all** + only when there are **2 or more** pending proposals (and the doc is authorable, + annotations on). With 0–1 pending it is hidden. +2. **One gesture applies all.** Click **Accept all**: every pending proposal lands + (Claude-attributed per INV-40), the ✓/✗ blocks clear, and a status message + reports how many were accepted. No confirmation dialog. +3. **Undo restores.** `Ctrl+Z` walks back the applied edits (consistent with + single accept; a block accept is itself N steps — see SLICE-2). +4. **Orphan-skip + report.** If one proposal's target text was changed by hand + first (so it can't anchor), Accept all applies the rest and the report says + `… , N skipped (target text changed — undo or reject)`; the orphaned proposal + stays pending, its text untouched (never force-applied). +5. **Command path.** With no preview panel open, the command palette **Cowriting: + Accept All Claude Proposals** (markdown-gated) applies all proposals on the + active doc with the same report. + +### Pass criteria + +Accept all is offered only at ≥2 pending; one click applies every resolvable +proposal and reports the tally; orphans are skipped (not mangled) and remain +pending; the palette command works on the active doc; no console errors. diff --git a/media/preview.ts b/media/preview.ts index 2d6cc17..b42e8ab 100644 --- a/media/preview.ts +++ b/media/preview.ts @@ -30,6 +30,7 @@ const legend = document.getElementById("cw-legend")!; const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null; const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null; const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null; +const acceptAllEl = document.getElementById("cw-acceptall") as HTMLButtonElement | null; // F10: the annotations on/off toggle. annotationsEl?.addEventListener("change", () => { @@ -88,6 +89,11 @@ askEl?.addEventListener("click", () => { } }); +// #46 (INV-42): Accept all — batch-accept every pending proposal (intent only). +acceptAllEl?.addEventListener("click", () => { + vscodeApi.postMessage({ type: "acceptAll" }); +}); + // F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam). body.addEventListener("click", (e) => { const btn = (e.target as HTMLElement)?.closest(".cw-actions button"); @@ -132,6 +138,9 @@ window.addEventListener("message", (event: MessageEvent) => { if (askEl) askEl.disabled = !authorable; const on = msg.mode === "on"; if (annotationsEl) annotationsEl.checked = on; + // #46: Accept all shows only with ≥2 pending proposals, on an authorable doc, + // in the annotated (on) state — a single proposal is just a ✓ in place. + if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2; // Off-state is a clean preview: hide the review chrome. header.hidden = !on; summary.hidden = !on; diff --git a/package.json b/package.json index 1e595e2..b08e4ab 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,11 @@ "command": "cowriting.editDocument", "title": "Ask Claude to Edit Document", "category": "Cowriting" + }, + { + "command": "cowriting.acceptAllProposals", + "title": "Accept All Claude Proposals", + "category": "Cowriting" } ], "menus": { @@ -110,6 +115,10 @@ { "command": "cowriting.editDocument", "when": "editorLangId == markdown" + }, + { + "command": "cowriting.acceptAllProposals", + "when": "editorLangId == markdown" } ], "editor/title": [ diff --git a/src/extension.ts b/src/extension.ts index 4691f42..1db0f5f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -107,6 +107,20 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); context.subscriptions.push(trackChangesPreviewController); + // #46 (INV-42): accept every pending proposal on the active doc in one gesture + // (also reachable from the preview toolbar's "Accept all" button). Reuses the + // batched F4 seam + reports applied-vs-skipped. + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { + const doc = vscode.window.activeTextEditor?.document; + if (!doc || doc.languageId !== "markdown") { + void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals."); + return; + } + await trackChangesPreviewController.acceptAll(doc); + }), + ); + // --- F6 machine-landing wiring — now for ANY authorable doc --- // The seam's single machine-landing signal advances the F6 baseline (INV-18); // the seam can now fire on out-of-folder files too, so wire it unconditionally. diff --git a/src/proposalController.ts b/src/proposalController.ts index 60ac284..5e712bf 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -138,9 +138,40 @@ export class ProposalController implements vscode.Disposable { // ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ---------------------------------- /** Accept by proposal id (test-facing twin of the thread-menu gesture). */ - async acceptById(docPath: string, proposalId: string): Promise { + async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise { const hit = this.byId(docPath, proposalId); - return hit ? this.accept(hit.state, hit.proposal) : false; + return hit ? this.accept(hit.state, hit.proposal, opts) : false; + } + + /** + * #46 (INV-42): accept EVERY pending proposal on a document in one gesture — a + * batched application of the existing `acceptById` seam, not a new mechanism. + * Block proposals take the INV-40 word-precise path automatically. Applied in + * DESCENDING anchor order so an earlier accept never invalidates a later one's + * offsets; proposals whose anchor can't resolve are SKIPPED (never force-applied) + * and counted. Returns the applied-vs-skipped tally for the caller to report. + */ + async acceptAllProposals(document: vscode.TextDocument): Promise<{ applied: number; skipped: number }> { + if (!this.isTracked(document)) return { applied: 0, skipped: 0 }; + const state = this.ensureState(document); + state.artifact = this.store.load(state.docPath) ?? emptyArtifact(state.docPath); + const text = document.getText(); + const items = state.artifact.proposals.map((p) => { + const fp = state.artifact.anchors[p.anchorId]?.fingerprint; + const resolved = fp ? resolve(text, fp) : "orphaned"; + return { id: p.id, start: resolved === "orphaned" ? null : resolved.start }; + }); + const resolvable = items + .filter((i): i is { id: string; start: number } => i.start !== null) + .sort((a, b) => b.start - a.start); + let applied = 0; + let skipped = items.length - resolvable.length; // orphans, skipped up front + for (const it of resolvable) { + // silent: one batch report stands in for N per-proposal warnings. + if (await this.acceptById(state.docPath, it.id, { silent: true })) applied++; + else skipped++; + } + return { applied, skipped }; } /** Reject by proposal id (test-facing twin of the thread-menu gesture). */ rejectById(docPath: string, proposalId: string): boolean { @@ -150,7 +181,7 @@ export class ProposalController implements vscode.Disposable { return true; } - private async accept(state: DocState, proposal: Proposal): Promise { + private async accept(state: DocState, proposal: Proposal, opts?: { silent?: boolean }): Promise { if (this.guard.isReadOnly(state.docPath)) return false; const document = this.openDoc(state); if (!document) return false; @@ -158,9 +189,11 @@ export class ProposalController implements vscode.Disposable { const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint; const resolved = fp ? resolve(document.getText(), fp) : "orphaned"; if (resolved === "orphaned") { - void vscode.window.showWarningMessage( - "Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).", - ); + // #46: accept-all suppresses per-proposal warnings (one batch report instead). + if (!opts?.silent) + void vscode.window.showWarningMessage( + "Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).", + ); this.renderAll(document); return false; } @@ -178,9 +211,10 @@ export class ProposalController implements vscode.Disposable { { expectedVersion: document.version, turnId: proposal.turnId }, ); if (!ok) { - void vscode.window.showWarningMessage( - "Cowriting: the editor rejected the accept — the proposal is still pending.", - ); + if (!opts?.silent) + void vscode.window.showWarningMessage( + "Cowriting: the editor rejected the accept — the proposal is still pending.", + ); return false; } this.store.update(state.docPath, (a) => removeProposal(a, proposal.id)); diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 4cb86cb..f96da2c 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -337,14 +337,16 @@ function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignO * did and this supersedes for document edits). Built by aligning both sides into * the existing block units (`splitBlocksWithRanges`) and keying with the same diff * as `diffBlocks`: - * - an ISOLATED changed block (1:1, unchanged blocks on both sides) → a + * - a CHANGED block (1:1 aligned, even when adjacent to other changed blocks) → a * block-aligned hunk `[block.start, block.end)` → the rewritten block's raw - * text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23). - * - any other changed RUN (block insertions/deletions, or adjacent changes) → one - * gap-span hunk covering the source between the bounding unchanged blocks → the - * matching rewritten span (separators included), so reconstruction stays exact. - * A zero-width gap-span (insert at a seamless boundary) is anchored to an - * adjacent token (INV-41) so its F4 fingerprint resolves and it accepts. + * text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23). So + * a copy-edit pass touching N consecutive paragraphs yields N proposals. + * - a run of block INSERTIONS / DELETIONS → one gap-span hunk covering the source + * between the bounding aligned blocks (unchanged OR changed — both are 1:1 + * anchors) → the matching rewritten span (separators included), so + * reconstruction stays exact. A zero-width gap-span (insert at a seamless + * boundary) is anchored to an adjacent token (INV-41) so its F4 fingerprint + * resolves and it accepts. * - unchanged blocks (same key AND same raw) → no hunk. * Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which * is RETAINED as the intra-block sub-diff engine for word-precise accept @@ -359,34 +361,39 @@ export function diffToBlockHunks(currentText: string, rewrittenText: string): Ed for (const op of ops) { if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed"; } + // `changed` and `unchanged` are 1:1 anchors (both sides' offsets are known); + // `added`/`removed` have no counterpart and must be spanned together. + const isAnchor = (op: BlockAlignOp) => op.kind === "unchanged" || op.kind === "changed"; const hunks: EditHunk[] = []; let i = 0; while (i < ops.length) { - if (ops[i].kind === "unchanged") { + const op = ops[i]; + if (op.kind === "unchanged") { i++; continue; } - let j = i; - while (j < ops.length && ops[j].kind !== "unchanged") j++; - const run = ops.slice(i, j); - if (run.length === 1 && run[0].kind === "changed") { - const c = cur[run[0].ci!]; - hunks.push({ start: c.start, end: c.end, replacement: next[run[0].ni!].raw }); - } else { - // A run with insertions/deletions: replace the whole inter-anchor gap so the - // separators reconstruct exactly. Bound by the unchanged blocks on each side - // (or the document edges). - const prev = i > 0 ? ops[i - 1] : null; - const after = j < ops.length ? ops[j] : null; - const curStart = prev ? cur[prev.ci!].end : 0; - const curEnd = after ? cur[after.ci!].start : currentText.length; - const newStart = prev ? next[prev.ni!].end : 0; - const newEnd = after ? next[after.ni!].start : rewrittenText.length; - const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) }; - hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk); + if (op.kind === "changed") { + const c = cur[op.ci!]; + hunks.push({ start: c.start, end: c.end, replacement: next[op.ni!].raw }); + i++; + continue; } + // A maximal run of added/removed blocks → one gap-span hunk over the source + // between the bounding anchors (or the document edges), replaced with the + // matching rewritten span, so the separators reconstruct exactly. + let j = i; + while (j < ops.length && !isAnchor(ops[j])) j++; + const prev = i > 0 ? ops[i - 1] : null; // an anchor by construction + const after = j < ops.length ? ops[j] : null; // an anchor (or null at EOF) + const curStart = prev ? cur[prev.ci!].end : 0; + const curEnd = after ? cur[after.ci!].start : currentText.length; + const newStart = prev ? next[prev.ni!].end : 0; + const newEnd = after ? next[after.ni!].start : rewrittenText.length; + const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) }; + hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk); i = j; } + hunks.sort((a, b) => a.start - b.start); return hunks; } diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index 98d8da7..f3be21e 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -37,7 +37,8 @@ type ToolbarMsg = | { type: "reject"; proposalId: string } | { type: "pinBaseline" } | { type: "askClaude"; scope: "document" } - | { type: "askClaude"; scope: "selection"; start: number; end: number }; + | { type: "askClaude"; scope: "selection"; start: number; end: number } + | { type: "acceptAll" }; export class TrackChangesPreviewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; @@ -190,9 +191,29 @@ export class TrackChangesPreviewController implements vscode.Disposable { const target: EditTarget = m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" }; void this.askClaude(document, target); + } else if (m?.type === "acceptAll") { + // #46 (INV-42): batch-accept every pending proposal on this doc, then report. + void this.acceptAll(document); } } + /** + * #46 (INV-42): apply every pending proposal on the document through the F4 + * accept seam (orphan-skip) and report the applied-vs-skipped tally. No + * confirmation dialog — VS Code undo restores (parity with single accept). + * Public so the `cowriting.acceptAllProposals` command can reach it for the + * active doc (not only the webview button). + */ + async acceptAll(document: vscode.TextDocument): Promise { + const { applied, skipped } = await this.proposals.acceptAllProposals(document); + this.refresh(document); + if (applied === 0 && skipped === 0) return; + const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; + void vscode.window.showInformationMessage( + `Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`, + ); + } + /** * 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 @@ -392,6 +413,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { + Review diff --git a/test/e2e/suite/f12Accept.test.ts b/test/e2e/suite/f12Accept.test.ts new file mode 100644 index 0000000..16a38f0 --- /dev/null +++ b/test/e2e/suite/f12Accept.test.ts @@ -0,0 +1,128 @@ +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 { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports controllers"); + 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); + await vscode.window.showTextDocument(doc); + await settle(); + return { doc, key: uri.toString() }; +} + +// #46 SLICE-3 (accept): one "Accept all" gesture applies every pending proposal +// on the current document through the existing F4 accept seam (INV-42) — batched, +// re-anchor-safe (descending), orphan-skip + report. Host E2E, no LLM. +suite("F12 SLICE-3 — accept-all (#46, INV-42)", () => { + // PUC-6: N pending → all applied, text replaced, proposals cleared. + test("acceptAll applies every pending proposal and reconstructs the document", async () => { + const original = "# All\n\nFirst para alpha.\n\nSecond para beta.\n\nThird para gamma.\n"; + const rewrite = "# All\n\nFirst para ALPHA.\n\nSecond para BETA.\n\nThird para GAMMA.\n"; + const { doc, key } = await freshDoc("docs/f12-all.md", original); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + + ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-all" })); + const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase the nouns"); + await settle(); + assert.strictEqual(ids.length, 3, "three changed blocks → three pending proposals"); + + // Simulate the toolbar "Accept all" button posting its intent. + ctl.receiveMessage(key, { type: "acceptAll" }); + await settle(); + await settle(); + + assert.strictEqual(doc.getText(), rewrite, "accept-all reconstructs the intended document"); + assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "all proposals cleared"); + }); + + // PUC-6 / INV-42: an orphaned proposal is skipped (not force-applied) and the + // resolvable ones still apply — reported via the {applied, skipped} tally. + test("acceptAllProposals skips an orphaned proposal and applies the rest (report)", async () => { + const original = "# Mix\n\nKeep alpha here.\n\nKeep gamma here.\n"; + const rewrite = "# Mix\n\nKeep ALPHA here.\n\nKeep GAMMA here.\n"; + const { doc } = await freshDoc("docs/f12-orphan.md", original); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + + ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-f12-orphan" })); + const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase"); + await settle(); + assert.strictEqual(ids.length, 2, "two pending proposals"); + + // Orphan the FIRST block's proposal by mangling its target text in the buffer. + const para = "Keep alpha here."; + const start = doc.getText().indexOf(para); + const edit = new vscode.WorkspaceEdit(); + edit.replace( + doc.uri, + new vscode.Range(doc.positionAt(start), doc.positionAt(start + para.length)), + "Totally different first paragraph now.", + ); + assert.ok(await vscode.workspace.applyEdit(edit), "mangling edit applied"); + await settle(); + + const { applied, skipped } = await api.proposalController.acceptAllProposals(doc); + assert.strictEqual(applied, 1, "the still-resolvable proposal applied"); + assert.strictEqual(skipped, 1, "the orphaned proposal was skipped, not mangled"); + assert.ok(doc.getText().includes("Keep GAMMA here."), "the resolvable block landed"); + assert.ok( + doc.getText().includes("Totally different first paragraph now."), + "the orphaned block kept the operator's text (never force-applied)", + ); + assert.strictEqual(api.proposalController.listProposals(doc).length, 1, "the orphaned proposal remains pending"); + }); + + // A single pending proposal still applies through the batch path (the button is + // hidden < 2 pending in the webview, but the command/seam handle any count). + test("acceptAllProposals with one pending proposal applies it", async () => { + const { doc } = await freshDoc("docs/f12-one.md", "# One\n\nThe only paragraph here.\n"); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + ctl.setEditTurnForTest(async () => ({ + replacement: "# One\n\nThe ONLY paragraph here.\n", + model: "sonnet", + sessionId: "e2e-f12-one", + })); + const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "uppercase only"); + await settle(); + assert.strictEqual(ids.length, 1, "one pending proposal"); + const { applied, skipped } = await api.proposalController.acceptAllProposals(doc); + assert.strictEqual(applied, 1); + assert.strictEqual(skipped, 0); + assert.strictEqual(doc.getText(), "# One\n\nThe ONLY paragraph here.\n"); + }); + + // The command is registered + palette-guarded on markdown. + test("cowriting.acceptAllProposals is registered and palette-guarded on markdown", async () => { + const all = await vscode.commands.getCommands(true); + assert.ok(all.includes("cowriting.acceptAllProposals"), "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.acceptAllProposals", + ); + assert.ok(entry, "has a commandPalette entry"); + assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); + }); +});