feat(editor): EditorProposalController — optimistic apply + decorations + CodeLens (#64)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* EditorProposalController — F12/#64 editor surface (spec coauthoring-inline-editor-diff
|
||||||
|
* §3.5). Reverses INV-32 for pending proposals: on `onDidChangeProposals` it
|
||||||
|
* (1) optimistically applies any not-yet-applied proposal into the active editor's
|
||||||
|
* buffer (ProposalController.optimisticApply, INV-48), (2) decorates each applied
|
||||||
|
* proposal — insertion tint over the proposed text + a non-editable struck-red hint
|
||||||
|
* for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens
|
||||||
|
* `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all).
|
||||||
|
* Owns no proposal STATE — it is a view over ProposalController (which stays the
|
||||||
|
* pure F4 owner). A document with no pending proposals shows nothing (INV-32's
|
||||||
|
* spirit when nothing is pending).
|
||||||
|
*/
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import type { ProposalController } from "./proposalController";
|
||||||
|
import { decorationPlan } from "./trackChangesModel";
|
||||||
|
import { isAuthorable } from "./workspacePath";
|
||||||
|
|
||||||
|
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
||||||
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
private readonly insertionDeco = vscode.window.createTextEditorDecorationType({
|
||||||
|
backgroundColor: new vscode.ThemeColor("diffEditor.insertedTextBackground"),
|
||||||
|
});
|
||||||
|
private readonly deletionDeco = vscode.window.createTextEditorDecorationType({
|
||||||
|
// a non-editable struck-red hint injected AFTER the insertion (INV-52)
|
||||||
|
after: { color: new vscode.ThemeColor("gitDecoration.deletedResourceForeground") },
|
||||||
|
textDecoration: "none",
|
||||||
|
});
|
||||||
|
private readonly lensEmitter = new vscode.EventEmitter<void>();
|
||||||
|
readonly onDidChangeCodeLenses = this.lensEmitter.event;
|
||||||
|
/** Pending debounce timers — one per URI — coalesce rapid-fire propose events
|
||||||
|
* (e.g. runEditAndPropose's N sequential propose() calls) into a single
|
||||||
|
* optimistic-apply pass that runs after ALL proposals are created. Without this,
|
||||||
|
* each propose() fires onDidChangeProposals synchronously and the controller's
|
||||||
|
* optimisticApply runs concurrently with the still-in-progress propose loop,
|
||||||
|
* causing "file changed in the meantime" workspace-edit conflicts. */
|
||||||
|
private readonly pendingApply = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
constructor(private readonly proposals: ProposalController) {
|
||||||
|
this.disposables.push(
|
||||||
|
this.insertionDeco, this.deletionDeco, this.lensEmitter,
|
||||||
|
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
||||||
|
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
||||||
|
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
|
||||||
|
// the four QuickPick-backed menu commands
|
||||||
|
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
|
||||||
|
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debounce-schedule an optimistic-apply pass for the given URI. Multiple rapid
|
||||||
|
* onDidChangeProposals events (from a single runEditAndPropose batch) collapse
|
||||||
|
* into one pass that runs after the batch completes. */
|
||||||
|
private scheduleApply(uri: string): void {
|
||||||
|
const prev = this.pendingApply.get(uri);
|
||||||
|
if (prev !== undefined) clearTimeout(prev);
|
||||||
|
this.pendingApply.set(
|
||||||
|
uri,
|
||||||
|
setTimeout(() => {
|
||||||
|
this.pendingApply.delete(uri);
|
||||||
|
void this.onProposalsChanged(uri);
|
||||||
|
}, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */
|
||||||
|
private async onProposalsChanged(uri: string): Promise<void> {
|
||||||
|
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||||
|
if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return;
|
||||||
|
const key = this.proposals.keyFor(doc);
|
||||||
|
for (const v of this.proposals.listProposals(doc)) {
|
||||||
|
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
|
||||||
|
await this.proposals.optimisticApply(doc, v.id); // fires onDidChangeProposals again; guarded by isApplied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||||
|
if (ed) this.renderEditor(ed);
|
||||||
|
this.lensEmitter.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decorate the editor for every applied proposal on its document (INV-52). */
|
||||||
|
private renderEditor(editor: vscode.TextEditor): void {
|
||||||
|
const doc = editor.document;
|
||||||
|
if (doc.languageId !== "markdown") {
|
||||||
|
editor.setDecorations(this.insertionDeco, []);
|
||||||
|
editor.setDecorations(this.deletionDeco, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = this.proposals.keyFor(doc);
|
||||||
|
const insertions: vscode.Range[] = [];
|
||||||
|
const deletions: vscode.DecorationOptions[] = [];
|
||||||
|
for (const v of this.proposals.listProposals(doc)) {
|
||||||
|
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
|
||||||
|
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
|
||||||
|
for (const ins of plan.insertions) {
|
||||||
|
insertions.push(new vscode.Range(doc.positionAt(ins.start), doc.positionAt(ins.end)));
|
||||||
|
}
|
||||||
|
for (const del of plan.deletions) {
|
||||||
|
deletions.push({
|
||||||
|
range: new vscode.Range(doc.positionAt(del.at), doc.positionAt(del.at)),
|
||||||
|
renderOptions: { after: { contentText: ` ${del.text} `, textDecoration: "line-through" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor.setDecorations(this.insertionDeco, insertions);
|
||||||
|
editor.setDecorations(this.deletionDeco, deletions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
|
||||||
|
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
|
||||||
|
if (document.languageId !== "markdown") return [];
|
||||||
|
const key = this.proposals.keyFor(document);
|
||||||
|
const lenses: vscode.CodeLens[] = [];
|
||||||
|
for (const v of this.proposals.listProposals(document)) {
|
||||||
|
if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue;
|
||||||
|
const pos = document.positionAt(v.anchorStart);
|
||||||
|
const line = new vscode.Range(pos.line, 0, pos.line, 0);
|
||||||
|
lenses.push(
|
||||||
|
new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }),
|
||||||
|
new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", arguments: [v.id] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dropdown: this-proposal vs all-proposals, then dispatch. */
|
||||||
|
private async menu(kind: "accept" | "reject", id?: string): Promise<void> {
|
||||||
|
const doc = vscode.window.activeTextEditor?.document;
|
||||||
|
if (!doc || !id) return;
|
||||||
|
const key = this.proposals.keyFor(doc);
|
||||||
|
const verb = kind === "accept" ? "Accept" : "Reject";
|
||||||
|
const pick = await vscode.window.showQuickPick(
|
||||||
|
[`${verb} this proposal`, `${verb} ALL proposals`],
|
||||||
|
{ placeHolder: `${verb} Claude's proposal` },
|
||||||
|
);
|
||||||
|
if (!pick) return;
|
||||||
|
const all = pick.includes("ALL");
|
||||||
|
if (kind === "accept") {
|
||||||
|
if (all) await this.proposals.acceptAllProposals(doc);
|
||||||
|
else await this.proposals.finalizeInPlace(key, id);
|
||||||
|
} else {
|
||||||
|
if (all) await this.proposals.rejectAll(doc);
|
||||||
|
else await this.proposals.revertInPlace(key, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const t of this.pendingApply.values()) clearTimeout(t);
|
||||||
|
this.pendingApply.clear();
|
||||||
|
for (const d of this.disposables) d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ 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 { InlineAskController } from "./inlineAsk";
|
||||||
|
import { EditorProposalController } from "./editorProposalController";
|
||||||
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
@@ -27,6 +28,7 @@ export interface CowritingApi {
|
|||||||
sidecarRouter: SidecarRouter;
|
sidecarRouter: SidecarRouter;
|
||||||
liveProgressUi: LiveProgressUi;
|
liveProgressUi: LiveProgressUi;
|
||||||
inlineAsk: InlineAskController;
|
inlineAsk: InlineAskController;
|
||||||
|
editorProposalController: EditorProposalController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||||
@@ -123,6 +125,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
);
|
);
|
||||||
context.subscriptions.push(trackChangesPreviewController);
|
context.subscriptions.push(trackChangesPreviewController);
|
||||||
|
|
||||||
|
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
|
||||||
|
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
||||||
|
const editorProposalController = new EditorProposalController(proposalController);
|
||||||
|
context.subscriptions.push(editorProposalController);
|
||||||
|
|
||||||
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
// #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
|
// (also reachable from the preview toolbar's "Accept all" button). Reuses the
|
||||||
// batched F4 seam + reports applied-vs-skipped.
|
// batched F4 seam + reports applied-vs-skipped.
|
||||||
@@ -345,6 +352,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
sidecarRouter,
|
sidecarRouter,
|
||||||
liveProgressUi,
|
liveProgressUi,
|
||||||
inlineAsk,
|
inlineAsk,
|
||||||
|
editorProposalController,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
anchorStart: resolved === "orphaned" ? null : resolved.start,
|
anchorStart: resolved === "orphaned" ? null : resolved.start,
|
||||||
anchorEnd: resolved === "orphaned" ? null : resolved.end,
|
anchorEnd: resolved === "orphaned" ? null : resolved.end,
|
||||||
replaced: fp?.text ?? "",
|
replaced: p.original ?? fp?.text ?? "",
|
||||||
replacement: p.replacement,
|
replacement: p.replacement,
|
||||||
original: p.original,
|
original: p.original,
|
||||||
};
|
};
|
||||||
@@ -143,7 +143,29 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
|
|
||||||
/** Accept by id — F12: finalize the already-applied text in place (INV-51). */
|
/** Accept by id — F12: finalize the already-applied text in place (INV-51). */
|
||||||
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
|
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
|
||||||
if (this.isApplied(docPath, proposalId)) return this.finalizeInPlace(docPath, proposalId);
|
if (this.isApplied(docPath, proposalId)) {
|
||||||
|
// INV-11 (applied path): if an external write mangled the optimistically-applied
|
||||||
|
// text so the fingerprint no longer resolves, refuse finalize — same guard as the
|
||||||
|
// legacy accept path. Direct finalizeInPlace calls (CodeLens Accept gesture where
|
||||||
|
// the user may have edited inside the applied span) bypass this check intentionally.
|
||||||
|
const hit = this.byId(docPath, proposalId);
|
||||||
|
if (hit) {
|
||||||
|
const document = this.openDoc(hit.state);
|
||||||
|
if (document) {
|
||||||
|
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
|
||||||
|
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||||
|
if (resolved === "orphaned") {
|
||||||
|
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).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.finalizeInPlace(docPath, proposalId);
|
||||||
|
}
|
||||||
// Fallback: a proposal that was never optimistically applied (e.g. orphaned at
|
// Fallback: a proposal that was never optimistically applied (e.g. orphaned at
|
||||||
// apply time) keeps the legacy seam-apply accept.
|
// apply time) keeps the legacy seam-apply accept.
|
||||||
const hit = this.byId(docPath, proposalId);
|
const hit = this.byId(docPath, proposalId);
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ suite("F10 interactive review (host E2E — preview is the single review surface
|
|||||||
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
|
const pIdx = html.indexOf(`data-proposal-id="${id}"`);
|
||||||
const t2Idx = html.indexOf("second claude target");
|
const t2Idx = html.indexOf("second claude target");
|
||||||
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
|
assert.ok(t2Idx >= 0 && pIdx < t2Idx, "the proposal renders in place, before the following block (#31)");
|
||||||
// INV-10: proposing never touches the document.
|
// F12 (INV-48): EditorProposalController auto-applies the proposal into the buffer;
|
||||||
assert.ok(doc.getText().includes(T1), "document unchanged by propose");
|
// the replacement text is now in the document, proposal is still pending.
|
||||||
|
assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
|
test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => {
|
||||||
@@ -131,17 +132,18 @@ suite("F10 interactive review (host E2E — preview is the single review surface
|
|||||||
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
|
assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reject → the proposal vanishes and the document is untouched (PUC-5)", async () => {
|
test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => {
|
||||||
const { doc, key } = await reopen(DOC_REL);
|
const { doc, key } = await reopen(DOC_REL);
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const before = doc.getText();
|
const before = doc.getText();
|
||||||
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
|
const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2");
|
||||||
await settle();
|
await settle();
|
||||||
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
|
assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending");
|
||||||
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject");
|
// F12: use rejectByIdInPlace to revert the optimistically-applied buffer edit.
|
||||||
|
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id2), "rejectByIdInPlace reverts + removes");
|
||||||
await settle();
|
await settle();
|
||||||
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
|
assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone");
|
||||||
assert.strictEqual(doc.getText(), before, "document untouched by reject");
|
assert.strictEqual(doc.getText(), before, "document reverted to pre-propose state");
|
||||||
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
const html = api.trackChangesPreviewController.renderHtmlFor(key);
|
||||||
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
|
assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,8 +99,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
|||||||
"The quick RED fox jumps over the lazy CAT.",
|
"The quick RED fox jumps over the lazy CAT.",
|
||||||
"the block proposal carries the whole rewritten paragraph",
|
"the block proposal carries the whole rewritten paragraph",
|
||||||
);
|
);
|
||||||
// INV-10: proposing never mutates the document.
|
// F12 (INV-48): EditorProposalController optimistically applies the proposed text,
|
||||||
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
|
// so after settle the buffer has the replacement. The proposal is still pending.
|
||||||
|
assert.ok(doc.getText().includes("RED fox") && doc.getText().includes("lazy CAT"), "F12 optimistic-apply: proposed text in buffer");
|
||||||
void key;
|
void key;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,7 +130,9 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
|||||||
assert.ok(view, "the proposal is live");
|
assert.ok(view, "the proposal is live");
|
||||||
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
|
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
|
||||||
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range");
|
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range");
|
||||||
assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)");
|
// F12 (INV-48): the EditorProposalController optimistically applies, so the buffer
|
||||||
|
// now has the replacement. `view.replaced` still records the original target text.
|
||||||
|
assert.ok(doc.getText().includes("The REWRITTEN paragraph from Claude."), "F12 optimistic-apply: proposed text in buffer");
|
||||||
void key;
|
void key;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -116,3 +116,39 @@ suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
|
|||||||
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
|
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
|
||||||
|
test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => {
|
||||||
|
const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n");
|
||||||
|
const api = await getApi();
|
||||||
|
const ctl = api.trackChangesPreviewController;
|
||||||
|
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nThe red fox runs.\n", model: "m", sessionId: "s" }));
|
||||||
|
await ctl.runEditAndPropose(doc, { kind: "document" }, "recolor the fox");
|
||||||
|
await settle(); await settle();
|
||||||
|
assert.ok(doc.getText().includes("The red fox runs."), "optimistically applied into the buffer");
|
||||||
|
const v = api.proposalController.listProposals(doc)[0];
|
||||||
|
assert.strictEqual(v.original, "The brown fox runs.", "original captured for the deletion hint/revert");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editing the inserted text then finalizing keeps the human edit", async () => {
|
||||||
|
const { doc } = await freshDoc("docs/f12-edit-keep.md", "# E\n\nalpha word here.\n");
|
||||||
|
const api = await getApi();
|
||||||
|
const ctl = api.trackChangesPreviewController;
|
||||||
|
ctl.setEditTurnForTest(async () => ({ replacement: "# E\n\nALPHA word here.\n", model: "m", sessionId: "s" }));
|
||||||
|
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||||
|
await settle(); await settle();
|
||||||
|
// human tweaks the inserted text
|
||||||
|
const at = doc.getText().indexOf("ALPHA");
|
||||||
|
const we = new vscode.WorkspaceEdit();
|
||||||
|
we.replace(doc.uri, new vscode.Range(doc.positionAt(at), doc.positionAt(at + 5)), "ALPHA!");
|
||||||
|
await vscode.workspace.applyEdit(we);
|
||||||
|
await settle();
|
||||||
|
// keyFor(doc) gives the repo-relative path that finalizeInPlace uses as its key;
|
||||||
|
// the `key` from freshDoc is the URI string, which would not match for in-workspace docs.
|
||||||
|
const docKey = api.proposalController.keyFor(doc);
|
||||||
|
await api.proposalController.finalizeInPlace(docKey, ids[0]);
|
||||||
|
await settle();
|
||||||
|
assert.ok(doc.getText().includes("ALPHA! word here."), "human edit preserved on accept");
|
||||||
|
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "cleared");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -98,8 +98,10 @@ suite("F12 SLICE-1 — Ask-Claude reach (#42, INV-38)", () => {
|
|||||||
0,
|
0,
|
||||||
"active doc A was NOT edited — editDocument honored the tab URI",
|
"active doc A was NOT edited — editDocument honored the tab URI",
|
||||||
);
|
);
|
||||||
// INV-10: proposing never mutates the document.
|
// F12 (INV-48): EditorProposalController optimistically applies proposals into the
|
||||||
assert.ok(b.doc.getText().includes("tab target paragraph"), "tab doc unchanged by propose");
|
// buffer, so the proposed text is now in the tab doc B. Confirm one of the proposal
|
||||||
|
// texts is present (the tab doc was edited, not A).
|
||||||
|
assert.ok(b.doc.getText().includes("REWRITTEN"), "F12 optimistic-apply: proposed text in tab doc B");
|
||||||
});
|
});
|
||||||
|
|
||||||
// No URI arg (palette / keybinding) → fall back to the active editor.
|
// No URI arg (palette / keybinding) → fall back to the active editor.
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ suite("#60 live turn progress (additive + cancel)", () => {
|
|||||||
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
|
assert.strictEqual(ids.length, 1, "one changed block → one proposal, regardless of progress events");
|
||||||
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
|
const view = api.proposalController.listProposals(doc).find((v) => v.id === ids[0]);
|
||||||
assert.ok(view, "the proposal is live");
|
assert.ok(view, "the proposal is live");
|
||||||
assert.ok(doc.getText().includes("Old paragraph."), "document unchanged by propose (INV-10)");
|
// F12 (INV-48): the EditorProposalController optimistically applies the proposed
|
||||||
|
// text into the buffer, so after settle the active-editor buffer shows the
|
||||||
|
// replacement. The proposal is still pending (not finalized) until Accept.
|
||||||
|
assert.ok(doc.getText().includes("New paragraph."), "F12 optimistic-apply: proposed text in buffer");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an aborted turn proposes nothing (INV-47)", async () => {
|
test("an aborted turn proposes nothing (INV-47)", async () => {
|
||||||
|
|||||||
@@ -63,13 +63,14 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
const TARGET = "The propose target sentence lives here.";
|
const TARGET = "The propose target sentence lives here.";
|
||||||
const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence.";
|
const REPLACEMENT = "PROPOSED-BY-CLAUDE replacement sentence.";
|
||||||
|
|
||||||
test("propose records + renders a pending proposal and does NOT touch the document (INV-10)", async () => {
|
test("propose records a pending proposal; F12 optimistically applies it (INV-48, reverses INV-10)", async () => {
|
||||||
const doc = await openDoc();
|
const doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const before = doc.getText();
|
|
||||||
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
|
await proposeViaCommand(doc, TARGET, REPLACEMENT, "turn-p1");
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(doc.getText(), before, "document text unchanged (INV-10)");
|
// F12 (INV-48): EditorProposalController auto-applies the proposed text into the
|
||||||
|
// buffer, so after settle the buffer has the replacement text (INV-10 is reversed).
|
||||||
|
assert.ok(doc.getText().includes(REPLACEMENT), "F12 optimistic-apply: replacement in buffer after propose");
|
||||||
const rendered = api.proposalController.getRendered(DOC_REL);
|
const rendered = api.proposalController.getRendered(DOC_REL);
|
||||||
assert.strictEqual(rendered.length, 1);
|
assert.strictEqual(rendered.length, 1);
|
||||||
assert.strictEqual(rendered[0].pending, true);
|
assert.strictEqual(rendered[0].pending, true);
|
||||||
@@ -77,7 +78,8 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)");
|
assert.strictEqual(rendered[0].canReply, false, "proposals are decide-only — no dead reply input (INV-12)");
|
||||||
const art = readSidecar();
|
const art = readSidecar();
|
||||||
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time");
|
assert.strictEqual(art.proposals.length, 1, "proposal persisted at propose time");
|
||||||
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, TARGET);
|
// After optimistic apply, the fingerprint re-anchors to the replacement text.
|
||||||
|
assert.strictEqual(art.anchors[art.proposals[0].anchorId].fingerprint.text, REPLACEMENT);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
|
test("accept applies via the seam: text replaced, Claude-attributed, proposal removed (INV-9/11/13)", async () => {
|
||||||
@@ -97,15 +99,18 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
|
assert.strictEqual(readSidecar().proposals.length, 0, "removed from the sidecar");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reject leaves the document untouched and removes the proposal (PUC-3)", async () => {
|
test("reject reverts the optimistically-applied text and removes the proposal (PUC-3, F12-INV-48)", async () => {
|
||||||
const doc = await openDoc();
|
const doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const before = doc.getText();
|
const before = doc.getText();
|
||||||
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
|
const id = await proposeViaCommand(doc, "A second target for coexistence checks.", "WOULD-BE replacement.", "turn-p2");
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
|
// F12: optimistic apply has put "WOULD-BE replacement." in the buffer.
|
||||||
|
assert.ok(doc.getText().includes("WOULD-BE replacement."), "F12: optimistically applied");
|
||||||
|
// rejectByIdInPlace reverts the buffer to the original (PUC-3, INV-51).
|
||||||
|
assert.ok(await api.proposalController.rejectByIdInPlace(DOC_REL, id), "rejectByIdInPlace removes + reverts");
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(doc.getText(), before, "document untouched");
|
assert.strictEqual(doc.getText(), before, "document restored to pre-propose state");
|
||||||
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
||||||
assert.strictEqual(readSidecar().proposals.length, 0);
|
assert.strictEqual(readSidecar().proposals.length, 0);
|
||||||
});
|
});
|
||||||
@@ -114,25 +119,35 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
let doc = await openDoc();
|
let doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const anchor = "A stable closing paragraph.";
|
const anchor = "A stable closing paragraph.";
|
||||||
await proposeViaCommand(doc, anchor, "A PROPOSED closing paragraph.", "turn-p3");
|
const appliedText = "A PROPOSED closing paragraph.";
|
||||||
|
await proposeViaCommand(doc, anchor, appliedText, "turn-p3");
|
||||||
await settle();
|
await settle();
|
||||||
|
// F12: optimistic apply has put the proposed text in the buffer (anchor → appliedText).
|
||||||
|
assert.ok(doc.getText().includes(appliedText), "F12: optimistically applied before reload");
|
||||||
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||||
|
// doc.getText() now has appliedText; the reload prepends a line.
|
||||||
doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
|
doc = await externalWriteAndReload(uri, "PREPENDED LINE\n\n" + doc.getText());
|
||||||
await settle();
|
await settle();
|
||||||
api.proposalController.renderAll(doc);
|
api.proposalController.renderAll(doc);
|
||||||
const rendered = api.proposalController.getRendered(DOC_REL);
|
const rendered = api.proposalController.getRendered(DOC_REL);
|
||||||
assert.strictEqual(rendered.length, 1, "proposal survived reload");
|
assert.strictEqual(rendered.length, 1, "proposal survived reload");
|
||||||
assert.strictEqual(rendered[0].pending, true, "still decidable");
|
assert.strictEqual(rendered[0].pending, true, "still decidable");
|
||||||
const moved = doc.getText().indexOf(anchor);
|
// After F12 re-anchor, the fingerprint tracks the appliedText, not the original anchor.
|
||||||
assert.strictEqual(rendered[0].range.start, moved, "re-anchored after the move");
|
const moved = doc.getText().indexOf(appliedText);
|
||||||
|
assert.ok(moved >= 0, "applied text present in the reloaded document");
|
||||||
|
assert.strictEqual(rendered[0].range.start, moved, "re-anchored to appliedText after the move");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => {
|
test("editing the target text makes the proposal stale: flagged, accept refused, doc untouched (INV-11)", async () => {
|
||||||
let doc = await openDoc();
|
let doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const id = api.proposalController.getRendered(DOC_REL)[0].id;
|
const id = api.proposalController.getRendered(DOC_REL)[0].id;
|
||||||
const mangled = doc.getText().replace("A stable closing paragraph.", "A reworded closing paragraph.");
|
// After F12 optimistic apply the fingerprint tracks the APPLIED text ("A PROPOSED
|
||||||
doc = await externalWriteAndReload(vscode.Uri.file(path.join(WS, DOC_REL)), mangled);
|
// closing paragraph."), not the original. Mangle that text to make the proposal stale.
|
||||||
|
const preMangled = doc.getText();
|
||||||
|
const mangled = preMangled.replace("A PROPOSED closing paragraph.", "A reworded closing paragraph.");
|
||||||
|
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||||
|
doc = await externalWriteAndReload(uri, mangled);
|
||||||
await settle();
|
await settle();
|
||||||
api.proposalController.renderAll(doc);
|
api.proposalController.renderAll(doc);
|
||||||
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
|
assert.strictEqual(api.proposalController.getStaleCount(DOC_REL), 1, "flagged stale");
|
||||||
@@ -143,22 +158,30 @@ suite("F4 propose/accept (host E2E — programmatic ingress, no LLM)", () => {
|
|||||||
assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
|
assert.strictEqual(readSidecar().proposals.length, 1, "proposal still pending (recoverable)");
|
||||||
// discard the husk so later tests see a clean sidecar
|
// discard the husk so later tests see a clean sidecar
|
||||||
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
|
assert.strictEqual(api.proposalController.rejectById(DOC_REL, id), true);
|
||||||
|
// Restore the file so subsequent tests can find their fixture targets:
|
||||||
|
// replace the optimistically-applied text back to the original fixture text.
|
||||||
|
const restored = preMangled.replace("A PROPOSED closing paragraph.", "A stable closing paragraph.");
|
||||||
|
doc = await externalWriteAndReload(uri, restored);
|
||||||
|
await settle();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => {
|
test("multiple pending proposals coexist and are decidable out of order (PUC-5)", async () => {
|
||||||
const doc = await openDoc();
|
const doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
|
// Clean up any proposals left over from prior tests (F12: proposals persist in state).
|
||||||
const id2 = await proposeViaCommand(doc, "PROPOSED-BY-CLAUDE replacement sentence.", "A REPLACED-AGAIN sentence.", "turn-p5");
|
await api.proposalController.rejectAll(doc);
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2);
|
const id1 = await proposeViaCommand(doc, "A stable opening paragraph.", "An ACCEPTED opening paragraph.", "turn-p4");
|
||||||
|
const id2 = await proposeViaCommand(doc, "A stable closing paragraph.", "A REPLACED closing paragraph.", "turn-p5");
|
||||||
|
await settle();
|
||||||
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 2, "two new proposals");
|
||||||
// decide the SECOND first, then the first — order independence
|
// decide the SECOND first, then the first — order independence
|
||||||
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
|
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id2), true);
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
|
assert.strictEqual(await api.proposalController.acceptById(DOC_REL, id1), true);
|
||||||
await settle();
|
await settle();
|
||||||
assert.ok(doc.getText().includes("An ACCEPTED opening paragraph."));
|
assert.ok(doc.getText().includes("An ACCEPTED opening paragraph."));
|
||||||
assert.ok(doc.getText().includes("A REPLACED-AGAIN sentence."));
|
assert.ok(doc.getText().includes("A REPLACED closing paragraph."));
|
||||||
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
assert.strictEqual(api.proposalController.getRendered(DOC_REL).length, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user