Files
claude-nova/Scripts/main.js
T
2026-08-11 12:38:02 -03:00

745 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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();
};