// // main.js — extension entry point. // // Owns the lifecycle: one Claude Code process per window, a session model fed // from its output, and the views and commands that surface it. // const { conf, log, warn, relativize, oneLine } = require("./util.js"); const cli = require("./cli.js"); const auth = require("./auth.js"); const diff = require("./diff.js"); const store = require("./sessions-store.js"); const { ClaudeClient } = require("./client.js"); const { Session } = require("./session.js"); const { Transcript } = require("./transcript.js"); const { Sidebar } = require("./sidebar.js"); const { PermissionBroker } = require("./permissions.js"); const MODELS = [ ["", "Default (from your Claude Code settings)"], ["opus", "Opus"], ["sonnet", "Sonnet"], ["haiku", "Haiku"], ["fable", "Fable"], ]; const PERMISSION_MODES = [ ["manual", "Manual — ask before each tool"], ["acceptEdits", "Accept Edits — auto-approve file edits"], ["plan", "Plan — research only, no changes"], ["auto", "Auto — Claude decides what needs asking"], ["bypassPermissions", "Bypass — approve everything (dangerous)"], ]; class Controller { constructor() { this.session = new Session(); this.transcript = new Transcript(this.session); this.broker = new PermissionBroker(this.session, this.transcript); this.client = null; this.authState = null; this.sidebar = null; this.starting = null; } // -- lifecycle --------------------------------------------------------- activate() { this.transcript.showThinking = conf("claudenova.showThinking", true) !== false; const controller = this; this.sidebar = new Sidebar({ session: this.session, get authState() { return controller.authState; }, describeAuth: () => auth.describe(this.authState), configuredModel: () => this.labelFor(MODELS, conf("claudenova.model", "")), configuredPermissionMode: () => this.labelFor(PERMISSION_MODES, conf("claudenova.permissionMode", "manual")), }); this.session.onChange.on(() => { this.sidebar.reload(); this.transcript.update(); }); this.registerCommands(); this.watchConfig(); this.refreshAuth().catch((err) => log("initial auth check failed:", err)); } dispose() { if (this.client) this.client.stop(true); this.transcript.dispose(); if (this.sidebar) this.sidebar.dispose(); } labelFor(table, value) { const row = table.find((entry) => entry[0] === (value || "")); return row ? row[1] : value; } watchConfig() { const reloadThinking = () => { this.transcript.showThinking = conf("claudenova.showThinking", true) !== false; this.transcript.update(); }; nova.subscriptions.add(nova.config.onDidChange("claudenova.showThinking", reloadThinking)); nova.subscriptions.add( nova.workspace.config.onDidChange("claudenova.showThinking", reloadThinking) ); nova.subscriptions.add( nova.config.onDidChange("claudenova.binaryPath", () => cli.forgetBinary()) ); } // -- account ----------------------------------------------------------- async refreshAuth() { this.authState = await auth.status(); this.sidebar.reload(); return this.authState; } // -- session ----------------------------------------------------------- sessionOptions(overrides = {}) { const extra = String(conf("claudenova.extraArgs", "")).trim(); return Object.assign( { cwd: nova.workspace.path || undefined, model: conf("claudenova.model", "") || undefined, effort: conf("claudenova.effort", "") || undefined, permissionMode: conf("claudenova.permissionMode", "manual"), streamPartial: conf("claudenova.streamPartial", true) !== false, extraArgs: extra ? extra.split(/\s+/) : [], }, overrides ); } /** Start a session if one is not already running. */ async ensureSession() { if (this.client && this.client.isRunning) return this.client; if (this.starting) return this.starting; this.starting = this.startSession().finally(() => { this.starting = null; }); return this.starting; } async startSession(overrides = {}) { if (this.client) this.client.stop(); this.clearStagedProposals(); this.session.reset(); this.session.setStatus("starting"); const client = new ClaudeClient({ onMessage: (message) => this.session.handleMessage(message), onPermission: (request, requestId) => this.broker.handle(request, requestId), onPermissionCancelled: (requestId) => this.broker.cancel(requestId), onExit: (status, stderr) => this.handleExit(status, stderr), }); this.client = client; try { await client.start(this.sessionOptions(overrides)); } catch (err) { this.client = null; this.session.setStatus("error", String(err.message || err)); throw err; } this.session.sessionId = client.sessionId; this.session.setStatus("idle"); if (conf("claudenova.openTranscript", true) !== false) { await this.transcript.show(); } return client; } /** Remove proposal files left over from a previous session. */ clearStagedProposals() { const root = nova.path.join( nova.workspace.path ? nova.extension.workspaceStoragePath : nova.extension.globalStoragePath, "proposed" ); try { if (!nova.fs.access(root, nova.fs.F_OK)) return; for (const name of nova.fs.listdir(root)) { nova.fs.remove(nova.path.join(root, name)); } } catch (err) { log("could not clear staged proposals:", err); } } handleExit(status, stderr) { const wasBusy = this.session.isBusy; this.session.setStatus("stopped"); for (const approval of this.session.approvals.slice()) { approval.settled = true; this.session.removeApproval(approval.requestId); } if (status !== 0 && wasBusy) { const detail = oneLine(stderr, 300) || `Claude Code exited with status ${status}.`; this.session.addNotice(detail, "error"); nova.workspace.showErrorMessage("Claude Code stopped unexpectedly.\n\n" + detail); } this.transcript.flush().catch(() => { /* the tab may be closed */ }); } // -- asking ------------------------------------------------------------ async ask(text, context) { if (!text || !text.trim()) return; try { await this.ensureSession(); } catch (err) { nova.workspace.showErrorMessage(String(err.message || err)); return; } this.session.addUserTurn(text, context ? context.label : null); await this.transcript.show(); try { this.client.send(context ? context.content(text) : text); } catch (err) { this.session.setStatus("error", String(err.message || err)); nova.workspace.showErrorMessage("Could not send your message: " + (err.message || err)); } } promptAndAsk(prefill, context) { nova.workspace.showInputPalette( context ? `Ask Claude about ${context.label}` : "Ask Claude", { placeholder: "What would you like Claude to do?", value: prefill || "" }, (value) => { if (value === null) return; this.ask(value, context); } ); } /** Build the context payload for the current editor selection. */ selectionContext(editor) { if (!editor) return null; if (conf("claudenova.includeSelection", true) === false) return null; const range = editor.selectedRange; if (!range || range.empty) return null; const document = editor.document; const path = document.path; if (!path) return null; const before = document.getTextInRange(new Range(0, range.start)); const startLine = before.split("\n").length; const selected = document.getTextInRange(range); const endLine = startLine + selected.split("\n").length - 1; const label = `${relativize(path)}:${startLine}-${endLine}`; const syntax = document.syntax || ""; return { label: label, content: (question) => [ question, "", `From \`${relativize(path)}\` lines ${startLine}–${endLine}:`, "```" + syntax, selected, "```", ].join("\n"), }; } fileContext(editor) { const path = editor && editor.document && editor.document.path; if (!path) return null; return { label: relativize(path), content: (question) => `${question}\n\nThe file in question is \`${path}\`.`, }; } // -- commands ---------------------------------------------------------- registerCommands() { const register = (name, callback) => { nova.subscriptions.add(nova.commands.register(name, callback)); }; register("claudenova.ask", (editor) => { const context = TextEditor.isTextEditor(editor) ? this.selectionContext(editor) : this.selectionContext(nova.workspace.activeTextEditor); this.promptAndAsk("", context); }); register("claudenova.askAboutSelection", (editor) => { const context = this.selectionContext(editor); if (!context) { nova.workspace.showWarningMessage("Select some code first, then ask Claude about it."); return; } this.promptAndAsk("", context); }); register("claudenova.addFiles", () => this.commandAddFiles()); register("claudenova.addFileContext", (editor) => { const context = this.fileContext(editor); if (!context) { nova.workspace.showWarningMessage("Save the file first so Claude can read it."); return; } this.promptAndAsk("", context); }); register("claudenova.plan", (editor) => { const context = TextEditor.isTextEditor(editor) ? this.selectionContext(editor) : this.selectionContext(nova.workspace.activeTextEditor); this.commandPlan(context); }); register("claudenova.showTranscript", () => { this.transcript.show().catch((err) => warn("could not open the transcript:", err)); }); register("claudenova.newSession", () => this.commandNewSession()); register("claudenova.resumeSession", () => this.commandResumeSession()); register("claudenova.interrupt", () => this.commandInterrupt()); register("claudenova.endSession", () => this.commandEndSession()); register("claudenova.setModel", () => this.commandSetModel()); register("claudenova.setPermissionMode", () => this.commandSetPermissionMode()); register("claudenova.signIn", () => this.commandSignIn()); register("claudenova.signOut", () => this.commandSignOut()); register("claudenova.authStatus", () => this.commandAuthStatus()); register("claudenova.refreshStatus", () => this.refreshAuth()); register("claudenova.setApiKey", () => this.commandSetApiKey()); register("claudenova.clearApiKey", () => this.commandClearApiKey()); register("claudenova.approvalAction", () => this.commandApprovalAction()); register("claudenova.reviewApproval", () => this.approvalCommand("review")); register("claudenova.allowApproval", () => this.approvalCommand("allow")); register("claudenova.allowApprovalAlways", () => this.approvalCommand("always")); register("claudenova.denyApproval", () => this.approvalCommand("deny")); register("claudenova.openActivityFile", () => this.commandOpenActivityFile()); register("claudenova.copyActivity", () => this.commandCopyActivity()); register("claudenova.openChangedFile", () => this.commandOpenChangedFile()); register("claudenova.showChangeDiff", () => this.commandShowChangeDiff()); register("claudenova.clearChanges", () => this.session.clearChangedFiles()); } /** * Ask in plan mode: Claude researches and proposes, changing nothing until * you approve the plan. Switches a running session over rather than * starting a new one, so the existing context carries into the planning. */ async commandPlan(context) { try { await this.ensureSession(); } catch (err) { nova.workspace.showErrorMessage(String(err.message || err)); return; } if (this.session.permissionMode !== "plan") { try { await this.client.setPermissionMode("plan"); this.session.permissionMode = "plan"; this.session.changed(); } catch (err) { warn("could not switch to plan mode:", err); nova.workspace.showWarningMessage( "Could not switch to plan mode: " + (err.message || err) ); return; } } nova.workspace.showInputPalette( context ? `Plan with Claude about ${context.label}` : "Plan with Claude", { placeholder: "What should Claude plan?" }, (value) => { if (value === null) return; this.ask(value, context); } ); } /** * Pick files (or a directory) to put in front of Claude, with the current * selection's line range attached when there is one. The editor-menu * command covers the active file; this covers everything else. */ commandAddFiles() { nova.workspace.showFileChooser( "Choose files for Claude to work with", { prompt: "Add", allowFiles: true, allowDirectories: true, allowMultiple: true, }, (paths) => { if (!paths || paths.length === 0) return; const listed = paths.map((path) => "`" + relativize(path) + "`").join(", "); const label = paths.length === 1 ? relativize(paths[0]) : `${paths.length} files`; this.promptAndAsk("", { label: label, content: (question) => `${question}\n\nWork with ${paths.length === 1 ? "this file" : "these files"}: ${listed}`, }); } ); } async commandNewSession() { try { await this.startSession(); this.session.addNotice("New session started.", "info"); } catch (err) { nova.workspace.showErrorMessage(String(err.message || err)); } } commandResumeSession() { const sessions = store.recentSessions(nova.workspace.path); if (sessions.length === 0) { nova.workspace.showInformativeMessage("No previous Claude sessions were found for this project."); return; } const choices = sessions.map((session) => { const when = session.modifiedAt.toLocaleString(); return `${session.label} — ${when}`; }); nova.workspace.showChoicePalette( choices, { placeholder: "Resume a Claude session" }, (choice, index) => { if (index === null || index === undefined) return; const picked = sessions[index]; this.startSession({ resume: picked.id }) .then(() => { this.session.addNotice(`Resumed session ${picked.id.slice(0, 8)}.`, "info"); }) .catch((err) => nova.workspace.showErrorMessage(String(err.message || err))); } ); } async commandInterrupt() { if (!this.client || !this.client.isRunning) { nova.workspace.showInformativeMessage("No Claude session is running."); return; } try { await this.client.interrupt(); this.session.addNotice("Interrupted.", "info"); this.session.setStatus("idle"); } catch (err) { warn("interrupt failed:", err); nova.workspace.showWarningMessage("Could not interrupt Claude: " + (err.message || err)); } } commandEndSession() { if (!this.client) { nova.workspace.showInformativeMessage("No Claude session is running."); return; } this.client.stop(); this.client = null; this.session.setStatus("stopped"); this.transcript.flush().catch(() => { /* the tab may be closed */ }); } commandSetModel() { const labels = MODELS.map((entry) => entry[1]); nova.workspace.showChoicePalette(labels, { placeholder: "Model" }, (choice, index) => { if (index === null || index === undefined) return; const value = MODELS[index][0]; nova.workspace.config.set("claudenova.model", value); if (this.client && this.client.isRunning) { this.client .setModel(value || undefined) .then(() => { this.session.model = value || null; this.session.changed(); }) .catch((err) => warn("could not change the model:", err)); } }); } commandSetPermissionMode() { const labels = PERMISSION_MODES.map((entry) => entry[1]); nova.workspace.showChoicePalette(labels, { placeholder: "Permission mode" }, (choice, index) => { if (index === null || index === undefined) return; const value = PERMISSION_MODES[index][0]; nova.workspace.config.set("claudenova.permissionMode", value); if (this.client && this.client.isRunning) { this.client .setPermissionMode(value) .then(() => { this.session.permissionMode = value; this.session.changed(); }) .catch((err) => warn("could not change the permission mode:", err)); } }); } // -- account commands -------------------------------------------------- commandSignIn() { nova.workspace.showActionPanel( "How would you like to sign in to Claude?", { buttons: ["Claude Subscription", "Anthropic Console", "Open a Terminal", "Cancel"], }, (index) => { if (index === null || index === 3) return; if (index === 2) { auth.signInViaTerminal() .then(() => { nova.workspace.showInformativeMessage( "Finish signing in in the terminal window, then use Refresh in the Claude sidebar." ); }) .catch((err) => nova.workspace.showErrorMessage(String(err.message || err))); return; } auth.signIn({ console: index === 1 }) .then(async (ok) => { if (!ok) return; await this.refreshAuth(); nova.workspace.showInformativeMessage("Signed in to Claude as " + auth.describe(this.authState)); }) .catch((err) => { nova.workspace.showErrorMessage( String(err.message || err) + "\n\nYou can also sign in by running `claude auth login` in a terminal." ); }); } ); } commandSignOut() { auth.signOut() .then(() => this.refreshAuth()) .then(() => nova.workspace.showInformativeMessage("Signed out of Claude.")) .catch((err) => nova.workspace.showErrorMessage(String(err.message || err))); } async commandAuthStatus() { const state = await this.refreshAuth(); const lines = []; if (!state.available) { lines.push("Claude Code was not found on this machine."); if (state.error) lines.push(state.error); } else if (state.loggedIn) { lines.push("Signed in to Claude."); if (state.email) lines.push("Account: " + state.email); if (state.subscriptionType) lines.push("Plan: " + state.subscriptionType); if (state.orgName) lines.push("Organization: " + state.orgName); if (state.authMethod) lines.push("Method: " + state.authMethod); } else { lines.push("Not signed in."); if (state.error) lines.push(state.error); } if (cli.storedApiKey()) { lines.push(""); lines.push("An API key stored in your keychain is being passed to Claude Code."); } const version = await cli.version().catch(() => null); if (version) { lines.push(""); lines.push("Claude Code " + version); } nova.workspace.showInformativeMessage(lines.join("\n")); } commandSetApiKey() { nova.workspace.showInputPanel( "Store an Anthropic API key in your keychain. Claude Code will use it instead of your signed-in account.", { label: "API key", placeholder: "sk-ant-…", secure: true, prompt: "Save" }, (value) => { if (value === null) return; const key = value.trim(); if (!key) return; try { cli.setStoredApiKey(key); nova.workspace.showInformativeMessage( "Saved. Start a new Claude session for it to take effect." ); this.refreshAuth(); } catch (err) { nova.workspace.showErrorMessage("Could not save the key: " + (err.message || err)); } } ); } commandClearApiKey() { cli.clearStoredApiKey(); nova.workspace.showInformativeMessage( "Removed the stored API key. Start a new Claude session for it to take effect." ); this.refreshAuth(); } // -- approval commands ------------------------------------------------- /** An action row under a pending request was activated. */ commandApprovalAction() { const selection = this.sidebar.approvalsView.selection; const row = selection && selection[0]; if (!row || !row.approval) return; this.broker.decide(row.approval, row.id); } approvalCommand(choice) { const selection = this.sidebar.approvalsView.selection; const selected = selection && selection[0]; // Selecting an action row and using the context menu should still act // on its parent request. const approval = (selected && (selected.approval || (selected.request ? selected : null))) || this.session.approvals[0]; if (!approval) { nova.workspace.showInformativeMessage("Nothing is waiting for approval."); return; } this.broker.decide(approval, choice); } // -- activity and changes ---------------------------------------------- commandOpenActivityFile() { const selection = this.sidebar.activityView.selection; const entry = selection && selection[0]; if (!entry) return; if (entry.filePath) { nova.workspace.openFile(entry.filePath); } else { this.commandCopyActivity(); } } commandCopyActivity() { const selection = this.sidebar.activityView.selection; const entry = selection && selection[0]; if (!entry) return; const details = [ `${entry.name} — ${entry.state}`, "", JSON.stringify(entry.input, null, 2), ]; if (entry.result) { details.push("", "Result:", entry.result); } nova.clipboard.writeText(details.join("\n")); nova.workspace.showInformativeMessage("Copied the tool call to the clipboard."); } commandOpenChangedFile() { const selection = this.sidebar.changesView.selection; for (const change of selection || []) { nova.workspace.openFile(change.path); } } commandShowChangeDiff() { const selection = this.sidebar.changesView.selection; const change = selection && selection[0]; if (!change) return; const tool = this.session.entries .filter((entry) => entry.kind === "tool" && entry.filePath === change.path) .pop(); if (!tool) { nova.workspace.openFile(change.path); return; } const preview = diff.previewForTool(tool.name, tool.input); if (preview) { diff.showPreview(preview); } else { nova.workspace.openFile(change.path); } } } const controller = new Controller(); exports.activate = function () { log("activating"); try { controller.activate(); } catch (err) { warn("activation failed:", err); nova.workspace.showErrorMessage("The Claude extension failed to start: " + (err.message || err)); } }; exports.deactivate = function () { controller.dispose(); };