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 { GitBaselineAdapter } from "./gitBaseline"; import { EditFlow } from "./editFlow"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, routeEdit } from "./workspacePath"; import { CoeditingRegistry } from "./coeditingRegistry"; import { ScmSurfaceController } from "./scmSurface"; import type MarkdownIt from "markdown-it"; import { cowritingMarkdownItPlugin, type AnnotationInputs } from "./previewAnnotations"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; export interface CowritingApi { threadController: ThreadController; attributionController: AttributionController; proposalController: ProposalController; versionGuard: VersionGuard; diffViewController: DiffViewController; editFlow: EditFlow; sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; editorProposalController: EditorProposalController; coeditingRegistry: CoeditingRegistry; scmSurfaceController: ScmSurfaceController; /** Task 7 test seam: the REAL production `inputsFor`, so E2E exercises the * actual registry/diffView/attribution/proposals/config wiring (not a * hand-rolled reimplementation of it). */ previewAnnotationHost: { inputsFor(env: unknown): AnnotationInputs | undefined }; /** Task 7 (D3/D21): the markdown-it plugin factory VS Code's built-in preview * loads via `markdown.markdownItPlugins` (package.json). */ extendMarkdownIt: (md: unknown) => unknown; } 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); 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}`); } }), ); // --- PUC-7 (native-surfaces migration): the INV-10 opt-in gate --- // Every later native surface (SCM/QuickDiff, comments, preview annotations, // edit commands, decorations) checks isCoediting before attaching. Persisted // in workspaceState so a reload restores the set. const coeditingRegistry = new CoeditingRegistry(context.workspaceState); context.subscriptions.push(coeditingRegistry); context.subscriptions.push( vscode.commands.registerCommand("cowriting.coeditDocument", () => { const ed = vscode.window.activeTextEditor; if (!ed || ed.document.languageId !== "markdown" || !isAuthorable(ed.document.uri.scheme)) { void vscode.window.showWarningMessage("Cowriting: open a Markdown document to coedit it."); return; } coeditingRegistry.enter(ed.document.uri); coeditingRegistry.syncContext(ed); }), vscode.commands.registerCommand("cowriting.stopCoediting", () => { const ed = vscode.window.activeTextEditor; if (!ed) return; coeditingRegistry.exit(ed.document.uri); coeditingRegistry.syncContext(ed); }), vscode.window.onDidChangeActiveTextEditor((ed) => coeditingRegistry.syncContext(ed)), ); coeditingRegistry.syncContext(vscode.window.activeTextEditor); // --- Baseline router (native-surfaces migration, spec §6.4, INV-7) — workspace- // INDEPENDENT --- The two-pane vscode.diff VIEW was removed in #34 (F10's // rendered preview is the single review surface); only the baseline data // layer 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. A SNAPSHOT baseline lives in VS Code's per-extension GLOBAL // storage (always present), never the repo (INV-19); a git-tracked doc's HEAD // baseline is never persisted (INV-7). Baselines are established only for // documents the CoeditingRegistry has entered — see the registry wiring // inside DiffViewController's constructor. const gitBaseline = new GitBaselineAdapter(); context.subscriptions.push(gitBaseline); const baselineStorageDir = context.globalStorageUri?.fsPath; const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null; const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry); context.subscriptions.push(diffViewController); // Task 1 hook: expose the resolved baseline mode ("head"/"snapshot") to the // registry's context-key sync, and re-sync `cowriting.baselineMode` whenever // a baseline is (re)captured (entry, head-refresh, pin) — the "Mark Changes // as Reviewed" menu entry (snapshot-only, Task 3) is gated on that key. coeditingRegistry.baselineModeOf = (uri) => diffViewController.modeOf(uri.toString()); context.subscriptions.push( diffViewController.onDidChangeBaseline(() => coeditingRegistry.syncContext(vscode.window.activeTextEditor)), ); // --- Task 3: the native diff surface (spec §6.4/§5, INV-13) — QuickDiff // gutter bars, the cowriting-baseline: content provider, "Review Changes", // and the status-bar change count. Constructed after diffViewController // (consumes getBaseline/modeOf/onDidChangeBaseline) and the registry // (consumes isCoediting/onDidChange), gated on INV-10. --- const scmSurfaceController = new ScmSurfaceController(coeditingRegistry, diffViewController); context.subscriptions.push(scmSurfaceController); // 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); // --- F3: live attribution (Feature #6) --- const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry); context.subscriptions.push(attributionController); // --- F4: propose/accept (Feature #12) --- const proposalController = new ProposalController( sidecarRouter, attributionController, root, versionGuard, coeditingRegistry, ); context.subscriptions.push(proposalController); // --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the // `cowriting.editDocument` command, the instruction-prompt/progress-wrapped // "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut // (`runEditAndPropose`, INV-39/40). Constructed BEFORE the thread controller, // which consumes it too (Task 6: the comment-loop's "make this edit" offer // calls runEditAndPropose). --- const editFlow = new EditFlow(proposalController); context.subscriptions.push(editFlow); // --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→ // offer→proposal loop. Consumes editFlow (offer → pending proposals) and // liveProgressUi (the shared "asking Claude…" notification + output channel). --- const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi); context.subscriptions.push(threadController); // --- 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, diffViewController, attributionController, coeditingRegistry, ); context.subscriptions.push(editorProposalController); // #46 (INV-42): accept every pending proposal on the active doc in one gesture // (also reachable from the top-of-file "Keep all (N)" CodeLens, Task 8 PUC-2). // Reuses the batched F4 seam + reports applied-vs-skipped, routing directly to // ProposalController (native-surfaces migration: no preview-controller // indirection). 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; } const { applied, skipped } = await proposalController.acceptAllProposals(doc); if (applied === 0 && skipped === 0) return; const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; void vscode.window.showInformationMessage( `Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`, ); }), ); // #64 (INV-53): reject every pending proposal on the active doc in one gesture // (native-surfaces migration: routes directly to ProposalController). context.subscriptions.push( vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => { const doc = vscode.window.activeTextEditor?.document; if (!doc || doc.languageId !== "markdown") { void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals."); return; } const { reverted } = await proposalController.rejectAll(doc); if (reverted > 0) { void vscode.window.showInformationMessage( `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, ); } }), ); // NOTE (native-surfaces migration, INV-7/INV-18 retirement): the shipped // machine-landing baseline advance (#48) — wiring // attributionController.onDidApplyAgentEdit → diffViewController.advance — // was removed here. A landed Claude edit now stays a visible change-since- // baseline until the file is committed (head mode) or reviewed via // "Mark Changes as Reviewed" (snapshot mode). // 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, }); }, ), ); // Task 6 (D19): the comments-first ask replaces the old inline turn // plumbing here — the ask now IS a comment. threadController.askClaude() // opens a focused comment box on the current selection (or, with no // selection, a top-anchored whole-document thread); the comment→reply→ // offer→proposal loop (ThreadController.respondInThread/makeThreadEdit) // takes it from there. Kept as its own command (rather than folded into // cowriting.edit) for the host E2E harness and the internal seams. context.subscriptions.push( vscode.commands.registerCommand("cowriting.editSelection", async () => { await threadController.askClaude(); }), ); // Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument` // used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompted via // `askEditInstruction` — a rejecting stub after Task 6 sunset the input webview // (spec §6.10; both `askClaude`/`askEditInstruction` were deleted from EditFlow in // Task 8 once their only caller, the review webview, died too). Reached with no // selection (the common case for `cowriting.edit`), that was a SILENT dead end: the // reject fired before EditFlow.askClaude's own try/catch and was `void`'d, so no // toast, no thread, nothing. This command now resolves // its target document exactly as before (a tab's clicked URI, falling back to the // active editor), focuses it, and hands off to the SAME comments-first ask as // `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a // whole-document ask when the (now-focused) editor's selection is empty (D19). Kept // registered + hidden from the palette for the host E2E harness and #42's // tab-targeting behavior; `EditFlow.runEditAndPropose` (untouched) stays the E2E seam // for driving a turn without the native comment UI — see f12Reach.test.ts / // f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts. context.subscriptions.push( vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => { const doc = uri ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? (await vscode.workspace.openTextDocument(uri)) : vscode.window.activeTextEditor?.document; if (!doc || doc.languageId !== "markdown") { void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it."); return; } if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) { await vscode.window.showTextDocument(doc); } await threadController.askClaude(); }), ); // 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. Both underlying commands now end at ThreadController.askClaude() // (comments-first ask, D19); they 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); } }), ); // Task 8 (native-surfaces migration, spec §6.10): the minimal gateway replacing // the sunset review-webview's #41 right-click entries. Resolves the clicked // doc (or falls back to the active editor, mirroring editDocument/#41), enters // coediting if not already (so a fresh doc gets a baseline to review against — // no-op if already entered or not authorable), then hands off to VS Code's // OWN "Open Preview to the Side" — the built-in preview IS the review surface // now (Task 7's annotations render inside it). context.subscriptions.push( vscode.commands.registerCommand("cowriting.openReviewPreview", async (uri?: vscode.Uri) => { const doc = uri ? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ?? (await vscode.workspace.openTextDocument(uri)) : vscode.window.activeTextEditor?.document; if (!doc || doc.languageId !== "markdown") { void vscode.window.showWarningMessage("Cowriting: open a Markdown document to review it."); return; } if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) { await vscode.window.showTextDocument(doc); } if (!coeditingRegistry.isCoediting(doc.uri) && isAuthorable(doc.uri.scheme)) { await vscode.commands.executeCommand("cowriting.coeditDocument"); } await vscode.commands.executeCommand("markdown.showPreviewToSide", doc.uri); }), ); // Render threads + attributions for already-open editors, and on future opens. // INV-10: gated on the registry too — belt-and-suspenders with the per-controller // gates (renderAll/loadAll already early-return), but keeps the intent explicit // at the call site and avoids the no-op calls for a non-entered doc. const renderIfOpen = (doc: vscode.TextDocument) => { if (isAuthorable(doc.uri.scheme) && coeditingRegistry.isCoediting(doc.uri)) { // Finding 3 (final whole-branch review): DiffViewController only // establishes a baseline on the registry's enter TRANSITION and, at // startup, for documents already open. A doc that is a PERSISTED // coediting member but wasn't open yet at either of those moments (e.g. // lazily restored after a window reload) reaches this handler with no // baseline/mode — establish it now so QuickDiff/Review-Changes/Mark- // Reviewed never see a silent empty-baseline gap. if (diffViewController.modeOf(doc.uri.toString()) === undefined) void diffViewController.establish(doc); threadController.renderAll(doc); attributionController.loadAll(doc); proposalController.renderAll(doc); } }; vscode.workspace.textDocuments.forEach(renderIfOpen); context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); // --- Task 7 (D3/D21, PUC-3): annotations in the BUILT-IN Markdown preview --- // `env.currentDocument` (VS Code's markdown-it render env) is an OBSERVED // preview-engine behavior, not a documented contract, so `lastCoeditedUri` // is the fallback that keeps single-doc correctness when it's absent — the // last editor that WAS coediting when it lost focus (e.g. focus moved to the // preview pane itself) stays the annotation target until a different // coedited doc becomes active. let lastCoeditedUri: string | undefined; const trackLastCoedited = (ed: vscode.TextEditor | undefined) => { if (ed && coeditingRegistry.isCoediting(ed.document.uri)) lastCoeditedUri = ed.document.uri.toString(); }; trackLastCoedited(vscode.window.activeTextEditor); context.subscriptions.push( vscode.window.onDidChangeActiveTextEditor(trackLastCoedited), // The active editor doesn't itself change when `cowriting.coeditDocument` // enters IT into coediting — re-check on every gate change too, so the // very first render after entering already has an annotation target. coeditingRegistry.onDidChange(() => trackLastCoedited(vscode.window.activeTextEditor)), ); const previewAnnotationHost = { inputsFor(env: unknown): AnnotationInputs | undefined { const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString(); // Finding 1 (final whole-branch review): the `lastCoeditedUri` fallback // is for an ABSENT env only (env.currentDocument didn't resolve) — a // present envUri is authoritative for the doc actually being previewed // and must never fall back to a different (or since-exited) document's // inputs. Either way, the winning key must currently be a coediting // member (INV-10) — an exited doc's stale `lastCoeditedUri` fails this // check too, closing the "preview stays annotated after stopCoediting" // gap. const key = envUri === undefined ? lastCoeditedUri : envUri; if (!key || !coeditingRegistry.list().includes(key)) return undefined; const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); if (!doc) return undefined; const baseline = diffViewController.getBaseline(key); return { baselineText: baseline?.text, baselineReason: baseline?.reason, spans: attributionController.spansFor(doc), proposals: proposalController.listProposals(doc), enabled: vscode.workspace.getConfiguration("cowriting").get("annotations", true), }; }, }; // Toggle: flips `cowriting.annotations` + refreshes the built-in preview // (VS Code re-invokes extendMarkdownIt's plugin on the next render — no // extension-side re-render call exists beyond the standard refresh command). context.subscriptions.push( vscode.commands.registerCommand("cowriting.toggleAnnotations", async () => { const config = vscode.workspace.getConfiguration("cowriting"); const current = config.get("annotations", true); await config.update("annotations", !current, vscode.ConfigurationTarget.Global); await vscode.commands.executeCommand("markdown.preview.refresh"); }), ); return { threadController, attributionController, proposalController, versionGuard, diffViewController, editFlow, sidecarRouter, liveProgressUi, editorProposalController, coeditingRegistry, scmSurfaceController, previewAnnotationHost, extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost), }; } export function deactivate(): void { // Disposables registered on the context handle cleanup. }