Move to a terminal driven integration to have better interactions

This commit is contained in:
2026-08-11 17:10:26 -03:00
parent 89d1a06204
commit b05c28cff0
26 changed files with 679 additions and 4058 deletions
+112 -494
View File
@@ -1,23 +1,20 @@
//
// 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.
// 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, relativize, oneLine } = require("./util.js");
const { conf, log, warn, oneLine, relativize } = require("./util.js");
const cli = require("./cli.js");
const auth = require("./auth.js");
const diff = require("./diff.js");
const launch = require("./launch.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)"],
["", "Claude Code default"],
["opus", "Opus"],
["sonnet", "Sonnet"],
["haiku", "Haiku"],
@@ -25,6 +22,7 @@ const MODELS = [
];
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"],
@@ -34,46 +32,43 @@ const PERMISSION_MODES = [
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.sessions = [];
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 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", "")),
configuredPermissionMode: () =>
this.labelFor(PERMISSION_MODES, conf("claudenova.permissionMode", "manual")),
});
this.session.onChange.on(() => {
this.sidebar.reload();
this.transcript.update();
});
// 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.client) this.client.stop(true);
this.transcript.dispose();
if (this.sidebar) this.sidebar.dispose();
}
@@ -83,208 +78,30 @@ class Controller {
}
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)
);
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())
);
}
// -- account -----------------------------------------------------------
// -- 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();
this.sidebar.reload();
if (this.sidebar) 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() {
@@ -292,48 +109,12 @@ class Controller {
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.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());
@@ -344,200 +125,126 @@ class Controller {
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");
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 = store.recentSessions(nova.workspace.path);
const sessions = this.refreshSessions();
if (sessions.length === 0) {
nova.workspace.showInformativeMessage("No previous Claude sessions were found for this project.");
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}`;
});
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;
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)));
this.copyFor(sessions[index], "resume");
}
);
}
async commandInterrupt() {
if (!this.client || !this.client.isRunning) {
nova.workspace.showInformativeMessage("No Claude session is running.");
/** 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 this.client.interrupt();
this.session.addNotice("Interrupted.", "info");
this.session.setStatus("idle");
await launch.copyCommand({
resume: session.id,
remoteControl: action === "resume-remote",
});
} catch (err) {
warn("interrupt failed:", err);
nova.workspace.showWarningMessage("Could not interrupt Claude: " + (err.message || err));
nova.workspace.showErrorMessage(String(err.message || err));
}
}
commandEndSession() {
if (!this.client) {
nova.workspace.showInformativeMessage("No Claude session is running.");
/**
* 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;
}
this.client.stop();
this.client = null;
this.session.setStatus("stopped");
this.transcript.flush().catch(() => {
/* the tab may be closed */
});
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;
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));
}
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;
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));
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 commands --------------------------------------------------
// -- account -----------------------------------------------------------
commandSignIn() {
nova.workspace.showActionPanel(
"How would you like to sign in to Claude?",
{
buttons: ["Claude Subscription", "Anthropic Console", "Open a Terminal", "Cancel"],
},
{ buttons: ["Claude Subscription", "Anthropic Console", "Open a Terminal", "Cancel"] },
(index) => {
if (index === null || index === 3) return;
@@ -545,7 +252,7 @@ class Controller {
auth.signInViaTerminal()
.then(() => {
nova.workspace.showInformativeMessage(
"Finish signing in in the terminal window, then use Refresh in the Claude sidebar."
"Finish signing in in the terminal window, then refresh the Claude sidebar."
);
})
.catch((err) => nova.workspace.showErrorMessage(String(err.message || err)));
@@ -556,7 +263,9 @@ class Controller {
.then(async (ok) => {
if (!ok) return;
await this.refreshAuth();
nova.workspace.showInformativeMessage("Signed in to Claude as " + auth.describe(this.authState));
nova.workspace.showInformativeMessage(
"Signed in to Claude as " + auth.describe(this.authState)
);
})
.catch((err) => {
nova.workspace.showErrorMessage(
@@ -595,7 +304,7 @@ class Controller {
if (cli.storedApiKey()) {
lines.push("");
lines.push("An API key stored in your keychain is being passed to Claude Code.");
lines.push("An API key stored in your keychain is passed to Claude Code.");
}
const version = await cli.version().catch(() => null);
@@ -619,7 +328,7 @@ class Controller {
try {
cli.setStoredApiKey(key);
nova.workspace.showInformativeMessage(
"Saved. Start a new Claude session for it to take effect."
"Saved. It applies to sessions you start from now on."
);
this.refreshAuth();
} catch (err) {
@@ -631,100 +340,9 @@ class Controller {
commandClearApiKey() {
cli.clearStoredApiKey();
nova.workspace.showInformativeMessage(
"Removed the stored API key. Start a new Claude session for it to take effect."
);
nova.workspace.showInformativeMessage("Removed the stored API key.");
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();