diff --git a/package.json b/package.json index bd15bcf..1b1bbd2 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,18 @@ "command": "cowriting.stopCoediting", "title": "Stop editing with Claude", "category": "Cowriting" + }, + { + "command": "cowriting.askClaude", + "title": "Ask Claude", + "category": "Cowriting", + "icon": "$(sparkle)" + }, + { + "command": "cowriting.makeThreadEdit", + "title": "✦ Make this edit", + "category": "Cowriting", + "icon": "$(sparkle)" } ], "menus": { @@ -171,6 +183,14 @@ "command": "cowriting.editDocument", "when": "false" }, + { + "command": "cowriting.askClaude", + "when": "editorLangId == markdown && cowriting.isCoediting" + }, + { + "command": "cowriting.makeThreadEdit", + "when": "false" + }, { "command": "cowriting.acceptAllProposals", "when": "editorLangId == markdown && cowriting.isCoediting" @@ -194,6 +214,11 @@ "when": "resourceLangId == markdown && cowriting.isCoediting", "group": "navigation@1" }, + { + "command": "cowriting.askClaude", + "when": "resourceLangId == markdown && cowriting.isCoediting", + "group": "navigation@2" + }, { "command": "cowriting.markReviewed", "when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot", @@ -249,11 +274,21 @@ "comments/commentThread/context": [ { "command": "cowriting.reply", - "group": "inline", + "group": "inline@1", "when": "commentController == cowriting.threads" + }, + { + "command": "cowriting.makeThreadEdit", + "group": "inline@2", + "when": "commentController == cowriting.threads && commentThread == offer" } ], "comments/commentThread/title": [ + { + "command": "cowriting.makeThreadEdit", + "group": "inline@1", + "when": "commentController == cowriting.threads && commentThread == offer" + }, { "command": "cowriting.resolveThread", "group": "inline", diff --git a/src/editFlow.ts b/src/editFlow.ts index a174fdd..1cf440d 100644 --- a/src/editFlow.ts +++ b/src/editFlow.ts @@ -17,7 +17,6 @@ import type { CoeditingRegistry } from "./coeditingRegistry"; import { diffToBlockHunks } from "./trackChangesModel"; import { buildFingerprint } from "./anchorer"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; -import { promptEditInstruction } from "./editInstructionInput"; /** The exact warning copy for every gated edit gesture (INV-10). */ const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; @@ -42,12 +41,20 @@ export class EditFlow implements vscode.Disposable { 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 (extension.ts). + * LEGACY (Task 6, spec §6.10): the instruction prompt used to be the + * multi-line split-below input webview (`editInstructionInput.ts`, deleted). + * The real interactive ask is now ThreadController.askClaude — a focused + * comment box (D19); `cowriting.editSelection` routes there directly. This + * field's only remaining production caller is the `cowriting.editDocument` + * command below (via `askClaude`), and the review-webview's toolbar ask — + * both die fully in Task 8. Rejects by default so any surviving real call + * fails loudly instead of silently invoking a webview that no longer + * exists; host E2E stub this field directly (mirrors `editTurn`). */ - askEditInstruction: (header: string) => Promise = promptEditInstruction; + askEditInstruction: (header: string) => Promise = (header) => + Promise.reject( + new Error(`Cowriting: the "${header}" instruction input was removed — use ✦ Ask Claude instead.`), + ); /** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */ private turnSeq = 0; private nextTurnSeq(): number { diff --git a/src/editInstructionInput.ts b/src/editInstructionInput.ts deleted file mode 100644 index 12cb29c..0000000 --- a/src/editInstructionInput.ts +++ /dev/null @@ -1,147 +0,0 @@ -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 7456457..d39c3a7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,13 +15,11 @@ import { TrackChangesPreviewController } from "./trackChangesPreview"; import { EditFlow } from "./editFlow"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; -import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; +import { isAuthorable, routeEdit } from "./workspacePath"; import { CoeditingRegistry } from "./coeditingRegistry"; import { ScmSurfaceController } from "./scmSurface"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; -/** The exact warning copy for every gated edit gesture (INV-10). */ -const NOT_COEDITING_WARNING = "Run ✦ Coedit this Document with Claude first."; export interface CowritingApi { threadController: ThreadController; @@ -148,8 +146,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // F5 (INV-16): one shared guard — newer-major sidecars are read-only, one // warning per doc across the three co-owning controllers. const versionGuard = new VersionGuard(sidecarRouter); - const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry); - context.subscriptions.push(threadController); // --- F3: live attribution (Feature #6) --- const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry); @@ -170,10 +166,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the review preview (which - // delegates to it) and reachable from the thread controller too (Task 6). --- + // delegates to it) and BEFORE the thread controller, which now consumes it too + // (Task 6: the comment-loop's "make this edit" offer calls runEditAndPropose). --- const editFlow = new EditFlow(proposalController, attributionController, liveProgressUi, coeditingRegistry); context.subscriptions.push(editFlow); + // --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→ + // offer→proposal loop. Consumes editFlow (offer → pending proposals) and + // liveProgressUi (the shared "asking Claude…" notification + output channel). --- + const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi); + context.subscriptions.push(threadController); + // --- F7/F10: the review preview is the single interactive review surface --- // Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, // INV-20). Constructed AFTER attribution (reads F3 spans), proposals (routes @@ -307,109 +310,16 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ), ); - // F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one - // claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4, - // INV-10); the applyAgentEdit seam (INV-9) now fires only on accept. + // Task 6 (D19): the comments-first ask replaces the old inline turn + // plumbing here — the ask now IS a comment. threadController.askClaude() + // opens a focused comment box on the current selection (or, with no + // selection, a top-anchored whole-document thread); the comment→reply→ + // offer→proposal loop (ThreadController.respondInThread/makeThreadEdit) + // takes it from there. Kept as its own command (rather than folded into + // cowriting.edit) for the host E2E harness and the internal seams. context.subscriptions.push( vscode.commands.registerCommand("cowriting.editSelection", async () => { - const editor = vscode.window.activeTextEditor; - // F8: authoring works on any file: or untitled: doc (the router decides - // where its artifact is stored). Each failure still names its real reason - // (no editor / no selection / a non-{file,untitled} read-only view) — not - // always "select some text" (#24's per-condition messaging). - const reason = selectionRejection({ - hasEditor: !!editor, - selectionEmpty: editor?.selection.isEmpty ?? true, - scheme: editor?.document.uri.scheme ?? "", - }); - if (reason) { - void vscode.window.showWarningMessage(reason); - return; - } - if (!editor) return; // unreachable once reason is null, but narrows the type - const document = editor.document; - // INV-10: a non-entered doc gets the gate warning, not a turn. - if (!coeditingRegistry.isCoediting(document.uri)) { - void vscode.window.showWarningMessage(NOT_COEDITING_WARNING); - return; - } - const selection = editor.selection; // non-empty (selectionRejection guaranteed it) - // 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); - 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 EditFlow); 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 editFlow.askEditInstruction("Ask Claude to Edit This Selection"); - if (!instruction) return; - const turnId = `turn-${Date.now().toString(36)}`; - try { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: "Cowriting: asking Claude…", - cancellable: true, - }, - async (progress, token) => { - const { runEditTurn } = await import("./liveTurn"); - const ui = liveProgressUi.begin(instruction, progress, token); - let turn; - try { - turn = await runEditTurn(instruction, selectedText, { - onProgress: ui.onProgress, - signal: ui.signal, - }); - } catch (err) { - // #60 (INV-47): a user cancel surfaces as "cancelled", not a failure. - if (token.isCancellationRequested) { - void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled."); - return; - } - throw err; - } - if (turn.replacement === "") { - void vscode.window.showWarningMessage( - "Cowriting: Claude returned an empty replacement — nothing was proposed.", - ); - return; - } - if (turn.replacement === selectedText) { - void vscode.window.showInformationMessage( - "Cowriting: Claude proposed no change to the selection.", - ); - return; - } - // F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation. - // The seam now fires only on accept (ProposalController, INV-9). - const id = await proposalController.propose( - document, - fp, - turn.replacement, - { - kind: "agent", - id: "claude", - agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId }, - }, - { turnId, instruction }, - ); - if (id) { - void vscode.window.showInformationMessage( - "Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).", - ); - } - }, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`); - } + await threadController.askClaude(); }), ); diff --git a/src/threadController.ts b/src/threadController.ts index 54d3135..a329bbc 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -16,6 +16,9 @@ import { addThread, appendMessage, setStatus } from "./threadModel"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; import type { CoeditingRegistry } from "./coeditingRegistry"; +import type { EditFlow, EditTarget } from "./editFlow"; +import type { LiveProgressUi } from "./liveProgressUi"; +import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedThread { @@ -25,6 +28,15 @@ export interface RenderedThread { range: { startLine: number; endLine: number }; } +/** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */ +const REPLY_PROMPT = (ask: string): string => + "Reply conversationally (2-4 sentences) to this remark about the text, then, if the remark implies " + + "an edit, state the edit you would make. Remark: " + + ask; + +/** F6.x/Task 6: the injectable low-level turn runner behind respondInThread. */ +type TurnRunner = (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise; + interface DocState { docPath: string; uri: vscode.Uri; @@ -41,6 +53,10 @@ export class ThreadController implements vscode.Disposable { private readonly controller: vscode.CommentController; private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath + /** thread -> the pending machine "offer" (D8): the ask it answered, ready to become an edit. */ + private readonly offers = new WeakMap(); + /** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */ + private turnRunner: TurnRunner | undefined; /** Kept as ONE object; re-assigned on every registry change so VS Code re-queries * (spec §6.4 v0.2.1 — reassignment is the API's only "ranges changed" signal). */ @@ -57,6 +73,8 @@ export class ThreadController implements vscode.Disposable { private readonly rootDir: string | undefined, private readonly guard: VersionGuard, private readonly registry: CoeditingRegistry, + private readonly editFlow: EditFlow, + private readonly liveProgressUi: LiveProgressUi, ) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); this.controller.commentingRangeProvider = this.rangeProvider; @@ -71,6 +89,14 @@ export class ThreadController implements vscode.Disposable { vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) => this.setThreadStatus(t, "open"), ), + // D19 (spec §6.4 v0.2.1): the comments-first "Ask Claude" gesture — a + // focused comment box, not a webview. + vscode.commands.registerCommand("cowriting.askClaude", () => this.askClaude()), + // D8: turn a machine "offer" thread into pending F4 proposal(s) (INV-5). + vscode.commands.registerCommand( + "cowriting.makeThreadEdit", + (arg: { thread: vscode.CommentThread } | vscode.CommentThread) => this.makeEditFromThreadArg(arg), + ), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), // INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate @@ -127,6 +153,31 @@ export class ThreadController implements vscode.Disposable { return state; } + // ---- D19: comments-first ask ----------------------------------------------------- + + /** + * D19 (spec §6.4 v0.2.1): "Ask Claude" opens a focused comment box on the + * active editor's selection, or, when there is no selection, a top-anchored + * whole-document thread. The ask itself IS a comment — respondInThread (D10) + * takes it from there once the human submits it via cowriting.reply. + */ + async askClaude(): Promise { + const ed = vscode.window.activeTextEditor; + if (!ed || !this.registry.isCoediting(ed.document.uri)) { + void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first."); + return; + } + if (ed.selection.isEmpty) { + // whole-document ask → top-anchored thread (D19) + ed.selection = new vscode.Selection(0, 0, 0, 0); + ed.revealRange(new vscode.Range(0, 0, 0, 0)); + } + // Spike finding: workbench.action.addComment is the only path that opens the + // comment widget WITH its input focused (no thread.focus() API exists). + this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh + await vscode.commands.executeCommand("workbench.action.addComment"); + } + // ---- PUC-1: create on selection ------------------------------------------------- async createThreadOnSelection(firstBody = "New thread"): Promise { @@ -143,20 +194,140 @@ export class ThreadController implements vscode.Disposable { const fp = buildFingerprint(document.getText(), offsets); const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody }); this.persist(state); - this.renderThread(document, state, threadId, offsets, false); + const vsThread = this.renderThread(document, state, threadId, offsets, false); + // D10: the first message of a new thread is also "a comment on a coedited + // doc" — run the same respond loop as a reply. + void this.respondInThread(vsThread, state, threadId, firstBody); return threadId; } // ---- PUC-2: reply / resolve ----------------------------------------------------- reply(r: vscode.CommentReply): void { - const threadId = this.threadIdOf(r.thread); - const state = this.stateOfThread(r.thread); - if (!threadId || !state) return; + const vsThread = r.thread; + if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) return; + const document = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString()); + if (!document) return; + const state = this.ensureState(document); if (this.guard.isReadOnly(state.docPath)) return; - appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); + let threadId = this.threadIdOf(vsThread); + if (threadId) { + appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); + } else { + // D10/D19: a brand-new thread created via the native "+"/comment-widget + // gesture (not via createThreadOnSelection) — VS Code hands us a real, + // still-unregistered CommentThread; register it now. + const range = vsThread.range ?? new vscode.Range(0, 0, 0, 0); + const offsets: OffsetRange = { + start: document.offsetAt(range.start), + end: document.offsetAt(range.end), + }; + const fp = buildFingerprint(document.getText(), offsets); + const created = addThread(state.artifact, fp, { author: this.currentAuthor(), body: r.text }); + threadId = created.threadId; + state.vsThreads.set(threadId, vsThread); + state.live.set(threadId, offsets); + state.orphaned.set(threadId, false); + } this.persist(state); - this.refreshComments(r.thread, state, threadId); + this.refreshComments(vsThread, state, threadId); + // D10: every human comment on a coedited doc runs a turn. + void this.respondInThread(vsThread, state, threadId, r.text); + } + + /** + * D10/PUC-8: run one reply turn for `ask`, then append the machine's reply + * (INV-8 onBehalfOf provenance) and flip the thread into an "offer" — ready + * for makeThreadEdit to cut it into pending proposal(s) (D8, INV-5). + */ + private async respondInThread( + vsThread: vscode.CommentThread, + state: DocState, + threadId: string, + ask: string, + ): Promise { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString()); + if (!doc) return; + const contextText = this.threadContextText(doc, vsThread); + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true }, + async (progress, token) => { + const run = this.turnRunner ?? (await import("./liveTurn")).runEditTurn; + const ui = this.liveProgressUi.begin(ask, progress, token); + let turn: EditTurnResult; + try { + turn = await run(REPLY_PROMPT(ask), contextText, { onProgress: ui.onProgress, signal: ui.signal }); + } catch (err) { + if (token.isCancellationRequested) return; + throw err; + } + // machine reply, onBehalfOf provenance (INV-8) + appendMessage(state.artifact, threadId, { + author: { + kind: "agent", + id: "claude", + agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId, onBehalfOf: this.currentAuthor() }, + }, + body: turn.replacement, + }); + this.persist(state); + this.refreshComments(vsThread, state, threadId); + vsThread.contextValue = "offer"; // when-key: commentThread == offer + this.offers.set(vsThread, { ask, threadId }); + }, + ); + } catch (err) { + void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** The passage a ranged thread discusses, or the whole document for a top-anchored (D19) thread. */ + private threadContextText(doc: vscode.TextDocument, vsThread: vscode.CommentThread): string { + const range = vsThread.range; + if (range && !range.isEmpty) return doc.getText(range); + return doc.getText(); + } + + /** D19: a ranged thread targets its range; a top-anchored (whole-doc-ask) thread targets the document. */ + private targetOf(doc: vscode.TextDocument, vsThread: vscode.CommentThread): EditTarget { + const range = vsThread.range; + if (!range || range.isEmpty) return { kind: "document" }; + return { kind: "range", start: doc.offsetAt(range.start), end: doc.offsetAt(range.end) }; + } + + // ---- D8: offer -> pending proposal(s) (INV-5) ------------------------------------- + + private async makeEditFromThreadArg(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise { + const vsThread = "thread" in arg ? arg.thread : arg; + await this.runMakeEdit(vsThread); + } + + private async runMakeEdit(vsThread: vscode.CommentThread): Promise { + const offer = this.offers.get(vsThread); + if (!offer || !this.registry.isCoediting(vsThread.uri)) return []; + const doc = await vscode.workspace.openTextDocument(vsThread.uri); + const target = this.targetOf(doc, vsThread); + const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask); + vsThread.contextValue = "offer-done"; + if (ids.length) { + void vscode.window.showInformationMessage( + `Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`, + ); + } + return ids; + } + + /** Test seam (Task 6): run the offer for a thread without the UI button. */ + async makeThreadEdit(threadId: string, docPath: string): Promise { + const vsThread = this.docs.get(docPath)?.vsThreads.get(threadId); + if (!vsThread) return []; + return this.runMakeEdit(vsThread); + } + + /** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */ + setTurnRunnerForTest(fn: TurnRunner): void { + this.turnRunner = fn; } private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void { @@ -248,7 +419,7 @@ export class ThreadController implements vscode.Disposable { threadId: string, offsets: OffsetRange, orphaned: boolean, - ): void { + ): vscode.CommentThread { const thread = state.artifact.threads.find((t) => t.id === threadId)!; const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); const vsThread = this.controller.createCommentThread( @@ -263,6 +434,7 @@ export class ThreadController implements vscode.Disposable { state.vsThreads.set(threadId, vsThread); state.live.set(threadId, offsets); state.orphaned.set(threadId, orphaned); + return vsThread; } private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void { diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts index a9b885a..3047d2b 100644 --- a/test/e2e/suite/coediting.test.ts +++ b/test/e2e/suite/coediting.test.ts @@ -42,6 +42,12 @@ suite("coediting registry (PUC-7)", () => { // rendered thread (not the sidecar-backed data); re-entering restores it. test("non-entered doc gets no surfaces; exit detaches; re-enter restores threads", async () => { const api = await activateApi(); + // Task 6 (D10): createThreadOnSelection now also fires the respond-in- + // thread turn once the doc is coediting — stub it off (not under test + // here; this suite asserts render/hide/restore, not the reply loop). + api.threadController.setTurnRunnerForTest(async () => { + throw new Error("coediting suite: reply-loop turn stubbed off"); + }); const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# a\n\npara\n" }); const ed = await vscode.window.showTextDocument(doc); // not entered → no thread creation diff --git a/test/e2e/suite/commentLoop.test.ts b/test/e2e/suite/commentLoop.test.ts new file mode 100644 index 0000000..4136ace --- /dev/null +++ b/test/e2e/suite/commentLoop.test.ts @@ -0,0 +1,47 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { activateApi, settleUntil } from "./helpers"; + +// Task 6 (D19/D10/D8, PUC-8): the comments-first ask + comment→reply→offer→ +// proposal loop, with a stubbed reply turn (no real @cline/sdk call). Runs on +// an untitled buffer (F8 routes it to the global sidecar) so this suite has no +// workspace-folder dependency. +suite("comment → reply → offer → proposal (PUC-8, D10/D19)", () => { + test("reply on a coedited doc summons a machine reply + offer; accept lands pending proposals", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ + language: "markdown", + content: "# T\n\nThe quick brown fox jumps over the lazy dog paragraph.\n", + }); + const ed = await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + api.threadController.setTurnRunnerForTest(async () => ({ + replacement: "I would tighten this sentence.", + model: "stub", + sessionId: "s1", + })); + api.editFlow.setEditTurnForTest(async () => ({ + replacement: "The quick fox jumps the lazy dog.", + model: "stub", + sessionId: "s1", + })); + ed.selection = new vscode.Selection(2, 0, 2, 20); + const threadId = (await api.threadController.createThreadOnSelection("tighten this"))!; + // reply-loop fires on the human message; wait for the machine message to persist + await settleUntil(() => { + const t = api.sidecarRouter.load(api.proposalController.keyFor(doc))?.threads.find((x) => x.id === threadId); + return (t?.messages.length ?? 0) >= 2 && t!.messages[1].author.kind === "agent"; + }, 10000); + const ids = await api.threadController.makeThreadEdit(threadId, api.proposalController.keyFor(doc)); + assert.ok(ids.length >= 1); + assert.ok(api.proposalController.listProposals(doc).length >= 1); // pending, INV-5 + }); + + test("a comment on a NON-coedited doc summons nothing (INV-10)", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "plain\n" }); + await vscode.window.showTextDocument(doc); + const id = await api.threadController.createThreadOnSelection("hello"); + assert.strictEqual(id, undefined); // gate refuses thread creation entirely + }); +}); diff --git a/test/e2e/suite/crossrung.test.ts b/test/e2e/suite/crossrung.test.ts index 9ac7f1c..ab2bbba 100644 --- a/test/e2e/suite/crossrung.test.ts +++ b/test/e2e/suite/crossrung.test.ts @@ -57,6 +57,13 @@ suite("F5 cross-rung round-trip (host E2E)", () => { const DOC = "docs/crossrung.md"; const doc = await open(DOC); const api = await getApi(); + // Task 6 (D10): createThreadOnSelection also fires the respond-in-thread + // turn now — stub it off so it can't race the forge stand-in's reply + // below (this test asserts an EXACT 2-message sequence: editor note + + // forge reply). + api.threadController.setTurnRunnerForTest(async () => { + throw new Error("crossrung suite: reply-loop turn stubbed off"); + }); const target = "portable record"; const start = doc.getText().indexOf(target); const editor = vscode.window.activeTextEditor!; diff --git a/test/e2e/suite/outOfWorkspace.test.ts b/test/e2e/suite/outOfWorkspace.test.ts index adabf77..89607aa 100644 --- a/test/e2e/suite/outOfWorkspace.test.ts +++ b/test/e2e/suite/outOfWorkspace.test.ts @@ -41,6 +41,12 @@ suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)", await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); const api = await getApi(); + // Task 6 (D10): createThreadOnSelection (used later in this test) also + // fires the respond-in-thread turn now — stub it off (this "no LLM" suite + // asserts the programmatic propose/accept seam, not the reply loop). + api.threadController.setTurnRunnerForTest(async () => { + throw new Error("out-of-workspace suite: reply-loop turn stubbed off"); + }); const key = uri.toString(); const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1"); diff --git a/test/e2e/suite/threads.test.ts b/test/e2e/suite/threads.test.ts index 347c1f8..05f3684 100644 --- a/test/e2e/suite/threads.test.ts +++ b/test/e2e/suite/threads.test.ts @@ -41,6 +41,17 @@ async function getApi(): Promise { return api; } +// Task 6 (D10): every human comment on a coedited doc now also fires the +// respond-in-thread turn (createThreadOnSelection/reply). This suite predates +// that loop and asserts exact message sequences unrelated to it — stub the +// turn to reject so the loop runs harmlessly (no real @cline/sdk call, and no +// extra machine message lands in the sidecar this suite's assertions read). +function stubReplyLoop(api: CowritingApi): void { + api.threadController.setTurnRunnerForTest(async () => { + throw new Error("F2 suite: reply-loop turn stubbed off (not under test here)"); + }); +} + // Reaches the controller's internal docs map for reply/resolve handlers (which // expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and // assert Comments-controller state" fallback. @@ -67,6 +78,7 @@ suite("F2 region-anchored threads (host E2E)", () => { test("create on selection persists a thread sidecar", async () => { const doc = await openSample(); const api = await getApi(); + stubReplyLoop(api); const start = doc.getText().indexOf("quick brown fox"); const editor = vscode.window.activeTextEditor!; editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length)); @@ -89,6 +101,7 @@ suite("F2 region-anchored threads (host E2E)", () => { test("reply appends and resolve flips status, both persisted", async () => { const api = await getApi(); + stubReplyLoop(api); const art0 = readSidecar(); const threadId = art0.threads[0].id;