1ab3cc5348
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
287 lines
12 KiB
TypeScript
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.
|
|
}
|