Files
vscode-cowriting-plugin/src/extension.ts
T
2026-06-11 08:46:49 -07:00

287 lines
12 KiB
TypeScript

import * as vscode from "vscode";
import { fetchSdkSummary } from "./cline";
import { CoauthorStore } from "./store";
import { ThreadController } from "./threadController";
import { AttributionController } from "./attributionController";
import { ProposalController } from "./proposalController";
import { buildFingerprint } from "./anchorer";
import { VersionGuard } from "./versionGuard";
import { BaselineStore } from "./baselineStore";
import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
export interface CowritingApi {
threadController: ThreadController;
attributionController: AttributionController;
proposalController: ProposalController;
versionGuard: VersionGuard;
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
// --- POC command (Feature #2), unchanged ---
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
context.subscriptions.push(output);
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
try {
const summary = await fetchSdkSummary();
output.clear();
output.appendLine(`@cline/sdk version: ${summary.version}`);
output.appendLine(`Builtin tools (${summary.tools.length}):`);
for (const tool of summary.tools) output.appendLine(`${tool.id}${tool.description}`);
output.show(true);
await vscode.window.showInformationMessage(
`Cline SDK ${summary.version} loaded — ${summary.tools.length} builtin tools. See the "${CHANNEL_NAME}" output channel.`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
output.appendLine(`Failed to drive @cline/sdk: ${message}`);
output.show(true);
await vscode.window.showErrorMessage(`Cowriting: failed to load @cline/sdk — ${message}`);
}
}),
);
// --- F6: diff-view toggle (Features #17 + #19) — workspace-INDEPENDENT ---
// F6 works on ANY diffable doc (file or untitled), so it is constructed
// regardless of whether a folder is open, and its commands are always live
// (never stubbed). Baseline lives in VS Code's per-extension GLOBAL storage
// (always present), never the repo (INV-19). The machine-landing advance is
// wired in the with-root branch below (landings only occur on workspace files
// through the seam). The controller self-wires baseline capture on open.
const baselineStorageDir = context.globalStorageUri?.fsPath;
const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null;
const diffViewController = new DiffViewController(baselineStore);
context.subscriptions.push(diffViewController);
// --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT ---
// Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it
// is constructed regardless of an open folder and its command is always live.
// It reuses the F6 baseline (INV-20) and adds no persistence.
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
);
context.subscriptions.push(trackChangesPreviewController);
// --- F2: region-anchored threads (Feature #4) ---
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!root) {
// No folder open → nothing to anchor against, but every contributed
// command must still exist: leave them unregistered and the palette
// errors with "command not found" (#8). Register warning stubs instead;
// opening a folder reloads the window, re-running activate with a root.
const stub = () =>
void vscode.window.showWarningMessage(
"Cowriting: open a folder first — coauthoring anchors threads and attribution to workspace files.",
);
for (const command of [
"cowriting.createThread",
"cowriting.reply",
"cowriting.resolveThread",
"cowriting.reopenThread",
"cowriting.editSelection",
"cowriting.toggleAttribution",
"cowriting.applyAgentEdit",
"cowriting.acceptProposal",
"cowriting.rejectProposal",
"cowriting.proposeAgentEdit",
]) {
context.subscriptions.push(vscode.commands.registerCommand(command, stub));
}
// F6 (toggleDiffView / pinDiffBaseline) is NOT stubbed here — it works
// without a folder (on untitled buffers and out-of-folder files); its real
// commands were registered above by DiffViewController.
return undefined;
}
const store = new CoauthorStore(root);
// 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(store);
const threadController = new ThreadController(store, root, versionGuard);
context.subscriptions.push(threadController);
// --- F3: live attribution (Feature #6) ---
const attributionController = new AttributionController(store, root, versionGuard);
context.subscriptions.push(attributionController);
// --- F4: propose/accept (Feature #12) ---
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
context.subscriptions.push(proposalController);
// --- F6 machine-landing wiring (with-root only) ---
// The seam's single machine-landing signal advances the baseline (INV-18).
// The seam fires only on workspace files, so this wiring belongs here; the
// DiffViewController itself was constructed above (workspace-independent).
context.subscriptions.push(
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
);
// One SHARED sidecar watcher for both controllers; self-writes are
// suppressed centrally in the store (the sidecar is co-owned).
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
const onSidecar = (uri: vscode.Uri) => {
if (store.consumeSelfWrite(uri.fsPath)) return;
threadController.handleExternalSidecarChange(uri);
attributionController.handleExternalSidecarChange(uri);
proposalController.handleExternalSidecarChange(uri);
};
watcher.onDidChange(onSidecar);
watcher.onDidCreate(onSidecar);
context.subscriptions.push(watcher);
// The seam as a command (INV-9): the only machine-edit ingress, hidden from
// the palette — for the host E2E harness and future SDK turn plumbing.
context.subscriptions.push(
vscode.commands.registerCommand(
"cowriting.applyAgentEdit",
(args: {
uri: string; start: number; end: number; newText: string;
model?: string; sessionId?: string; turnId?: string;
}) => {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri);
if (!doc) return Promise.resolve(false);
const range = new vscode.Range(doc.positionAt(args.start), doc.positionAt(args.end));
const provenance = {
kind: "agent" as const,
id: "claude",
agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" },
};
return attributionController.applyAgentEdit(doc, range, args.newText, provenance, { turnId: args.turnId });
},
),
);
// The propose ingress as a command (spec §6.4): records a pending proposal,
// NEVER touches the document (INV-10) — for the host E2E harness.
context.subscriptions.push(
vscode.commands.registerCommand(
"cowriting.proposeAgentEdit",
(args: {
uri: string; start: number; end: number; newText: string;
model?: string; sessionId?: string; turnId?: string; instruction?: string;
}) => {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri);
if (!doc) return Promise.resolve(undefined);
const fp = buildFingerprint(doc.getText(), { start: args.start, end: args.end });
const provenance = {
kind: "agent" as const,
id: "claude",
agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" },
};
return proposalController.propose(doc, fp, args.newText, provenance, {
turnId: args.turnId,
instruction: args.instruction,
});
},
),
);
// 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.
context.subscriptions.push(
vscode.commands.registerCommand("cowriting.editSelection", async () => {
const editor = vscode.window.activeTextEditor;
if (
!editor ||
editor.selection.isEmpty ||
editor.document.uri.scheme !== "file" ||
!editor.document.uri.fsPath.startsWith(root)
) {
void vscode.window.showWarningMessage("Cowriting: select some text in a workspace document first.");
return;
}
const instruction = await vscode.window.showInputBox({
prompt: "What should Claude do with the selection?",
placeHolder: "e.g. tighten this paragraph",
});
if (!instruction) return;
if (editor.selection.isEmpty) {
void vscode.window.showWarningMessage("Cowriting: select some text in a workspace document first.");
return;
}
const document = editor.document;
const selection = editor.selection;
const selectedText = document.getText(selection);
// Capture the anchor BEFORE the turn (spec §6.5 PUC-1): mid-turn edits
// can't skew it — the proposal renders wherever the target re-resolves.
const fp = buildFingerprint(document.getText(), {
start: document.offsetAt(selection.start),
end: document.offsetAt(selection.end),
});
const turnId = `turn-${Date.now().toString(36)}`;
try {
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
async () => {
const { runEditTurn } = await import("./liveTurn");
const turn = await runEditTurn(instruction, selectedText);
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}`);
}
}),
);
// Render threads + attributions for already-open editors, and on future opens.
const renderIfOpen = (doc: vscode.TextDocument) => {
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
threadController.renderAll(doc);
attributionController.loadAll(doc);
proposalController.renderAll(doc);
}
};
vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
return {
threadController,
attributionController,
proposalController,
versionGuard,
diffViewController,
trackChangesPreviewController,
};
}
export function deactivate(): void {
// Disposables registered on the context handle cleanup.
}