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
+75 -189
View File
@@ -1,38 +1,19 @@
//
// sidebar.js — the four sidebar sections.
// sidebar.js — the two sidebar sections.
//
// Session state, pending approvals, tool activity, and changed files. Each is
// a flat list; the interesting behaviour lives in the context commands that
// main.js registers against the selections here.
// Chat lives in a terminal, so the sidebar's job is everything around it:
// account state, and the project's sessions with the actions that launch them.
//
const {
throttle,
relativize,
basename,
oneLine,
modeLabel,
formatCost,
formatTokens,
} = require("./util.js");
const { friendlyToolName } = require("./session.js");
const { actionsFor } = require("./permissions.js");
const { throttle, oneLine, modeLabel, conf } = require("./util.js");
const RELOAD_INTERVAL = 200;
const MAX_ACTIVITY_ROWS = 120;
const STATUS_LABEL = {
idle: "Ready",
starting: "Starting…",
thinking: "Thinking…",
working: "Working…",
waiting: "Waiting for you",
stopped: "Stopped",
error: "Error",
};
function item(name, options = {}) {
const treeItem = new TreeItem(name, TreeItemCollapsibleState.None);
const treeItem = new TreeItem(
name,
options.collapsibleState || TreeItemCollapsibleState.None
);
if (options.descriptiveText) treeItem.descriptiveText = options.descriptiveText;
if (options.tooltip) treeItem.tooltip = options.tooltip;
if (options.image) treeItem.image = options.image;
@@ -43,7 +24,16 @@ function item(name, options = {}) {
return treeItem;
}
class SessionProvider {
/** Actions offered under a session row. Ids are handled by main.js. */
function sessionActions() {
return [
{ id: "resume", label: "Copy Resume Command" },
{ id: "resume-remote", label: "Copy Resume + Remote Control" },
{ id: "reveal", label: "Reveal Session File" },
];
}
class StatusProvider {
constructor(context) {
this.context = context;
}
@@ -51,84 +41,48 @@ class SessionProvider {
getChildren(element) {
if (element) return [];
const session = this.context.session;
const auth = this.context.authState;
const rows = [];
rows.push({
kind: "status",
name: STATUS_LABEL[session.status] || session.status,
detail: session.statusDetail,
});
rows.push({ kind: "account", auth: auth });
rows.push({
kind: "model",
name: session.model || this.context.configuredModel() || "Default model",
});
rows.push({
kind: "mode",
name: session.permissionMode
? modeLabel(session.permissionMode)
: this.context.configuredPermissionMode(),
});
const cost = formatCost(session.usage.costUSD);
const input = formatTokens(session.usage.inputTokens);
const output = formatTokens(session.usage.outputTokens);
if (cost || input || output) {
rows.push({
kind: "usage",
cost: cost,
tokens: input || output ? `${input || "0"} in / ${output || "0"} out` : null,
});
}
const rows = [{ kind: "account" }, { kind: "model" }, { kind: "mode" }];
if (!this.context.workspacePath) rows.push({ kind: "no-workspace" });
return rows;
}
getTreeItem(element) {
switch (element.kind) {
case "status": {
const treeItem = item(element.name, {
descriptiveText: element.detail ? oneLine(element.detail, 60) : "",
image: "__builtin.action",
contextValue: "status",
});
return treeItem;
}
case "account": {
const auth = element.auth;
const auth = this.context.authState;
const signedIn = auth && auth.loggedIn;
return item(signedIn ? "Account" : "Not signed in", {
descriptiveText: this.context.describeAuth(),
command: signedIn ? "claudenova.authStatus" : "claudenova.signIn",
contextValue: "account",
tooltip: signedIn ? "Signed in through Claude Code" : "Sign in to Claude",
image: "__builtin.action",
tooltip: signedIn
? "Signed in through Claude Code"
: "Sign in to Claude",
});
}
case "model":
return item("Model", {
descriptiveText: element.name,
descriptiveText: this.context.configuredModel(),
command: "claudenova.setModel",
contextValue: "model",
tooltip: "Model used by sessions this extension launches",
});
case "mode":
return item("Permissions", {
descriptiveText: element.name,
descriptiveText:
modeLabel(conf("claudenova.permissionMode", "")) || "Claude Code default",
command: "claudenova.setPermissionMode",
contextValue: "mode",
tooltip: "Permission mode used by sessions this extension launches",
});
case "usage":
return item("Usage", {
descriptiveText: [element.cost, element.tokens].filter(Boolean).join(" · "),
contextValue: "usage",
case "no-workspace":
return item("No project open", {
descriptiveText: "open a folder",
tooltip: "Sessions are stored per project directory",
});
default:
@@ -141,19 +95,18 @@ class SessionProvider {
}
}
class ApprovalsProvider {
class SessionsProvider {
constructor(context) {
this.context = context;
}
getChildren(element) {
if (!element) return this.context.session.approvals.slice();
if (!element) return this.context.sessions.slice();
// The choices for a request are rows beneath it, so a decision never
// depends on a panel being open.
if (element.request) {
return actionsFor(element).map((action) =>
Object.assign({ approval: element }, action)
// A session row expands into the things you can do with it.
if (element.id && element.path) {
return sessionActions().map((action) =>
Object.assign({ session: element }, action)
);
}
@@ -161,125 +114,53 @@ class ApprovalsProvider {
}
getTreeItem(element) {
if (element.approval) {
if (element.session) {
return item(element.label, {
identifier: element.approval.requestId + ":" + element.id,
contextValue: "approval-action",
command: "claudenova.approvalAction",
identifier: element.session.id + ":" + element.id,
contextValue: "session-action",
command: "claudenova.sessionAction",
tooltip: element.label,
});
}
const approval = element;
const request = approval.request;
const detail = approval.preview
? `+${approval.preview.additions} ${approval.preview.deletions}`
: oneLine(approval.summary, 50);
const treeItem = new TreeItem(
friendlyToolName(request.tool_name),
TreeItemCollapsibleState.Expanded
);
treeItem.descriptiveText = detail;
treeItem.tooltip = approval.summary || request.tool_name;
treeItem.image = "__builtin.action";
treeItem.contextValue = "approval";
treeItem.identifier = approval.requestId;
return treeItem;
const session = element;
return item(oneLine(session.label, 60), {
descriptiveText: session.relative,
tooltip: `${session.label}\n${session.id}\n${session.modifiedAt.toLocaleString()}`,
identifier: session.id,
contextValue: "session",
collapsibleState: TreeItemCollapsibleState.Collapsed,
command: "claudenova.copyResume",
});
}
getParent(element) {
return element && element.approval ? element.approval : null;
}
}
class ActivityProvider {
constructor(context) {
this.context = context;
}
getChildren(element) {
if (element) return [];
const tools = this.context.session.entries.filter((entry) => entry.kind === "tool");
return tools.slice(-MAX_ACTIVITY_ROWS).reverse();
}
getTreeItem(entry) {
const marks = { running: "…", done: "", error: "failed", denied: "denied" };
const detail = [oneLine(entry.summary, 48), marks[entry.state]].filter(Boolean).join(" · ");
const treeItem = item(friendlyToolName(entry.name), {
descriptiveText: detail,
tooltip: entry.summary || entry.name,
identifier: entry.id,
contextValue: entry.filePath ? "activity-file" : "activity",
});
if (entry.filePath) {
treeItem.path = entry.filePath;
treeItem.command = "claudenova.openActivityFile";
}
return treeItem;
}
getParent() {
return null;
}
}
class ChangesProvider {
constructor(context) {
this.context = context;
}
getChildren(element) {
if (element) return [];
return Array.from(this.context.session.changedFiles.values());
}
getTreeItem(change) {
const counts =
change.additions !== null || change.deletions !== null
? `+${change.additions || 0} ${change.deletions || 0}`
: null;
const detail = [counts, change.edits > 1 ? `${change.edits} edits` : null]
.filter(Boolean)
.join(" · ");
const treeItem = item(basename(change.path), {
descriptiveText: detail,
tooltip: relativize(change.path),
path: change.path,
identifier: change.path,
contextValue: "change",
command: "claudenova.openChangedFile",
});
return treeItem;
}
getParent() {
return null;
return element && element.session ? element.session : null;
}
}
class Sidebar {
/**
* @param context an object exposing `session`, `authState`, `describeAuth()`,
* `configuredModel()` and `configuredPermissionMode()`.
* @param context exposes `sessions`, `authState`, `workspacePath`,
* `describeAuth()` and `configuredModel()`.
*/
constructor(context) {
this.context = context;
this.sessionView = new TreeView("claude.session", { dataProvider: new SessionProvider(context) });
this.approvalsView = new TreeView("claude.approvals", { dataProvider: new ApprovalsProvider(context) });
this.activityView = new TreeView("claude.activity", { dataProvider: new ActivityProvider(context) });
this.changesView = new TreeView("claude.changes", { dataProvider: new ChangesProvider(context) });
this.statusView = new TreeView("claude.status", {
dataProvider: new StatusProvider(context),
});
this.sessionsView = new TreeView("claude.sessions", {
dataProvider: new SessionsProvider(context),
});
this.views = [this.sessionView, this.approvalsView, this.activityView, this.changesView];
this.views = [this.statusView, this.sessionsView];
this.visibilityHandlers = [];
this.sessionsView.onDidChangeVisibility(() => {
if (!this.sessionsView.visible) return;
for (const handler of this.visibilityHandlers) handler();
});
this.reload = throttle(() => {
for (const view of this.views) {
@@ -290,6 +171,11 @@ class Sidebar {
}, RELOAD_INTERVAL);
}
/** Run `handler` whenever the Sessions section becomes visible. */
onDidBecomeVisible(handler) {
this.visibilityHandlers.push(handler);
}
dispose() {
this.reload.cancel();
for (const view of this.views) {
@@ -302,4 +188,4 @@ class Sidebar {
}
}
module.exports = { Sidebar };
module.exports = { Sidebar, sessionActions };