Files
vscode-cowriting-plugin/src/extension.ts
T
Ben Stull c0bae8c774 Fix #8: register warning stubs when no workspace folder; F5 opens the repo. Add selection context menus (#9)
- extension.ts: with no folder open, every contributed coauthoring command now
  registers a warning stub ('open a folder first') instead of being absent —
  the palette no longer errors 'command not found'. Opening a folder reloads
  the window and re-activates with a real root.
- .vscode/launch.json: the EDH now opens the repo as its workspace, so plain
  F5 lands in a working state.
- package.json: editor/context menu entries for 'Ask Claude to Edit
  Selection' and 'Add Coauthoring Thread on Selection', shown only when text
  is highlighted in a file-scheme editor (editorHasSelection).
- E2E: second no-workspace pass (suite-no-workspace) pins the #8 regression.

Fixes #8. Fixes #9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:26:59 -07:00

186 lines
7.6 KiB
TypeScript

import * as vscode from "vscode";
import { fetchSdkSummary } from "./cline";
import { CoauthorStore } from "./store";
import { ThreadController } from "./threadController";
import { AttributionController } from "./attributionController";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
export interface CowritingApi {
threadController: ThreadController;
attributionController: AttributionController;
}
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}`);
}
}),
);
// --- 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",
]) {
context.subscriptions.push(vscode.commands.registerCommand(command, stub));
}
return undefined;
}
const store = new CoauthorStore(root);
const threadController = new ThreadController(store, root);
context.subscriptions.push(threadController);
// --- F3: live attribution (Feature #6) ---
const attributionController = new AttributionController(store, root);
context.subscriptions.push(attributionController);
// 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);
};
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 });
},
),
);
// F3 SLICE-5: the live turn (PUC-2) — selection + instruction → one
// claude-code SDK turn (liveTurn.ts, INV-8) → the applyAgentEdit seam (INV-9).
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 expectedVersion = document.version;
const selectedText = document.getText(selection);
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 applied.",
);
return;
}
const ok = await attributionController.applyAgentEdit(
document,
new vscode.Range(selection.start, selection.end),
turn.replacement,
{
kind: "agent",
id: "claude",
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
},
{ expectedVersion, turnId },
);
if (!ok) {
void vscode.window.showWarningMessage(
"Cowriting: the edit could not be applied (the document changed during the turn, or the editor rejected the edit) — nothing was changed.",
);
}
},
);
} 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);
}
};
vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
return { threadController, attributionController };
}
export function deactivate(): void {
// Disposables registered on the context handle cleanup.
}