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 { GlobalSidecarStore } from "./globalSidecarStore"; import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { TrackChangesPreviewController } from "./trackChangesPreview"; import { LiveProgressUi } from "./liveProgressUi"; import { InlineAskController } from "./inlineAsk"; import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; export interface CowritingApi { threadController: ThreadController; attributionController: AttributionController; proposalController: ProposalController; versionGuard: VersionGuard; diffViewController: DiffViewController; trackChangesPreviewController: TrackChangesPreviewController; sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; inlineAsk: InlineAskController; editorProposalController: EditorProposalController; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { // --- POC command (Feature #2), unchanged --- const output = vscode.window.createOutputChannel(CHANNEL_NAME); context.subscriptions.push(output); // #60: shared live-progress UI (notification activity line + "Cowriting: Claude" // OutputChannel) for both Ask-Claude entry points. const liveProgressUi = new LiveProgressUi(); context.subscriptions.push(liveProgressUi); // The inline "Ask Claude to Edit" prompt — rendered at the selection/cursor via // the Comments API rather than the top-center QuickInput (see inlineAsk.ts). // Shared by both Ask-Claude entry points (editSelection + the preview's askClaude). const inlineAsk = new InlineAskController(context.subscriptions); 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: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT --- // The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is // the single review surface); only the baseline store survives, consumed by // F7/F10. It tracks a baseline for ANY diffable doc (file or untitled), so it // is constructed regardless of whether a folder is open. Baseline lives in VS // Code's per-extension GLOBAL storage (always present), never the repo // (INV-19). The machine-landing advance is wired below. 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); // F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage // home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed // workspace-independently; the router falls back to it for any non-in-folder // doc. When globalStorageUri is unavailable, file writes throw (authoring // degrades to errors-on-write, the F6-equivalent edge) but untitled in-memory // still works — PUC-5. const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? ""); // --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) --- // The router routes per-document: in-workspace file: → the committable repo // `.threads/` sidecar (CoauthorStore, INV-2); out-of-folder file: + untitled: // → the global-storage sidecar (GlobalSidecarStore). Constructed even with NO // folder open (everything then routes global) — the F6 #19 precedent, now // extended to authoring (F2 threads, F3 attribution, F4 propose/accept). const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const coauthorStore = root ? new CoauthorStore(root) : null; const sidecarRouter = new SidecarRouter(coauthorStore, globalSidecarStore, 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(sidecarRouter); const threadController = new ThreadController(sidecarRouter, root, versionGuard); context.subscriptions.push(threadController); // --- F3: live attribution (Feature #6) --- const attributionController = new AttributionController(sidecarRouter, root, versionGuard); context.subscriptions.push(attributionController); // --- F4: propose/accept (Feature #12) — constructed before the preview so F10 // can route ✓/✗ through it --- const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); context.subscriptions.push(proposalController); // --- 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) and proposals (routes // F4 accept/reject from the webview ✓/✗). const trackChangesPreviewController = new TrackChangesPreviewController( diffViewController, context.extensionUri, attributionController, proposalController, liveProgressUi, inlineAsk, ); 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 // (also reachable from the preview toolbar's "Accept all" button). Reuses the // batched F4 seam + reports applied-vs-skipped. context.subscriptions.push( vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => { const doc = vscode.window.activeTextEditor?.document; if (!doc || doc.languageId !== "markdown") { void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals."); return; } await trackChangesPreviewController.acceptAll(doc); }), ); // --- F6 machine-landing wiring — now for ANY authorable doc --- // The seam's single machine-landing signal advances the F6 baseline (INV-18); // the seam can now fire on out-of-folder files too, so wire it unconditionally. context.subscriptions.push( attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)), ); // One SHARED sidecar watcher for both controllers; self-writes are suppressed // centrally in the repo store (only repo `.threads/` sidecars are watched — // global artifacts live outside the workspace). Harmless when no folder is open. const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); const onSidecar = (uri: vscode.Uri) => { if (sidecarRouter.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; // 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; const selection = editor.selection; // non-empty (selectionRejection guaranteed it) // The instruction prompt opens INLINE at the selection (inlineAsk), not the // top-center QuickInput — anchored to the text Claude will edit. const instruction = await inlineAsk.prompt(document.uri, selection); if (!instruction) return; 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…", 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}`); } }), ); // The single user-facing "Ask Claude to Edit" gesture (one command, one // keybinding, one menu entry). It routes to the selection flow (editSelection) // or the whole-document flow (editDocument) by selection/context — see // routeEdit. The two underlying commands stay registered for the host E2E // harness and the internal seams, but are hidden from the palette. context.subscriptions.push( vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => { const editor = vscode.window.activeTextEditor; const route = routeEdit({ hasUri: !!uri, uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(), hasActiveEditor: !!editor, selectionEmpty: editor?.selection.isEmpty ?? true, }); if (route === "selection") { await vscode.commands.executeCommand("cowriting.editSelection"); } else { await vscode.commands.executeCommand("cowriting.editDocument", uri); } }), ); // Render threads + attributions for already-open editors, and on future opens. const renderIfOpen = (doc: vscode.TextDocument) => { if (isAuthorable(doc.uri.scheme)) { 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, sidecarRouter, liveProgressUi, inlineAsk, editorProposalController, }; } export function deactivate(): void { // Disposables registered on the context handle cleanup. }