Files
claude-nova/Scripts/main.js
T

363 lines
13 KiB
JavaScript

//
// main.js — extension entry point.
//
// Chat happens in a terminal running `claude`. This extension is everything
// around that: the project's sessions, the commands that launch or resume one,
// and the account state behind them.
//
const { conf, log, warn, oneLine, relativize } = require("./util.js");
const cli = require("./cli.js");
const auth = require("./auth.js");
const launch = require("./launch.js");
const store = require("./sessions-store.js");
const { Sidebar } = require("./sidebar.js");
const MODELS = [
["", "Claude Code default"],
["opus", "Opus"],
["sonnet", "Sonnet"],
["haiku", "Haiku"],
["fable", "Fable"],
];
const PERMISSION_MODES = [
["", "Claude Code default"],
["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.sessions = [];
this.authState = null;
this.sidebar = null;
}
// -- lifecycle ---------------------------------------------------------
activate() {
const controller = this;
this.sidebar = new Sidebar({
get sessions() {
return controller.sessions;
},
get authState() {
return controller.authState;
},
get workspacePath() {
return nova.workspace.path;
},
describeAuth: () => auth.describe(this.authState),
configuredModel: () => this.labelFor(MODELS, conf("claudenova.model", "")),
});
// Sessions live in ~/.claude, outside the workspace, so a file watcher
// would never fire for them. Rescanning when the section becomes
// visible keeps the list fresh at the moment it is looked at.
this.sidebar.onDidBecomeVisible(() => this.refreshSessions());
this.registerCommands();
this.watchConfig();
this.refreshSessions();
this.refreshAuth().catch((err) => log("initial auth check failed:", err));
}
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 reload = () => this.sidebar.reload();
for (const key of ["claudenova.model", "claudenova.permissionMode"]) {
nova.subscriptions.add(nova.config.onDidChange(key, reload));
nova.subscriptions.add(nova.workspace.config.onDidChange(key, reload));
}
nova.subscriptions.add(
nova.config.onDidChange("claudenova.binaryPath", () => cli.forgetBinary())
);
}
// -- data --------------------------------------------------------------
refreshSessions() {
this.sessions = store.recentSessions(nova.workspace.path);
if (this.sidebar) this.sidebar.reload();
return this.sessions;
}
async refreshAuth() {
this.authState = await auth.status();
if (this.sidebar) this.sidebar.reload();
return this.authState;
}
// -- commands ----------------------------------------------------------
registerCommands() {
const register = (name, callback) => {
nova.subscriptions.add(nova.commands.register(name, callback));
};
register("claudenova.newSession", () => this.commandNewSession());
register("claudenova.resumeSession", () => this.commandResumeSession());
register("claudenova.copyResume", () => this.sessionCommand("resume"));
register("claudenova.sessionAction", () => this.commandSessionAction());
register("claudenova.refreshSessions", () => this.refreshSessions());
register("claudenova.copyFileReference", (editor) => this.commandCopyFileReference(editor));
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());
}
async commandNewSession() {
try {
await launch.copyCommand({});
} catch (err) {
nova.workspace.showErrorMessage(String(err.message || err));
}
}
/** Pick a session from a palette, then copy its resume command. */
commandResumeSession() {
const sessions = this.refreshSessions();
if (sessions.length === 0) {
nova.workspace.showInformativeMessage(
"No previous Claude sessions were found for this project."
);
return;
}
const choices = sessions.map(
(session) => `${oneLine(session.label, 60)}${session.relative}`
);
nova.workspace.showChoicePalette(
choices,
{ placeholder: "Resume a Claude session" },
(choice, index) => {
if (index === null || index === undefined) return;
this.copyFor(sessions[index], "resume");
}
);
}
/** An action row under a session was activated. */
commandSessionAction() {
const selection = this.sidebar.sessionsView.selection;
const row = selection && selection[0];
if (!row || !row.session) return;
this.copyFor(row.session, row.id);
}
/** A session row (not an action row) was activated. */
sessionCommand(action) {
const selection = this.sidebar.sessionsView.selection;
const selected = selection && selection[0];
const session = selected && (selected.session || (selected.path ? selected : null));
if (!session) return;
this.copyFor(session, action);
}
async copyFor(session, action) {
if (action === "reveal") {
try {
nova.fs.reveal(session.path);
} catch (err) {
nova.workspace.showErrorMessage("Could not reveal the session file: " + err);
}
return;
}
try {
await launch.copyCommand({
resume: session.id,
remoteControl: action === "resume-remote",
});
} catch (err) {
nova.workspace.showErrorMessage(String(err.message || err));
}
}
/**
* Copy an @-mention for the active file, for pasting into a terminal
* session. Claude Code resolves `@path` against the project directory.
*/
commandCopyFileReference(editor) {
const target = TextEditor.isTextEditor(editor) ? editor : nova.workspace.activeTextEditor;
const path = target && target.document && target.document.path;
if (!path) {
nova.workspace.showWarningMessage("Save the file first so Claude can reference it.");
return;
}
const reference = "@" + relativize(path);
nova.clipboard.writeText(reference);
nova.workspace.showInformativeMessage(
`Copied ${reference} — paste it into your Claude session.`
);
}
commandSetModel() {
const labels = MODELS.map((entry) => entry[1]);
nova.workspace.showChoicePalette(labels, { placeholder: "Model" }, (choice, index) => {
if (index === null || index === undefined) return;
nova.workspace.config.set("claudenova.model", MODELS[index][0]);
this.sidebar.reload();
});
}
commandSetPermissionMode() {
const labels = PERMISSION_MODES.map((entry) => entry[1]);
nova.workspace.showChoicePalette(
labels,
{ placeholder: "Permission mode" },
(choice, index) => {
if (index === null || index === undefined) return;
nova.workspace.config.set("claudenova.permissionMode", PERMISSION_MODES[index][0]);
this.sidebar.reload();
}
);
}
// -- account -----------------------------------------------------------
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 refresh 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 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. It applies to sessions you start from now on."
);
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.");
this.refreshAuth();
}
}
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();
};