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
+23 -27
View File
@@ -2,31 +2,27 @@
Initial release.
- **Chat** — ask Claude from the sidebar, the command palette (⌘⇧L), or the
editor with a selection attached (⌃⌘L). Replies stream into a Markdown
transcript tab.
- **Approvals** — every tool call that needs permission surfaces in the sidebar
and as a notification. File changes open as an editable proposal: save to
apply, close to reject, and anything you change in that tab is what gets
written. A unified diff is a click away.
- **Add Files to Context…** — pick files or folders to work with from a chooser.
- **Plan mode** — plan with ⌘⇧P or by setting the permission mode. Finished
plans open in an editable tab; approve them (choosing whether edits then apply
automatically) or send them back for revision. Feedback can be given by
editing the plan directly — your version becomes the plan of record — by
highlighting a passage to quote it back, or by writing as much as you like in
a scratch tab and choosing Send Feedback. Plans announce themselves with a
notification rather than a modal sheet, so the plan stays readable while you
decide.
- **Questions** — when Claude asks for a decision, the options appear in a
choice palette, with free-text and multi-select answers supported.
- **Sidebar** — session status, account, model, permission mode and cost;
pending approvals with their choices as rows beneath them; a log of tool
activity; and the files Claude changed.
- **Sessions** — start, interrupt, end, or resume any recent session for the
project, including ones started in a terminal.
Claude Code sessions are launched and resumed from Nova's sidebar; the
conversation itself happens in a terminal.
- **Sessions** — every conversation for the project, newest first, labelled with
its opening prompt. Sessions started in a terminal appear too, because the
list is read from Claude Code's own store.
- **Launching** — copy a command to start a new session or resume any previous
one, in the project directory and with your configured model, effort and
permission mode. Optionally with `--remote-control` for driving the session
from elsewhere.
- **Accounts** — sign in with your Claude subscription or an Anthropic Console
account through Claude Code's own OAuth flow, or store an API key in the
macOS keychain.
- Model, effort, and permission mode are configurable globally and per project,
and can be changed mid-session.
account through Claude Code's own OAuth flow, or store an API key in the macOS
keychain.
- **Copy File Reference for Claude** — puts `@path/to/file` on the clipboard for
pasting into a session.
### Note on the earlier design
An earlier iteration ran the conversation inside Nova: a driven `claude --print`
session streamed into a Markdown tab, with tool approvals, plan review and
question answering in Nova panels. It was removed before release — modal panels
truncated long questions and lost their input whenever you clicked away to check
something, and Claude Code's terminal interface handles all of it better. That
work is preserved in commit `89d1a06` and informs the planned IDE bridge.
+45 -35
View File
@@ -6,14 +6,20 @@ changing a subsystem.
## What this is
A Nova extension that puts Claude Code inside Panic's Nova editor: a sidebar for
the session, a live Markdown transcript, and tool approvals reviewed in the
editor.
A Nova extension that launches and tracks Claude Code sessions for a project.
**Chat happens in a terminal, not in Nova.** The extension prepares launch
commands, lists the project's sessions, and manages the account behind them.
It **drives the user's own `claude` executable** as a subprocess, speaking the
CLI's newline-delimited JSON protocol over stdio. There is no bundled copy of
Claude Code, no Node sidecar, no npm dependencies, and no network access of its
own.
There is no bundled copy of Claude Code, no Node sidecar, no npm dependencies,
and no network access of its own.
> **The extension used to drive the conversation itself** — `claude --print` over
> the stream-json protocol, rendered into a Markdown tab, with approvals, plan
> review and question answering in Nova panels. That was removed after real use:
> modal panels truncate long questions and lose their input when you click away.
> The implementation is preserved in commit `89d1a06`; its transport notes and
> the editable-proposal flow are the starting point for the IDE bridge. **Do not
> reintroduce panel-based chat.**
## Hard constraints (do not re-derive these)
@@ -39,16 +45,20 @@ relative `require("./x.js")` between `Scripts/` files.
Type definitions worth having open: `npm pack @types/nova-editor-node` (it is a
single `index.d.ts`, and is more precise than the website).
**Claude Code CLI**see [session transport](docs/features/session-transport.md)
for the full protocol. The three facts that cost the most to find:
**Claude Code CLI**the facts this extension relies on:
1. `--permission-prompt-tool stdio` is **required** for permission prompts to
reach the host, and is absent from `claude --help`. Without it every tool
needing approval is silently auto-denied.
2. One assistant message id arrives multiple times (per completed content block,
plus streaming deltas). They must be merged, not appended.
3. A `can_use_tool` control request **must always be answered**, including when
the handler throws — the CLI blocks on it and the session hangs otherwise.
1. Sessions are stored as `~/.claude/projects/<slug>/<session-id>.jsonl`, where
the slug is the project path with `/` and `.` flattened to `-`. This is the
CLI's own store, so terminal-started sessions appear in the sidebar too.
2. `claude --resume <id>` resumes from any terminal; `--remote-control` enables
driving a session from elsewhere. Nothing extra is needed to support either —
a launched session is an ordinary CLI session.
3. `claude auth status --json` gives structured account state.
If you ever need to drive the CLI programmatically again, commit `89d1a06`
documents the stream-json and control protocols in detail — including that
`--permission-prompt-tool stdio` is required and undocumented, without which
every permission-requiring tool is silently auto-denied.
**Policy** — do not implement claude.ai OAuth in this extension. Anthropic's
Agent SDK terms bar third-party products from offering claude.ai login. Sign-in
@@ -60,15 +70,11 @@ shells out to `claude auth login`. See [authentication](docs/features/authentica
extension.json manifest: sidebar, commands, config, entitlements
Scripts/
main.js Controller: lifecycle, commands, wiring
client.js CLI process + stream-json/control protocol
session.js conversation model (entries, tools, changed files)
permissions.js can_use_tool → approvals, plans, questions, proposals
transcript.js Markdown tab rendering
sidebar.js four TreeView providers
diff.js change reconstruction + unified diff
launch.js building and copying the launch command
sessions-store.js reading ~/.claude/projects
sidebar.js Status and Sessions TreeView providers
auth.js claude auth status/login/logout
cli.js binary resolution, environment, keychain
sessions-store.js reading ~/.claude/projects for resume
util.js conf, throttle, Signal, runCommand, processWriter
docs/features/ one file per capability; read before changing one
```
@@ -96,28 +102,29 @@ nova extension activate . # load into Nova for development
Logs (`nova.inDevMode()` output) appear in Nova's Extension Console.
**Node harness.** The Nova API is stubbable, so the transport and model can be
driven under Node against the *real* CLI. This caught four real bugs and is the
highest-value testing move available:
**Node harness.** The Nova API is stubbable, so the extension can be exercised
under Node — including against real session files on disk. This caught several
real bugs and is the highest-value testing move available:
```js
global.Process = class { /* wrap child_process.spawn; onStdout/onStderr/onDidExit */ };
global.Range = class { constructor(start, end) { this.start = start; this.end = end; } };
global.nova = { environment: process.env, path, fs, workspace, extension, crypto,
inDevMode: () => true, /* … */ };
const { ClaudeClient } = require("./Scripts/client.js");
const store = require("./Scripts/sessions-store.js");
```
Then wire `onPermission` to scripted answers and send a prompt. Stub
`showChoicePalette` / `showActionPanel` / `showInputPanel` / `notifications.add`
to script UI decisions and assert on the resulting `PermissionResult`.
Point `nova.workspace.path` at a directory that really has sessions, stub
`clipboard.writeText` to capture output, and assert on the built command. Stub
`showChoicePalette` / `showActionPanel` / `notifications.add` to script UI.
`nova.fs.open` needs a working `readline()` — session labels depend on it.
> The harness scripts from the build session lived in a temporary scratchpad and
> are gone. Recreating them in a `Tests/` folder is the obvious next improvement.
**Live tests cost real money** — they run turns on the signed-in account. Use
`--model haiku` and one-line prompts; a full verification pass is a few tens of
cents.
Nothing in the current feature set spends tokens: the extension builds command
strings and reads files. (If you add code that runs turns, use `--model haiku`
and one-line prompts — a verification pass costs a few tens of cents.)
Cheap checks that need no CLI:
@@ -128,9 +135,12 @@ python3 -c "import json; json.load(open('extension.json'))"
## State
- Not yet committed. Not yet published. `min_runtime` is declared as `10`
(`nova.crypto` is the newest API touched, and it has a fallback).
- `89d1a06` is the in-editor-chat implementation, kept for the IDE bridge.
The terminal-first rewrite on top of it is uncommitted.
- Not published. `min_runtime` is declared as `10`.
- `nova extension validate` last passed cleanly on the schema; a later run
failed only on network reachability from a sandbox, not on the bundle.
- **Never run inside Nova yet by this project's tooling** — the user has run
earlier builds; verify UI changes in the app.
- Icons are generated by a script (a tapered starburst rendered to PNG with a
pure-stdlib encoder); regenerate rather than hand-editing if sizes change.
+75 -197
View File
@@ -1,232 +1,110 @@
# Claude for Nova
Claude Code inside [Nova](https://nova.app): a sidebar for the session, a live
transcript in an editor tab, and every tool call routed through an approval you
can review before it touches your files.
Launch and resume [Claude Code](https://claude.com/product/claude-code) sessions
for your project from Nova's sidebar.
The extension drives the `claude` executable already installed on your machine,
speaking its streaming JSON protocol over stdio. Your existing sign-in, settings,
`CLAUDE.md`, skills, plugins, and MCP servers all apply — this is the same agent
you get in a terminal, wearing Nova's interface.
Chat happens in a terminal, where Claude Code's own interface handles questions,
plans and long output properly. Nova's job is everything around it: which
sessions exist, how to get back into one, and which account you're on.
## Requirements
- Nova 10 or later (built against Nova 14; not exercised on older versions)
- [Claude Code](https://claude.com/product/claude-code) installed and on disk
(`~/.local/bin/claude`, Homebrew, or a path you set in settings)
- Nova 10 or later (built against Nova 14)
- Claude Code installed (`~/.local/bin/claude`, Homebrew, or a path you set in
settings)
## Getting started
1. Open the **Claude** sidebar (the starburst in the sidebar rail).
2. If the Account row says *Not signed in*, click it and choose how to sign in.
3. Press **⌘⇧L**, or use the ✳︎ button in the sidebar header, and ask for something.
2. If it says *Not signed in*, click it and choose how to sign in.
3. Press **⌘⇧L** — the launch command is copied to your clipboard.
4. Open a terminal in Nova and paste it.
The first prompt starts a session in your project directory. The transcript opens
as a tab and updates as Claude works.
## Signing in
Authentication is handled by Claude Code itself — the extension never sees your
credentials. **Sign In** offers three routes:
| Route | What happens |
| --- | --- |
| Claude Subscription | Runs `claude auth login`, opens the authorization page in your browser, and relays the code back |
| Anthropic Console | The same flow with `--console`, for API-billed accounts |
| Open a Terminal | Runs `claude auth login` in a terminal window — use this for SSO or if the in-app flow stalls |
You can also store an API key with **Set API Key…**. It goes in the macOS
keychain and is passed to Claude Code as `ANTHROPIC_API_KEY`, taking precedence
over a signed-in account. **Clear Stored API Key** removes it.
## Approving tool use
In the default *Manual* permission mode, Claude asks before each tool that needs
permission. Every request appears in **Needs Approval** in the sidebar *and* as a
panel, so dismissing the panel never strands the session — the request waits
until you answer it from the sidebar's context menu.
### Reviewing a file change
When Claude wants to edit or create a file, the file **as it would be after the
change** opens in a tab. Review it there, and settle it with ordinary editor
gestures:
| Gesture | Result |
| --- | --- |
| **⌘S** save | Apply the change |
| **⌘W** close | Reject it |
The tab is editable, and that is the point: fix the variable name Claude got
wrong, delete the half you don't want, then save — **your** version is what gets
written, not Claude's. A notification carries the same choices, and they appear
as rows under the request in the sidebar; **Show Diff** gives the unified diff if
you'd rather see just what moved.
Prefer the old quick yes/no? Set *Reviewing file changes* to **Ask in a panel**.
**Allow for Session** adds an allow rule for that tool for the rest of the
session, so Claude stops asking about it.
Two tools are their own interaction surface rather than a yes/no gate, and get
purpose-built handling — see *Plan mode* and *Questions* below.
## Plan mode
**Plan with Claude… (⌘⇧P)** switches the session into plan mode and prompts you.
Claude researches and proposes but changes nothing. When it has a plan, the plan
opens in a tab and you choose:
| Choice | What happens |
| --- | --- |
| Approve — auto-accept edits | Plan accepted; the session moves to Accept Edits so Claude can work uninterrupted |
| Approve — ask before each edit | Plan accepted; you keep approving each change |
| Keep Planning… | Send it back for revision; Claude revises and re-presents |
| Show Plan Again | Re-open the plan |
When a plan is ready you get a **notification**, not a modal sheet — the plan tab
stays scrollable and editable while you decide. The same choices also appear as
rows under the request in **Needs Approval**, so you can dismiss the notification
and take your time.
### Giving feedback on a plan
The plan is an ordinary editable document, and there are three ways to respond —
they combine freely:
- **Edit the plan in its tab.** Rewrite, delete, or add steps directly. When you
approve, your version is sent back and becomes the plan of record — Claude
works from what you wrote, not its own draft. Unsaved edits count.
- **Highlight a passage**, then choose *Keep Planning…*. The selected lines are
quoted back to Claude along with your note, so "this part is wrong" has an
unambiguous referent.
- **Write Feedback…** opens a scratch tab you can type into freely — as many
paragraphs, lists, and code snippets as you want. When you're done, choose
**Send Feedback** in the sidebar. Nova gives extensions no multi-line field of
their own, so the editor itself is the text box.
If you edit the plan *and* send feedback, your rewrite goes along with it, marked
authoritative.
Nothing is lost by ignoring the notification: the plan waits in **Needs Approval**
with its choices until you act. Plans are saved by Claude Code under
`~/.claude/plans/`, so you can reopen one later.
You can also stay in plan mode for a whole session by setting the permission
mode to *Plan*.
## Questions
When Claude needs a decision from you it asks a real question, and the options
appear in a Nova choice palette. Pick one — or **Other…** to type your own
answer. Multi-select questions let you toggle several options and then choose
*Done*. Your answer goes straight back to Claude as the tool's result.
## Permission modes
Change the mode any time from the Permissions row:
| Mode | Behaviour |
| --- | --- |
| Manual | Ask before each tool (default) |
| Accept Edits | File edits apply automatically; other tools still ask |
| Plan | Research only — Claude proposes, changes nothing |
| Auto | Claude decides what is worth asking about |
| Bypass | Approve everything. Only in a directory you trust |
That's a normal `claude` session, in your project directory, with whatever model
and permission mode the sidebar shows.
## The sidebar
- **Session** — status, account, model, permission mode, and running cost.
Double-click Model or Permissions to change them mid-session.
- **Needs Approval** — anything waiting on you, with its choices as rows
underneath: Allow / Allow for Session / Review / Deny for an ordinary tool,
or the approve-and-feedback options for a plan. Double-click a row to take it.
- **Activity** — every tool call, newest first. Double-click one that touched a
file to open it.
- **Changed Files** — files Claude actually modified. Double-click to open, or
use **Show Change…** to see the last edit as a diff.
```
Claude
Account [email protected] · Pro
Model Opus
Permissions Ask each time
Sessions
▾ fix the parser bug 2h ago
Copy Resume Command
Copy Resume + Remote Control
Reveal Session File
▸ add a --version flag yesterday
```
**Sessions** lists this project's conversations, newest first, labelled with
their opening prompt. Sessions you started in a terminal appear here too — it is
Claude Code's own store (`~/.claude/projects/`), not a separate list.
Double-click a session to copy its resume command, or expand it for more:
| Action | What it copies |
| --- | --- |
| Copy Resume Command | `cd <project> && claude --resume <id>` |
| Copy Resume + Remote Control | The same, plus `--remote-control` |
| Reveal Session File | *(opens the transcript in Finder)* |
The list refreshes when the section becomes visible, or from the header button.
## Why the clipboard?
Nova provides no terminal API to extensions — an extension cannot open a
terminal tab or type into one. So the extension prepares the exact command and
hands it to you; you paste it wherever you want it, including Nova's own
terminal. One paste, and the session is completely ordinary: resumable from any
terminal, and drivable with `--remote-control`.
## Commands
| Command | Shortcut |
| --- | --- |
| Ask Claude… | ⌘⇧L |
| Plan with Claude… | ⌘⇧P |
| Ask Claude About Selection… (Editor menu) | ⌃⌘L |
| Add File to Claude's Context (Editor menu) | |
| Add Files to Context… | |
| Show Transcript | |
| New Session / Resume Session… | |
| Stop Claude / End Session | |
| New Session… | ⌘⇧L |
| Resume Session… | |
| Refresh Sessions | |
| Copy File Reference for Claude (Editor menu) | |
| Set Model… / Set Permission Mode… | |
| Sign In… / Sign Out / Show Account Status | |
| Set API Key… / Clear Stored API Key | |
**Add Files to Context…** opens a file chooser — pick any files or folders in the
project and ask about them together, without opening them first.
**Copy File Reference for Claude** puts `@path/to/file` on the clipboard for
pasting into a session — Claude Code resolves `@` paths against the project.
**Ask Claude About Selection…** sends the file, the line range, and the selected
text with your question. Plain **Ask Claude** does the same automatically when
there is a selection — turn that off with the *Include the editor selection*
setting.
## Signing in
**Resume Session…** lists this project's recent Claude Code sessions, labelled
with their opening prompt, including ones started in a terminal.
Authentication is handled by Claude Code itself; the extension never sees your
credentials. **Sign In** offers your Claude subscription, an Anthropic Console
account, or opening a terminal to run `claude auth login` yourself — use that
last one for SSO.
You can also store an API key with **Set API Key…**. It goes in the macOS
keychain and is passed to Claude Code as `ANTHROPIC_API_KEY`.
## Settings
Model, effort, permission mode, and the `claude` executable path are set in
Extension Settings; Model and Permission Mode can also be overridden per project
in Project Settings. Other options:
Model, effort, permission mode and the `claude` executable path are set in
Extension Settings; Model and Permission Mode can be overridden per project.
They are applied to the commands the extension builds, so a launched session
matches what the sidebar shows. **Additional CLI arguments** are appended
verbatim.
- **Reviewing file changes** — an editable proposal tab, or a panel
- **Open the transcript automatically** when a session starts
- **Stream text as it is generated** — token-by-token output
- **Show thinking in the transcript**
- **Include the editor selection with prompts**
- **Additional CLI arguments** — appended to the `claude` command line
## What's next
## How it works
```
Nova extension ──stdio (stream-json)──> claude --print
│ │
│ <── assistant / tool / result ───────┤
├──── control_response (allow/deny) ────┤
└──── control_request (interrupt) ─────>┘
```
One `claude` process per window, launched with
`--input-format stream-json --output-format stream-json`. Session messages and
control traffic share the pipe: the CLI raises a `can_use_tool` control request
for each permission prompt, and the extension answers it with your decision.
Questions are answered by returning the choices as `updatedInput`; approving a
plan returns a `setMode` permission update, which is what moves the session out
of plan mode. Interrupts and mid-session model or permission changes travel the
same channel.
There is no bundled copy of Claude Code, no Node sidecar, and no network access
of its own — the extension only talks to the executable you already trust.
## Known limits
- Nova has no scriptable diff viewer, so proposed changes are shown as a unified
diff in a scratch tab rather than in a side-by-side editor.
- Extension sidebars can only contain tree rows — there is no API for a
multi-line field like the one in Nova's own Source Control sidebar. Feedback
is written in a scratch tab instead, and decisions are tree rows.
- Ordinary tool approvals still use a panel, since they are a quick yes/no.
- Questions and plan approvals are handled natively. Any *other* tool that
needs its own interactive prompt is declined with an explanation — run those
from a terminal.
- The transcript is a Markdown document, so it renders text, not widgets.
- Sessions run in the workspace directory. Open a folder in Nova before asking.
The [IDE bridge](docs/features/ide-bridge.md): a Node sidecar that lets the
terminal session see Nova — the current selection, open files, and diffs
reviewed in the editor rather than as terminal text.
## Development
```sh
nova extension validate . # check the bundle
nova extension validate . # check the bundle (makes network calls)
nova extension activate . # load it into Nova for development
```
Extension logs (with `nova.inDevMode()` output) appear in Nova's Extension
Console. Keyboard shortcuts can be rebound in Preferences → Key Bindings if any
of the defaults collide with yours.
See [`CLAUDE.md`](CLAUDE.md) for architecture and constraints, and
[`docs/features/`](docs/features/README.md) for per-feature detail.
-386
View File
@@ -1,386 +0,0 @@
//
// client.js — drives `claude` in streaming JSON mode.
//
// The CLI speaks newline-delimited JSON on stdio. Two kinds of traffic share
// that channel:
//
// * session messages — system / assistant / user / result / stream_event
// * control traffic — request/response pairs in both directions, used for
// the initialize handshake, permission prompts
// (`can_use_tool`), interrupts and live setting changes
//
// This class owns the process and the framing; everything above it deals in
// parsed messages.
//
const { log, warn, processWriter } = require("./util.js");
const cli = require("./cli.js");
const CONTROL_TIMEOUT = 60000;
function uuid() {
const bytes = new Uint8Array(16);
if (nova.crypto && typeof nova.crypto.getRandomValues === "function") {
nova.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
const hex = [];
for (let i = 0; i < bytes.length; i++) {
hex.push(bytes[i].toString(16).padStart(2, "0"));
}
const s = hex.join("");
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
}
class ClaudeClient {
/**
* @param {object} handlers
* onMessage(message) — a session message from the CLI
* onPermission(request) — must resolve to a PermissionResult
* onPermissionCancelled(id) — the CLI withdrew a pending prompt
* onExit(status, stderr) — the process ended
* onError(error) — transport-level failure
*/
constructor(handlers = {}) {
this.handlers = handlers;
this.process = null;
this.writer = null;
this.sessionId = null;
this.running = false;
this.stdoutBuffer = "";
this.stderrLines = [];
this.pendingControl = new Map();
this.inFlightPermissions = new Map();
}
get isRunning() {
return this.running;
}
/**
* Build the command line. Kept separate so it can be inspected and tested.
*/
buildArgs(options) {
const args = [
"--print",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--verbose",
// Route permission asks over the control channel to `onPermission`
// instead of auto-denying them.
"--permission-prompt-tool",
"stdio",
];
if (options.streamPartial) args.push("--include-partial-messages");
if (options.resume) {
args.push("--resume", options.resume);
} else {
args.push("--session-id", options.sessionId);
}
if (options.model) args.push("--model", options.model);
if (options.effort) args.push("--effort", options.effort);
if (options.permissionMode) args.push("--permission-mode", options.permissionMode);
for (const dir of options.additionalDirectories || []) {
args.push("--add-dir", dir);
}
for (const extra of options.extraArgs || []) {
args.push(extra);
}
return args;
}
/** Launch the CLI and complete the initialize handshake. */
async start(options = {}) {
if (this.running) {
throw new Error("A Claude session is already running.");
}
const binary = await cli.resolveBinary();
this.sessionId = options.resume || options.sessionId || uuid();
const args = this.buildArgs(
Object.assign({}, options, { sessionId: this.sessionId })
);
log("starting:", binary, args.join(" "));
this.stdoutBuffer = "";
this.stderrLines = [];
this.process = new Process(binary, {
args: args,
cwd: options.cwd || nova.workspace.path || nova.environment["HOME"],
env: cli.environment(),
stdio: "pipe",
});
this.process.onStdout((chunk) => this._onStdout(chunk));
this.process.onStderr((chunk) => this._onStderr(chunk));
this.process.onDidExit((status) => this._onExit(status));
this.process.start();
this.writer = processWriter(this.process);
this.running = true;
try {
await this._control({ subtype: "initialize" }, 20000);
} catch (err) {
// A failed handshake is worth reporting, but older CLIs still work
// for plain message traffic, so the session continues.
warn("initialize handshake failed:", err.message || err);
}
return this.sessionId;
}
/** Send a user turn. `content` is a string or an array of content blocks. */
send(content) {
this._write({
type: "user",
message: {
role: "user",
content: content,
},
parent_tool_use_id: null,
});
}
async interrupt() {
return this._control({ subtype: "interrupt" }, 15000);
}
async setPermissionMode(mode) {
return this._control({ subtype: "set_permission_mode", mode: mode });
}
async setModel(model) {
return this._control({ subtype: "set_model", model: model || undefined });
}
/** Stop the session. Graceful by default; `force` sends SIGKILL. */
stop(force = false) {
if (!this.process) return;
for (const [, pending] of this.pendingControl) {
pending.reject(new Error("The Claude session ended."));
}
this.pendingControl.clear();
this.inFlightPermissions.clear();
try {
if (this.writer) this.writer.close();
} catch (_) {
/* already closed */
}
try {
if (force) this.process.kill();
else this.process.terminate();
} catch (err) {
warn("could not stop the process:", err);
}
this.running = false;
}
// -- internals ---------------------------------------------------------
_write(object) {
if (!this.writer) {
throw new Error("The Claude session is not running.");
}
this.writer.write(JSON.stringify(object) + "\n");
}
/** Send a control request and wait for its matching response. */
_control(request, timeout = CONTROL_TIMEOUT) {
return new Promise((resolve, reject) => {
if (!this.running && request.subtype !== "initialize") {
reject(new Error("The Claude session is not running."));
return;
}
const requestId = "nova-" + uuid();
const timer = setTimeout(() => {
this.pendingControl.delete(requestId);
reject(new Error(`Claude Code did not answer "${request.subtype}" in time.`));
}, timeout);
this.pendingControl.set(requestId, {
resolve: (value) => {
clearTimeout(timer);
this.pendingControl.delete(requestId);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
this.pendingControl.delete(requestId);
reject(err);
},
});
try {
this._write({ type: "control_request", request_id: requestId, request: request });
} catch (err) {
const pending = this.pendingControl.get(requestId);
if (pending) pending.reject(err);
}
});
}
_onStdout(chunk) {
this.stdoutBuffer += chunk;
let newline = this.stdoutBuffer.indexOf("\n");
while (newline >= 0) {
const line = this.stdoutBuffer.slice(0, newline).trim();
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
if (line) this._handleLine(line);
newline = this.stdoutBuffer.indexOf("\n");
}
}
_onStderr(chunk) {
const text = String(chunk).trim();
if (!text) return;
this.stderrLines.push(text);
if (this.stderrLines.length > 200) this.stderrLines.shift();
log("stderr:", text);
}
_handleLine(line) {
let message;
try {
message = JSON.parse(line);
} catch (err) {
// Anything that is not JSON is CLI chatter, not protocol traffic.
log("non-JSON output:", line.slice(0, 200));
return;
}
switch (message.type) {
case "control_response":
this._handleControlResponse(message.response);
return;
case "control_request":
this._handleControlRequest(message);
return;
case "control_cancel_request":
this._handleControlCancel(message.request_id);
return;
case "keep_alive":
case "transcript_mirror":
return;
default:
break;
}
if (message.session_id) {
this.sessionId = message.session_id;
}
if (this.handlers.onMessage) {
this.handlers.onMessage(message);
}
}
_handleControlResponse(response) {
if (!response || !response.request_id) return;
const pending = this.pendingControl.get(response.request_id);
if (!pending) return;
if (response.subtype === "success") {
pending.resolve(response.response);
} else {
pending.reject(new Error(response.error || "Claude Code reported a control error."));
}
}
async _handleControlRequest(message) {
const requestId = message.request_id;
const request = message.request || {};
try {
let result;
if (request.subtype === "can_use_tool") {
if (!this.handlers.onPermission) {
throw new Error("No permission handler is registered.");
}
// A handler that throws must still produce an answer: the CLI
// blocks on this request, so silence would hang the session.
this.inFlightPermissions.set(requestId, request);
result = await this.handlers.onPermission(request, requestId);
if (!this.inFlightPermissions.has(requestId)) {
// Cancelled while the user was deciding; the CLI moved on.
return;
}
this.inFlightPermissions.delete(requestId);
result = Object.assign({ toolUseID: request.tool_use_id }, result);
} else {
throw new Error("Unsupported control request subtype: " + request.subtype);
}
this._write({
type: "control_response",
response: { subtype: "success", request_id: requestId, response: result },
});
} catch (err) {
this.inFlightPermissions.delete(requestId);
try {
this._write({
type: "control_response",
response: {
subtype: "error",
request_id: requestId,
error: String((err && err.message) || err),
},
});
} catch (writeErr) {
warn("could not answer a control request:", writeErr);
}
}
}
_handleControlCancel(requestId) {
if (this.inFlightPermissions.has(requestId)) {
this.inFlightPermissions.delete(requestId);
if (this.handlers.onPermissionCancelled) {
this.handlers.onPermissionCancelled(requestId);
}
}
}
_onExit(status) {
this.running = false;
this.writer = null;
for (const [, pending] of this.pendingControl) {
pending.reject(new Error("The Claude session ended."));
}
this.pendingControl.clear();
const stderr = this.stderrLines.join("\n");
log("process exited with", status);
if (this.handlers.onExit) {
this.handlers.onExit(status, stderr);
}
}
}
module.exports = { ClaudeClient, uuid };
-299
View File
@@ -1,299 +0,0 @@
//
// diff.js — previewing a change before you approve it.
//
// Nova has no scriptable diff viewer, so proposed edits are rendered as a
// unified diff in a scratch tab. That is enough to answer the only question
// that matters at approval time: what exactly is about to change on disk.
//
const { relativize, warn } = require("./util.js");
const CONTEXT_LINES = 3;
const MAX_DIFF_LINES = 400;
function readFile(path) {
try {
if (!nova.fs.access(path, nova.fs.R_OK)) return null;
const file = nova.fs.open(path, "r");
const contents = file.readlines().join("");
file.close();
return contents;
} catch (err) {
warn("could not read", path, err);
return null;
}
}
function splitLines(text) {
if (text === null || text === undefined) return [];
const body = String(text);
if (body === "") return [];
// A trailing newline terminates the last line rather than starting a new
// empty one — otherwise every diff carries a phantom blank line.
const lines = body.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
return lines;
}
/**
* Longest-common-subsequence line diff.
* Common prefixes and suffixes are trimmed first, so the quadratic table only
* ever covers the part that actually differs.
*/
function diffLines(beforeLines, afterLines) {
let start = 0;
while (
start < beforeLines.length &&
start < afterLines.length &&
beforeLines[start] === afterLines[start]
) {
start++;
}
let endBefore = beforeLines.length;
let endAfter = afterLines.length;
while (
endBefore > start &&
endAfter > start &&
beforeLines[endBefore - 1] === afterLines[endAfter - 1]
) {
endBefore--;
endAfter--;
}
const a = beforeLines.slice(start, endBefore);
const b = afterLines.slice(start, endAfter);
const ops = [];
for (let i = 0; i < start; i++) ops.push({ type: "equal", text: beforeLines[i] });
// Guard the table: very large rewrites are shown as a plain replacement.
if (a.length * b.length > 1000000) {
for (const line of a) ops.push({ type: "delete", text: line });
for (const line of b) ops.push({ type: "insert", text: line });
} else {
const table = [];
for (let i = 0; i <= a.length; i++) {
table.push(new Array(b.length + 1).fill(0));
}
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i][j] =
a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
}
}
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
ops.push({ type: "equal", text: a[i] });
i++;
j++;
} else if (table[i + 1][j] >= table[i][j + 1]) {
ops.push({ type: "delete", text: a[i] });
i++;
} else {
ops.push({ type: "insert", text: b[j] });
j++;
}
}
while (i < a.length) ops.push({ type: "delete", text: a[i++] });
while (j < b.length) ops.push({ type: "insert", text: b[j++] });
}
for (let i = endBefore; i < beforeLines.length; i++) {
ops.push({ type: "equal", text: beforeLines[i] });
}
return ops;
}
/** Render ops as a unified diff with limited context. */
function renderUnified(ops) {
const keep = new Array(ops.length).fill(false);
for (let i = 0; i < ops.length; i++) {
if (ops[i].type === "equal") continue;
for (let j = Math.max(0, i - CONTEXT_LINES); j <= Math.min(ops.length - 1, i + CONTEXT_LINES); j++) {
keep[j] = true;
}
}
const lines = [];
let skipping = false;
let shown = 0;
for (let i = 0; i < ops.length; i++) {
if (!keep[i]) {
if (!skipping) {
lines.push("@@ …");
skipping = true;
}
continue;
}
skipping = false;
if (shown >= MAX_DIFF_LINES) {
lines.push(`@@ … diff truncated after ${MAX_DIFF_LINES} lines`);
break;
}
const op = ops[i];
const marker = op.type === "insert" ? "+" : op.type === "delete" ? "-" : " ";
lines.push(marker + op.text);
shown++;
}
return lines.join("\n");
}
function counts(ops) {
let additions = 0;
let deletions = 0;
for (const op of ops) {
if (op.type === "insert") additions++;
else if (op.type === "delete") deletions++;
}
return { additions, deletions };
}
/**
* Work out what a file would contain if a pending tool call ran.
* Returns { path, before, after, title, exists } or null when the tool does not
* describe a file change we can reconstruct.
*/
function proposedChange(toolName, input) {
if (!input) return null;
const path = input.file_path;
if (!path) return null;
if (toolName === "Write") {
const before = readFile(path);
return {
path: path,
before: before,
after: String(input.content || ""),
exists: before !== null,
title: before === null ? `Create ${relativize(path)}` : `Overwrite ${relativize(path)}`,
};
}
if (toolName === "Edit" || toolName === "Update") {
if (typeof input.old_string !== "string") return null;
const before = readFile(path);
if (before === null) {
// The file is not readable — fall back to showing the replacement
// on its own, which still tells the user what Claude intends.
return {
path: path,
before: input.old_string,
after: String(input.new_string || ""),
exists: false,
title: `Edit ${relativize(path)}`,
};
}
const after = input.replace_all
? before.split(input.old_string).join(input.new_string || "")
: before.replace(input.old_string, input.new_string || "");
return {
path: path,
before: before,
after: after,
exists: true,
title: `Edit ${relativize(path)}`,
};
}
if (toolName === "MultiEdit" && Array.isArray(input.edits)) {
const before = readFile(path);
if (before === null) return null;
let after = before;
for (const edit of input.edits) {
if (typeof edit.old_string !== "string") continue;
after = edit.replace_all
? after.split(edit.old_string).join(edit.new_string || "")
: after.replace(edit.old_string, edit.new_string || "");
}
return {
path: path,
before: before,
after: after,
exists: true,
title: `Edit ${relativize(path)} (${input.edits.length} changes)`,
};
}
return null;
}
/**
* Build a preview for a pending tool call.
* Returns { title, path, diff, additions, deletions, before, after } or null.
*/
function previewForTool(toolName, input) {
const change = proposedChange(toolName, input);
if (!change) return null;
const ops = diffLines(splitLines(change.before), splitLines(change.after));
const stats = counts(ops);
return {
title: change.title,
path: change.path,
before: change.before,
after: change.after,
exists: change.exists,
diff: renderUnified(ops),
additions: stats.additions,
deletions: stats.deletions,
};
}
/** Line number (1-based) where `needle` starts in the file, or null. */
function lineOfMatch(path, needle) {
if (!needle) return null;
const contents = readFile(path);
if (contents === null) return null;
const index = contents.indexOf(needle);
if (index < 0) return null;
return contents.slice(0, index).split("\n").length;
}
/** Open a scratch tab showing the proposed change. */
async function showPreview(preview) {
if (!preview) return null;
const header = [
`--- a/${relativize(preview.path)}`,
`+++ b/${relativize(preview.path)}`,
].join("\n");
const content = `${header}\n${preview.diff}\n`;
try {
return await nova.workspace.openNewTextDocument({
content: content,
syntax: "diff",
});
} catch (err) {
warn("could not open the change preview:", err);
return null;
}
}
module.exports = {
previewForTool,
proposedChange,
showPreview,
readFile,
lineOfMatch,
diffLines,
renderUnified,
};
+147
View File
@@ -0,0 +1,147 @@
//
// launch.js — building the command that starts or resumes a session.
//
// Nova exposes no way for an extension to open its built-in terminal or type
// into one (no terminal object in the API; `newTerminal:` in the app is an
// Objective-C menu selector with no JS bridge). So the extension prepares the
// command and puts it on the clipboard; you open Nova's terminal and paste.
//
// The upside of launching a plain CLI session is that it is a completely
// ordinary one: stored under ~/.claude/projects, resumable from any terminal,
// and eligible for Remote Control.
//
const { conf, log, warn } = require("./util.js");
const cli = require("./cli.js");
/** Quote a value for a POSIX shell. */
function shellQuote(value) {
const text = String(value);
if (/^[A-Za-z0-9_./:@%+=-]+$/.test(text)) return text;
return "'" + text.replace(/'/g, `'\\''`) + "'";
}
/**
* Flags shared by new and resumed sessions, taken from settings so a launched
* session matches what the extension is configured for.
*/
function configuredFlags() {
const flags = [];
const model = conf("claudenova.model", "");
if (model) flags.push("--model", model);
const effort = conf("claudenova.effort", "");
if (effort) flags.push("--effort", effort);
const mode = conf("claudenova.permissionMode", "");
if (mode) flags.push("--permission-mode", mode);
const extra = String(conf("claudenova.extraArgs", "")).trim();
if (extra) {
for (const piece of extra.split(/\s+/)) flags.push(piece);
}
return flags;
}
/**
* Build a launch command line.
*
* @param options.resume session id to resume
* @param options.remoteControl enable Remote Control for the session
* @param options.cd prefix with a `cd` into the workspace
* @param options.binary executable path (defaults to the resolved one)
*/
function buildCommand(options = {}) {
const parts = [];
if (options.cd !== false && nova.workspace.path) {
parts.push("cd", shellQuote(nova.workspace.path), "&&");
}
// `claude` on PATH reads better than an absolute path; fall back to the
// resolved binary when the shell would not find it.
parts.push(options.binary ? shellQuote(options.binary) : "claude");
if (options.resume) {
parts.push("--resume", shellQuote(options.resume));
}
if (options.remoteControl) {
parts.push("--remote-control");
}
for (const flag of configuredFlags()) {
parts.push(shellQuote(flag));
}
return parts.join(" ");
}
/**
* Resolve which executable spelling to use: bare `claude` when the login shell
* can find it, otherwise the absolute path we resolved.
*/
async function preferredBinary() {
try {
const resolved = await cli.resolveBinary();
const home = nova.environment["HOME"] || "";
// The standard install is on PATH for interactive shells; anything
// unusual (a configured path, a version manager shim) is safer spelled
// out in full.
if (resolved === nova.path.join(home, ".local/bin/claude")) return null;
if (resolved === "/opt/homebrew/bin/claude") return null;
if (resolved === "/usr/local/bin/claude") return null;
return resolved;
} catch (err) {
log("falling back to bare `claude`:", err.message || err);
return null;
}
}
/**
* Put a launch command on the clipboard and tell the user it is ready.
* Resolves with the command.
*/
async function copyCommand(options = {}) {
const binary = await preferredBinary();
const command = buildCommand(Object.assign({ binary: binary }, options));
try {
await nova.clipboard.writeText(command);
} catch (err) {
warn("could not write to the clipboard:", err);
nova.workspace.showErrorMessage(
"Could not copy the command. Run this in a terminal:\n\n" + command
);
return command;
}
const request = new NotificationRequest("claudenova-launch");
request.title = options.resume ? "Resume command copied" : "Launch command copied";
request.body =
command + "\n\nOpen a terminal and paste it.";
request.actions = ["OK", "Show Command"];
try {
const response = await nova.notifications.add(request);
if (response && response.actionIdx === 1) {
nova.workspace.showInformativeMessage(command);
}
} catch (err) {
// Notifications are a courtesy; the clipboard already has the command.
log("could not post the launch notification:", err);
}
return command;
}
module.exports = {
buildCommand,
copyCommand,
configuredFlags,
shellQuote,
preferredBinary,
};
+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();
-938
View File
@@ -1,938 +0,0 @@
//
// permissions.js — answering the CLI's `can_use_tool` requests.
//
// Every prompt is registered in the sidebar first and only then surfaced as a
// panel. Dismissing the panel therefore does not strand the session: the
// request stays in "Needs Approval" until it is answered from the context
// menu, or the CLI withdraws it.
//
// Two tools are their own interaction surface rather than a yes/no gate, and
// get purpose-built handling:
//
// AskUserQuestion — the host collects the answers and returns them by
// rewriting the tool input (`updatedInput.answers`).
// ExitPlanMode — the host shows the plan and either approves it (moving
// the session out of plan mode) or sends back feedback.
//
const { log, oneLine, stripAnsi, warn, conf, basename } = require("./util.js");
const { summarizeTool, friendlyToolName, planTitle } = require("./session.js");
const diff = require("./diff.js");
const ALLOW = "Allow";
const ALLOW_ALWAYS = "Allow for Session";
const DENY = "Deny";
const REVIEW = "Review Change…";
const OTHER = "Other…";
const FEEDBACK_HEADER =
"<!-- Feedback on Claude's plan.\n" +
" Write below, then choose “Send Feedback” in the Claude sidebar.\n" +
" Editing the plan itself works too — approving sends your version. -->\n\n";
/**
* The actions a pending request offers, rendered as rows under it in the
* sidebar. Ids are handed straight back to `decide()`.
*/
function actionsFor(approval) {
const name = approval.request.tool_name;
if (name === "ExitPlanMode") {
return [
{ id: "approve-auto", label: "Approve — auto-accept edits" },
{ id: "approve-manual", label: "Approve — ask before each edit" },
approval.feedbackEditor
? { id: "send-feedback", label: "Send Feedback" }
: { id: "write-feedback", label: "Write Feedback…" },
{ id: "show", label: "Open Plan" },
];
}
if (name === "AskUserQuestion") {
return [
{ id: "answer", label: "Answer…" },
{ id: "deny", label: "Dismiss" },
];
}
if (approval.proposalEditor) {
const actions = [
{ id: "apply", label: "Apply Change" },
{ id: "deny", label: "Reject" },
{ id: "open-proposal", label: "Open Proposal" },
{ id: "review", label: "Show Diff" },
];
if (!approval.request.suppress_always_allow_rule) {
actions.push({ id: "always", label: "Allow for Session" });
}
return actions;
}
const actions = [{ id: "allow", label: "Allow" }];
if (!approval.request.suppress_always_allow_rule) {
actions.push({ id: "always", label: "Allow for Session" });
}
if (approval.preview) {
actions.push({ id: "review", label: "Review Change…" });
}
actions.push({ id: "deny", label: "Deny" });
return actions;
}
/** Quote a passage so it reads as a citation in the message back to Claude. */
function quote(text) {
return String(text)
.split("\n")
.map((line) => "> " + line)
.join("\n");
}
/** Promise wrappers around Nova's callback-style panels. */
function choicePalette(choices, placeholder) {
return new Promise((resolve) => {
nova.workspace.showChoicePalette(choices, { placeholder: placeholder }, (choice, index) => {
resolve(index === null || index === undefined ? null : index);
});
});
}
function inputPanel(message, options) {
return new Promise((resolve) => {
nova.workspace.showInputPanel(message, options || {}, (value) => resolve(value));
});
}
function actionPanel(message, buttons) {
return new Promise((resolve) => {
nova.workspace.showActionPanel(message, { buttons: buttons }, (index) => {
resolve(index === null || index === undefined ? null : index);
});
});
}
class PermissionBroker {
constructor(session, transcript) {
this.session = session;
this.transcript = transcript;
}
/**
* Handle one `can_use_tool` request.
* Resolves with a PermissionResult once the user decides.
*/
handle(request, requestId) {
return new Promise((resolve) => {
const summary = summarizeTool(request.tool_name, request.input);
const preview = diff.previewForTool(request.tool_name, request.input);
const approval = {
requestId: requestId,
request: request,
summary: summary,
preview: preview,
settled: false,
resolve: (result) => {
if (approval.settled) return;
approval.settled = true;
this.session.removeApproval(requestId);
resolve(result);
},
};
this.session.addApproval(approval);
this.transcript.update();
this._present(approval);
});
}
/** Route a request to the surface that fits it. */
_present(approval) {
const name = approval.request.tool_name;
if (name === "AskUserQuestion") {
this._askQuestions(approval);
return;
}
if (name === "ExitPlanMode") {
this._reviewPlan(approval);
return;
}
// A file change gets an editable proposal rather than a yes/no panel,
// so it can be amended before it lands.
if (approval.preview && conf("claudenova.reviewEdits", "editor") === "editor") {
this._reviewChange(approval);
return;
}
if (approval.request.requires_user_interaction) {
approval.resolve({
behavior: "deny",
message:
`${name} needs its own interactive prompt, which Nova cannot display. ` +
"Run it from `claude` in a terminal, or take a different approach.",
});
return;
}
this._prompt(approval);
}
/** Withdraw a prompt the CLI no longer needs an answer for. */
cancel(requestId) {
const approval = this.session.findApproval(requestId);
if (!approval) return;
approval.settled = true;
this.session.removeApproval(requestId);
log("permission request withdrawn:", requestId);
}
/** Answer from the sidebar: "allow" | "always" | "deny" | "review". */
async decide(approval, choice) {
if (!approval || approval.settled) return;
// Plan actions, chosen from the sidebar rather than a panel.
switch (choice) {
case "approve-auto":
await this._approvePlan(approval, "acceptEdits");
return;
case "approve-manual":
await this._approvePlan(approval, "default");
return;
case "write-feedback":
await this.writeFeedback(approval);
return;
case "send-feedback":
await this.sendFeedback(approval);
return;
case "show":
await this._showPlan(approval.request.input || {});
return;
case "answer":
await this._askQuestions(approval);
return;
case "apply":
await this.applyChange(approval);
return;
case "open-proposal":
if (approval.proposalPath) await nova.workspace.openFile(approval.proposalPath);
return;
default:
break;
}
// These two carry their own decision surface; a bare allow would skip
// the mode change a plan needs, and a bare deny would drop an answer.
const name = approval.request.tool_name;
if (name === "AskUserQuestion" || name === "ExitPlanMode") {
if (choice === "deny") {
approval.resolve({
behavior: "deny",
message: "The user declined this in Nova.",
});
return;
}
this._present(approval);
return;
}
switch (choice) {
case "allow":
approval.resolve({ behavior: "allow" });
return;
case "always":
approval.resolve(this._allowAlways(approval.request));
return;
case "deny":
approval.resolve({
behavior: "deny",
message: "The user declined this action in Nova.",
});
return;
case "review":
await this.review(approval);
return;
default:
return;
}
}
/**
* Re-open whatever surface this request needs. For a plain tool that is the
* change preview; for questions and plans it is the flow itself.
*/
async review(approval) {
if (approval.settled) return;
const name = approval.request.tool_name;
if (name === "AskUserQuestion" || name === "ExitPlanMode") {
this._present(approval);
return;
}
if (approval.preview) {
await diff.showPreview(approval.preview);
} else if (approval.request.input && approval.request.input.file_path) {
await nova.workspace.openFile(approval.request.input.file_path);
} else {
nova.workspace.showInformativeMessage(
"There is nothing to preview for this request:\n\n" +
oneLine(JSON.stringify(approval.request.input || {}), 400)
);
}
if (!approval.settled) this._prompt(approval);
}
// -- AskUserQuestion ---------------------------------------------------
/**
* Ask each question in turn and hand the answers back as `updatedInput`.
* The CLI feeds them to the tool, which reports them to Claude.
*/
async _askQuestions(approval) {
const input = approval.request.input || {};
const questions = Array.isArray(input.questions) ? input.questions : [];
if (questions.length === 0) {
approval.resolve({ behavior: "allow" });
return;
}
const answers = {};
for (const question of questions) {
const answer = question.multiSelect
? await this._askMultiple(question)
: await this._askOne(question);
if (answer === null) {
approval.resolve({
behavior: "deny",
message: "The user dismissed the question without answering.",
});
return;
}
answers[question.question] = answer;
}
approval.resolve({
behavior: "allow",
updatedInput: Object.assign({}, input, { answers: answers }),
});
}
_optionLabels(question) {
return (question.options || []).map((option) => {
const description = option.description ? `${oneLine(option.description, 70)}` : "";
return `${option.label}${description}`;
});
}
async _askOne(question) {
const choices = this._optionLabels(question).concat([OTHER]);
const index = await choicePalette(choices, oneLine(question.question, 90));
if (index === null) return null;
if (index === choices.length - 1) {
const custom = await inputPanel(question.question, {
label: question.header || "Answer",
placeholder: "Your answer",
prompt: "Answer",
});
if (custom === null || !custom.trim()) return null;
return custom.trim();
}
return question.options[index].label;
}
async _askMultiple(question) {
const chosen = new Set();
for (;;) {
const choices = (question.options || []).map((option, i) => {
const mark = chosen.has(i) ? "✓ " : " ";
const description = option.description ? `${oneLine(option.description, 60)}` : "";
return `${mark}${option.label}${description}`;
});
choices.push(chosen.size > 0 ? `Done — ${chosen.size} selected` : OTHER);
const index = await choicePalette(
choices,
`${oneLine(question.question, 70)} (choose any)`
);
if (index === null) return null;
if (index === choices.length - 1) {
if (chosen.size > 0) break;
const custom = await inputPanel(question.question, {
label: question.header || "Answer",
placeholder: "Your answer",
prompt: "Answer",
});
if (custom === null || !custom.trim()) return null;
return custom.trim();
}
if (chosen.has(index)) chosen.delete(index);
else chosen.add(index);
}
return Array.from(chosen)
.sort((a, b) => a - b)
.map((i) => question.options[i].label)
.join(", ");
}
// -- ExitPlanMode ------------------------------------------------------
/**
* Show the plan, then either approve it — which also chooses how the rest
* of the session handles edits — or send Claude back to planning.
*
* The plan opens as an ordinary editable document, and edits made there are
* the primary way to give feedback: an approved plan is handed back through
* `updatedInput.plan`, which the CLI writes to the plan file, so the plan of
* record becomes the user's version rather than Claude's draft.
*/
async _reviewPlan(approval) {
const input = approval.request.input || {};
await this._showPlan(input);
// A notification rather than a panel: it does not block the window, so
// the plan stays scrollable and editable while the decision is open,
// and the same choices sit under the request in the sidebar.
const request = new NotificationRequest("claudenova-plan-" + approval.requestId);
request.title = "Claude finished planning";
request.body =
(planTitle(input.plan) || "The plan is ready to review.") +
"\n\nEdit the plan in its tab to change it — approving sends your version back.";
request.actions = ["Approve", "Auto-accept edits", "Write Feedback…"];
let response = null;
try {
response = await nova.notifications.add(request);
} catch (err) {
warn("could not post the plan notification:", err);
await this._reviewPlanFallback(approval);
return;
}
if (!response || response.actionIdx === null || response.actionIdx === undefined) {
// Dismissed — the request stays in the sidebar with its actions.
this.transcript.update();
return;
}
if (response.actionIdx === 2) {
await this.writeFeedback(approval);
return;
}
await this._approvePlan(approval, response.actionIdx === 1 ? "acceptEdits" : "default");
}
/** Panel version, for when notifications are unavailable. */
async _reviewPlanFallback(approval) {
const buttons = [
"Approve — ask before each edit",
"Approve — auto-accept edits",
"Write Feedback…",
];
const index = await actionPanel(
"Claude finished planning.\n\n" +
(planTitle((approval.request.input || {}).plan) || "The plan is ready to review."),
buttons
);
if (index === null) {
this.transcript.update();
return;
}
if (index === 2) {
await this.writeFeedback(approval);
return;
}
await this._approvePlan(approval, index === 1 ? "acceptEdits" : "default");
}
/**
* Approve the plan, carrying any edits the user made in its tab, and set
* how the rest of the session handles edits.
*/
async _approvePlan(approval, mode) {
if (approval.settled) return;
const input = approval.request.input || {};
const proposed = String(input.plan || "");
const current = this._currentPlanText(input);
const edited = current.trim() !== proposed.trim() && current.trim().length > 0;
this.session.permissionMode = mode;
this.session.changed();
const result = {
behavior: "allow",
updatedPermissions: [{ type: "setMode", mode: mode, destination: "session" }],
};
if (edited) {
result.updatedInput = Object.assign({}, input, { plan: current });
this.session.addNotice("Approved your edited version of the plan.", "info");
}
approval.resolve(result);
}
/**
* Open a scratch document for feedback. Nova has no multi-line field an
* extension can put in a sidebar, so the editor itself is the text box —
* write as much as you like, then choose Send Feedback.
*/
async writeFeedback(approval) {
if (approval.settled) return;
if (approval.feedbackEditor && !approval.feedbackEditor.document.isClosed) {
await nova.workspace.openFile(approval.feedbackEditor.document.uri);
this.session.changed();
return;
}
try {
const editor = await nova.workspace.openNewTextDocument({
content: FEEDBACK_HEADER,
syntax: "markdown",
line: 5,
});
if (editor) {
approval.feedbackEditor = editor;
this.session.addNotice(
"Write your feedback in the new tab, then choose Send Feedback in the Claude sidebar.",
"info"
);
this.session.changed();
return;
}
} catch (err) {
warn("could not open a feedback document:", err);
}
// No scratch tab available — fall back to the one-line panel.
const notes = await inputPanel("What should Claude change about the plan?", {
label: "Notes",
placeholder: "Your feedback",
prompt: "Send",
});
if (notes === null) return;
this._sendFeedbackText(approval, notes);
}
/** Read the feedback document and send it back to Claude. */
async sendFeedback(approval) {
if (approval.settled) return;
let text = "";
const editor = approval.feedbackEditor;
if (editor && editor.document && !editor.document.isClosed) {
const document = editor.document;
text = document.getTextInRange(new Range(0, document.length));
const end = text.indexOf("-->");
if (text.trimStart().startsWith("<!--") && end >= 0) {
text = text.slice(end + 3);
}
}
this._sendFeedbackText(approval, text);
}
/** Combine notes, a highlighted passage, and plan edits into one reply. */
_sendFeedbackText(approval, notes) {
const input = approval.request.input || {};
const proposed = String(input.plan || "");
const current = this._currentPlanText(input);
const edited = current.trim() !== proposed.trim() && current.trim().length > 0;
const highlighted = this._planSelection(input);
const trimmed = String(notes || "").trim();
const parts = ["The user is not ready to proceed and wants to keep planning."];
if (highlighted) {
parts.push("", "They highlighted this part of the plan:", "", quote(highlighted));
}
if (trimmed) {
parts.push("", "Their feedback:", "", trimmed);
}
if (edited) {
parts.push(
"",
"They also edited the plan directly. This is their version — treat it as authoritative:",
"",
current.trim()
);
}
if (!highlighted && !trimmed && !edited) {
parts.push("Revise the plan before proceeding.");
}
approval.resolve({ behavior: "deny", message: parts.join("\n") });
}
/**
* The plan as it stands: the open document (which may hold unsaved edits),
* else the file on disk, else what Claude proposed.
*/
_currentPlanText(input) {
const path = input.planFilePath;
if (path) {
const editor = (nova.workspace.textEditors || []).find(
(candidate) => candidate.document && candidate.document.path === path
);
if (editor) {
return editor.document.getTextInRange(new Range(0, editor.document.length));
}
const onDisk = diff.readFile(path);
if (onDisk !== null) return onDisk;
}
return String(input.plan || "");
}
/** Text the user highlighted in the plan document, if any. */
_planSelection(input) {
const editor = nova.workspace.activeTextEditor;
if (!editor || !editor.document) return null;
const path = input.planFilePath;
if (path && editor.document.path !== path) return null;
const range = editor.selectedRange;
if (!range || range.empty) return null;
const text = editor.document.getTextInRange(range).trim();
return text || null;
}
async _showPlan(input) {
const path = input.planFilePath;
if (path && nova.fs.access(path, nova.fs.R_OK)) {
try {
await nova.workspace.openFile(path);
return;
} catch (err) {
warn("could not open the plan file:", err);
}
}
if (!input.plan) return;
try {
await nova.workspace.openNewTextDocument({
content: String(input.plan),
syntax: "markdown",
});
} catch (err) {
warn("could not show the plan:", err);
nova.workspace.showInformativeMessage(oneLine(input.plan, 800));
}
}
// -- editable change proposals -----------------------------------------
/**
* Open the file as it would be after the change, and let ordinary editor
* gestures settle it: Save applies, Close rejects. Anything the user edits
* in that tab is what gets written — the change can be amended, not just
* accepted or refused.
*/
async _reviewChange(approval) {
const preview = approval.preview;
const path = await this._writeProposal(approval, preview);
if (!path) {
// No scratch file — fall back to the panel with a diff.
this._prompt(approval);
return;
}
let editor = null;
try {
editor = await nova.workspace.openFile(path);
} catch (err) {
warn("could not open the proposed change:", err);
}
if (!editor) {
this._prompt(approval);
return;
}
approval.proposalEditor = editor;
this.session.changed();
// Save applies, close rejects — the same gestures the editor already
// trains, and the reason the tab is a real file rather than a scratch
// document (an untitled document cannot be saved without a dialog).
const disposables = [];
const cleanup = () => {
for (const d of disposables) {
try {
d.dispose();
} catch (_) {
/* already disposed */
}
}
};
disposables.push(
editor.onDidSave(() => {
if (approval.settled) return;
cleanup();
this.applyChange(approval);
})
);
disposables.push(
editor.onDidDestroy(() => {
if (approval.settled) return;
cleanup();
approval.resolve({
behavior: "deny",
message: "The user closed the proposed change without applying it.",
});
this.session.addNotice(`Rejected the change to ${basename(preview.path)}.`, "info");
})
);
const request = new NotificationRequest("claudenova-change-" + approval.requestId);
request.title = preview.title;
request.body =
`+${preview.additions} ${preview.deletions}\n\n` +
"Edit the proposal if you want changes, then save to apply it. " +
"Closing the tab rejects it.";
request.actions = ["Apply", "Reject", "Show Diff"];
let response = null;
try {
response = await nova.notifications.add(request);
} catch (err) {
warn("could not post the change notification:", err);
return; // the tab and its gestures are still live
}
if (approval.settled) return;
if (response && response.actionIdx === 0) {
cleanup();
await this.applyChange(approval);
} else if (response && response.actionIdx === 1) {
cleanup();
approval.resolve({
behavior: "deny",
message: "The user rejected the proposed change in Nova.",
});
} else if (response && response.actionIdx === 2) {
await diff.showPreview(preview);
}
}
/** Write the proposed file contents somewhere Nova can open and save. */
async _writeProposal(approval, preview) {
try {
// One directory per request: two files with the same name can be
// under review at once, and the tab should still read `index.js`.
const root = nova.path.join(
nova.workspace.path
? nova.extension.workspaceStoragePath
: nova.extension.globalStoragePath,
"proposed"
);
nova.fs.mkdir(root);
const directory = nova.path.join(root, String(approval.requestId).slice(-8));
nova.fs.mkdir(directory);
// Keep the extension so the proposal is syntax-highlighted.
const path = nova.path.join(directory, basename(preview.path));
const file = nova.fs.open(path, "w");
file.write(preview.after);
file.close();
approval.proposalPath = path;
return path;
} catch (err) {
warn("could not stage the proposed change:", err);
return null;
}
}
/** Apply the proposal, carrying any edits the user made to it. */
async applyChange(approval) {
if (approval.settled) return;
const preview = approval.preview;
const current = this._proposalText(approval);
const amended = current !== null && current !== preview.after;
const result = { behavior: "allow" };
if (amended) {
const updated = this._inputForContent(approval.request, preview, current);
if (updated) {
result.updatedInput = updated;
this.session.addNotice(
`Applied your amended version of ${basename(preview.path)}.`,
"info"
);
} else {
warn("could not express the amended change; applying Claude's version");
}
}
approval.resolve(result);
}
/**
* Rewrite the tool input so it produces `content`.
*
* A whole-file replacement is used rather than trying to recover a minimal
* edit: the old text is unique in the file by construction, so it always
* matches, and the result is exactly what the user saw.
*/
_inputForContent(request, preview, content) {
const input = request.input || {};
if (request.tool_name === "Write") {
return Object.assign({}, input, { content: content });
}
if (!preview.exists || typeof preview.before !== "string") return null;
if (request.tool_name === "Edit" || request.tool_name === "Update") {
return Object.assign({}, input, {
old_string: preview.before,
new_string: content,
replace_all: false,
});
}
if (request.tool_name === "MultiEdit") {
return Object.assign({}, input, {
edits: [{ old_string: preview.before, new_string: content, replace_all: false }],
});
}
return null;
}
/** The proposal as it stands, preferring unsaved editor content. */
_proposalText(approval) {
const editor = approval.proposalEditor;
if (editor && editor.document && !editor.document.isClosed) {
const document = editor.document;
return document.getTextInRange(new Range(0, document.length));
}
if (approval.proposalPath) {
return diff.readFile(approval.proposalPath);
}
return null;
}
// -- plain tool approvals ----------------------------------------------
_allowAlways(request) {
return {
behavior: "allow",
updatedPermissions: [
{
type: "addRules",
rules: [{ toolName: request.tool_name }],
behavior: "allow",
destination: "session",
},
],
};
}
_message(approval) {
const request = approval.request;
const lines = [];
const title = request.title || `Claude wants to use ${friendlyToolName(request.tool_name)}`;
lines.push(title);
if (approval.summary) {
lines.push("");
lines.push(approval.summary);
}
if (approval.preview) {
const { additions, deletions } = approval.preview;
lines.push("");
lines.push(`${approval.preview.title} · +${additions} ${deletions}`);
}
const reason = stripAnsi(request.decision_reason || request.description || "");
if (reason) {
lines.push("");
lines.push(oneLine(reason, 300));
}
if (request.blocked_path) {
lines.push("");
lines.push(`Outside the workspace: ${request.blocked_path}`);
}
return lines.join("\n");
}
_prompt(approval) {
const buttons = [ALLOW];
if (!approval.request.suppress_always_allow_rule) {
buttons.push(ALLOW_ALWAYS);
}
if (approval.preview) {
buttons.push(REVIEW);
}
buttons.push(DENY);
nova.workspace.showActionPanel(
this._message(approval),
{ buttons: buttons },
(index) => {
if (index === null || index === undefined) {
// Dismissed — leave it pending in the sidebar.
this.transcript.update();
return;
}
const choice = buttons[index];
if (choice === ALLOW) this.decide(approval, "allow");
else if (choice === ALLOW_ALWAYS) this.decide(approval, "always");
else if (choice === REVIEW) this.decide(approval, "review");
else this.decide(approval, "deny");
}
);
}
}
module.exports = { PermissionBroker, actionsFor };
-482
View File
@@ -1,482 +0,0 @@
//
// session.js — the conversation model.
//
// Consumes messages from the CLI and maintains everything the UI renders:
// the ordered transcript, tool activity, changed files, pending approvals,
// and running cost. Views subscribe to `onChange`.
//
const { Signal, oneLine, relativize, log } = require("./util.js");
const EDITING_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit", "Update"];
// Tools whose name is an implementation detail rather than something to show.
const TOOL_LABELS = {
AskUserQuestion: "Question",
ExitPlanMode: "Plan",
EnterPlanMode: "Plan mode",
TodoWrite: "Todos",
};
function friendlyToolName(name) {
return TOOL_LABELS[name] || name;
}
/** The first heading or line of a plan, for use as its title. */
function planTitle(plan) {
if (!plan) return "";
for (const line of String(plan).split("\n")) {
const trimmed = line.replace(/^#+\s*/, "").trim();
if (trimmed) return trimmed;
}
return "";
}
/** A one-line human summary of a tool call, used in the sidebar and prompts. */
function summarizeTool(name, input) {
const args = input || {};
switch (name) {
case "Read":
case "Edit":
case "Write":
case "MultiEdit":
case "NotebookEdit":
return relativize(args.file_path || args.notebook_path || "");
case "Bash":
case "BashOutput":
return oneLine(args.command || args.description || "", 100);
case "Glob":
return args.pattern || "";
case "Grep":
return oneLine(args.pattern || "", 60) + (args.path ? ` in ${relativize(args.path)}` : "");
case "WebFetch":
return args.url || "";
case "WebSearch":
return oneLine(args.query || "", 80);
case "Task":
return oneLine(args.description || args.subagent_type || "", 80);
case "TodoWrite":
return Array.isArray(args.todos) ? `${args.todos.length} items` : "";
case "AskUserQuestion": {
const questions = Array.isArray(args.questions) ? args.questions : [];
if (questions.length === 0) return "";
return questions.length === 1
? oneLine(questions[0].question, 70)
: `${questions.length} questions`;
}
case "ExitPlanMode":
case "EnterPlanMode":
return oneLine(planTitle(args.plan) || "ready for review", 70);
default:
break;
}
if (args.file_path) return relativize(args.file_path);
if (args.path) return relativize(args.path);
if (args.command) return oneLine(args.command, 100);
if (args.query) return oneLine(args.query, 80);
const keys = Object.keys(args);
if (keys.length === 0) return "";
return oneLine(JSON.stringify(args), 80);
}
/** The file a tool call touches, when it touches exactly one. */
function toolFilePath(name, input) {
if (!input) return null;
if (EDITING_TOOLS.indexOf(name) >= 0 || name === "Read") {
return input.file_path || input.notebook_path || null;
}
return null;
}
function isEditingTool(name) {
return EDITING_TOOLS.indexOf(name) >= 0;
}
class Session {
constructor() {
this.onChange = new Signal();
this.reset();
}
reset() {
this.status = "idle";
this.statusDetail = null;
this.sessionId = null;
this.model = null;
this.permissionMode = null;
this.cwd = null;
this.tools = [];
this.slashCommands = [];
this.entries = [];
this.toolsById = new Map();
this.assistantByMessageId = new Map();
this.approvals = [];
this.changedFiles = new Map();
this.usage = {
costUSD: null,
inputTokens: null,
outputTokens: null,
durationMs: null,
numTurns: 0,
};
this.liveAssistant = null;
this.lastError = null;
this.startedAt = null;
}
// -- mutation ----------------------------------------------------------
setStatus(status, detail) {
this.status = status;
this.statusDetail = detail || null;
this.changed();
}
changed() {
this.onChange.emit(this);
}
addEntry(entry) {
entry.at = entry.at || Date.now();
this.entries.push(entry);
return entry;
}
addUserTurn(text, context) {
this.addEntry({ kind: "user", text: text, context: context || null });
this.usage.numTurns += 1;
this.setStatus("thinking");
return this.entries[this.entries.length - 1];
}
addNotice(text, tone) {
this.addEntry({ kind: "notice", text: text, tone: tone || "info" });
this.changed();
}
// -- incoming CLI messages --------------------------------------------
handleMessage(message) {
switch (message.type) {
case "system":
this._handleSystem(message);
break;
case "rate_limit_event":
return; // advisory only
case "assistant":
this._handleAssistant(message);
break;
case "user":
this._handleUser(message);
break;
case "stream_event":
this._handleStreamEvent(message);
break;
case "result":
this._handleResult(message);
break;
default:
log("unhandled message type:", message.type);
return;
}
this.changed();
}
_handleSystem(message) {
if (message.subtype === "init") {
this.sessionId = message.session_id || this.sessionId;
this.model = message.model || this.model;
this.permissionMode = message.permissionMode || message.permission_mode || this.permissionMode;
this.cwd = message.cwd || this.cwd;
this.tools = message.tools || [];
this.slashCommands = message.slash_commands || [];
this.startedAt = this.startedAt || Date.now();
if (this.status === "starting") this.setStatus("idle");
return;
}
if (message.subtype === "compact_boundary") {
this.addEntry({ kind: "notice", text: "Conversation history was compacted.", tone: "info" });
return;
}
if (message.subtype === "permission_denied") {
const tool = message.tool_name || "A tool";
this.addEntry({
kind: "notice",
text: `${tool} was denied${message.reason ? ": " + message.reason : "."}`,
tone: "error",
});
return;
}
// Advisory chatter with nothing to render.
if (
message.subtype === "status" ||
message.subtype === "thinking_tokens" ||
message.subtype === "commands_changed" ||
message.subtype === "mcp_status"
) {
return;
}
if (message.subtype === "error" || message.is_error) {
const text = message.message || message.error || "Claude Code reported an error.";
this.lastError = text;
this.addEntry({ kind: "notice", text: text, tone: "error" });
}
}
/**
* The entry that collects one assistant message.
*
* A single message id arrives more than once: the CLI emits an `assistant`
* message per completed content block (thinking, then text), and streaming
* adds deltas for the same id. They all belong to one bubble.
*/
_assistantEntry(messageId) {
if (messageId && this.assistantByMessageId.has(messageId)) {
return this.assistantByMessageId.get(messageId);
}
const entry = this.addEntry({
kind: "assistant",
messageId: messageId || null,
blocks: [],
streamed: false,
});
if (messageId) this.assistantByMessageId.set(messageId, entry);
return entry;
}
_handleAssistant(message) {
const payload = message.message || {};
const blocks = Array.isArray(payload.content) ? payload.content : [];
const entry = this._assistantEntry(payload.id);
// Keep pointing at this entry: the CLI sends one `assistant` message
// per completed block, and further blocks of the same message may
// still be streaming in behind it.
this.liveAssistant = entry;
for (const block of blocks) {
if (block.type === "tool_use") {
this._registerToolUse(block, message.parent_tool_use_id);
continue;
}
// When the text already arrived as deltas, the authoritative copy
// would duplicate it; the streamed blocks are the same content.
if (entry.streamed) continue;
if (block.type === "text") {
entry.blocks.push({ type: "text", text: block.text || "" });
} else if (block.type === "thinking") {
entry.blocks.push({ type: "thinking", text: block.thinking || "" });
}
}
if (this.status === "thinking") this.setStatus("working");
}
_registerToolUse(block, parentToolUseId) {
const name = block.name || "Tool";
const input = block.input || {};
const entry = this.addEntry({
kind: "tool",
id: block.id,
name: name,
input: input,
summary: summarizeTool(name, input),
filePath: toolFilePath(name, input),
parentToolUseId: parentToolUseId || null,
state: "running",
result: null,
});
this.toolsById.set(block.id, entry);
this.setStatus("working", `${name}${entry.summary ? " · " + entry.summary : ""}`);
}
_handleUser(message) {
const payload = message.message || {};
const content = payload.content;
if (typeof content === "string") {
if (message.isSynthetic) {
this.addEntry({ kind: "notice", text: content, tone: "info" });
}
return;
}
if (!Array.isArray(content)) return;
for (const block of content) {
if (block.type !== "tool_result") continue;
const tool = this.toolsById.get(block.tool_use_id);
if (!tool) continue;
tool.state = block.is_error ? "error" : "done";
tool.result = this._resultText(block.content);
// Only successful edits changed the file on disk. A denied or
// failed write left it untouched, so it does not belong in the
// list of things to review.
if (!block.is_error && isEditingTool(tool.name) && tool.filePath) {
this._noteChangedFile(tool.filePath, tool.name, "changed", message.tool_use_result);
}
}
}
_resultText(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((b) => b && b.type === "text")
.map((b) => b.text)
.join("\n");
}
_handleStreamEvent(message) {
const event = message.event;
if (!event) return;
if (event.type === "message_start") {
const id = event.message && event.message.id;
const entry = this._assistantEntry(id);
entry.streamed = true;
entry.streaming = true;
this.liveAssistant = entry;
return;
}
if (!this.liveAssistant) return;
if (event.type === "content_block_start") {
const block = event.content_block || {};
if (block.type === "text") {
this.liveAssistant.blocks[event.index] = { type: "text", text: block.text || "" };
} else if (block.type === "thinking") {
this.liveAssistant.blocks[event.index] = { type: "thinking", text: block.thinking || "" };
}
return;
}
if (event.type === "content_block_delta") {
const delta = event.delta || {};
const target = this.liveAssistant.blocks[event.index];
if (delta.type === "text_delta") {
if (target && target.type === "text") target.text += delta.text || "";
else this.liveAssistant.blocks[event.index] = { type: "text", text: delta.text || "" };
} else if (delta.type === "thinking_delta") {
if (target && target.type === "thinking") target.text += delta.thinking || "";
else this.liveAssistant.blocks[event.index] = { type: "thinking", text: delta.thinking || "" };
}
return;
}
if (event.type === "message_stop") {
if (this.liveAssistant) this.liveAssistant.streaming = false;
}
}
_handleResult(message) {
this.usage.costUSD = typeof message.total_cost_usd === "number" ? message.total_cost_usd : this.usage.costUSD;
this.usage.durationMs = message.duration_ms || this.usage.durationMs;
this.usage.numTurns = message.num_turns || this.usage.numTurns;
const usage = message.usage || {};
if (typeof usage.input_tokens === "number") {
this.usage.inputTokens =
usage.input_tokens + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
}
if (typeof usage.output_tokens === "number") {
this.usage.outputTokens = usage.output_tokens;
}
if (message.is_error) {
this.lastError = message.result || "The turn ended with an error.";
this.addEntry({ kind: "notice", text: this.lastError, tone: "error" });
this.setStatus("error", this.lastError);
} else {
this.setStatus("idle");
}
if (this.liveAssistant) this.liveAssistant.streaming = false;
this.liveAssistant = null;
this.assistantByMessageId.clear();
}
// -- approvals ---------------------------------------------------------
addApproval(approval) {
this.approvals.push(approval);
this.setStatus("waiting", `${approval.request.tool_name} needs approval`);
return approval;
}
removeApproval(requestId) {
const index = this.approvals.findIndex((a) => a.requestId === requestId);
if (index >= 0) this.approvals.splice(index, 1);
if (this.approvals.length === 0 && this.status === "waiting") {
this.setStatus("working");
} else {
this.changed();
}
}
findApproval(requestId) {
return this.approvals.find((a) => a.requestId === requestId) || null;
}
// -- changed files -----------------------------------------------------
_noteChangedFile(path, tool, state, toolResult) {
const existing = this.changedFiles.get(path) || {
path: path,
edits: 0,
tool: tool,
additions: null,
deletions: null,
};
existing.tool = tool;
existing.edits += 1;
if (toolResult && typeof toolResult === "object") {
if (typeof toolResult.additions === "number") existing.additions = toolResult.additions;
if (typeof toolResult.deletions === "number") existing.deletions = toolResult.deletions;
}
this.changedFiles.set(path, existing);
}
clearChangedFiles() {
this.changedFiles.clear();
this.changed();
}
get isBusy() {
return this.status === "thinking" || this.status === "working" || this.status === "waiting";
}
}
module.exports = {
Session,
summarizeTool,
toolFilePath,
isEditingTool,
friendlyToolName,
planTitle,
};
+18 -2
View File
@@ -10,6 +10,20 @@
const { log, oneLine } = require("./util.js");
/** "3m ago", "2h ago", "yesterday", "4d ago", or a date for anything older. */
function relativeTime(date) {
const seconds = Math.max(0, (Date.now() - date.getTime()) / 1000);
if (seconds < 90) return "just now";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days === 1) return "yesterday";
if (days < 14) return `${days}d ago`;
return date.toLocaleDateString();
}
function projectSlug(path) {
return String(path).replace(/[/.]/g, "-");
}
@@ -68,7 +82,7 @@ function firstPrompt(filePath) {
* Recent sessions for a workspace, newest first.
* Returns [{ id, path, modifiedAt, label }].
*/
function recentSessions(workspacePath, limit = 15) {
function recentSessions(workspacePath, limit = 25) {
const directory = projectDirectory(workspacePath);
if (!directory) return [];
@@ -102,9 +116,11 @@ function recentSessions(workspacePath, limit = 15) {
const recent = sessions.slice(0, limit);
for (const session of recent) {
session.label = firstPrompt(session.path) || session.id.slice(0, 8);
session.relative = relativeTime(session.modifiedAt);
session.shortId = session.id.slice(0, 8);
}
return recent;
}
module.exports = { recentSessions, projectDirectory, projectSlug };
module.exports = { recentSessions, projectDirectory, projectSlug, relativeTime };
+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 };
-350
View File
@@ -1,350 +0,0 @@
//
// transcript.js — renders the session into a Markdown tab.
//
// Nova sidebars can only hold tree items, so the readable conversation lives
// in an editor tab. The document is rewritten in place: we diff the previous
// render against the new one and replace only the tail that changed, which
// keeps scrolling and selection stable while text streams in.
//
const { throttle, log, warn, modeLabel, formatCost, formatTokens } = require("./util.js");
const FILE_NAME = "Claude Transcript.md";
const RENDER_INTERVAL = 140;
const MAX_RESULT_LINES = 12;
const MAX_RESULT_CHARS = 1200;
function storageDirectory() {
return nova.workspace.path ? nova.extension.workspaceStoragePath : nova.extension.globalStoragePath;
}
/** Fence a block, widening the delimiter if the body contains backtick runs. */
function fence(text, language) {
const body = String(text || "").replace(/\n+$/, "");
let longest = 0;
const runs = body.match(/`+/g);
if (runs) {
for (const run of runs) longest = Math.max(longest, run.length);
}
const delimiter = "`".repeat(Math.max(3, longest + 1));
return delimiter + (language || "") + "\n" + body + "\n" + delimiter;
}
function truncateResult(text) {
if (!text) return "";
let body = String(text);
if (body.length > MAX_RESULT_CHARS) {
body = body.slice(0, MAX_RESULT_CHARS) + "\n…";
}
const lines = body.split("\n");
if (lines.length > MAX_RESULT_LINES) {
return lines.slice(0, MAX_RESULT_LINES).join("\n") + `\n${lines.length - MAX_RESULT_LINES} more lines`;
}
return body;
}
const STATE_MARK = {
running: "…",
done: "✓",
error: "✗",
denied: "⊘",
};
class Transcript {
constructor(session) {
this.session = session;
this.editor = null;
this.path = null;
this.lastText = "";
this.disposables = [];
this.showThinking = true;
this.scheduleRender = throttle(() => this._render(), RENDER_INTERVAL);
}
dispose() {
this.scheduleRender.cancel();
for (const d of this.disposables) {
try {
d.dispose();
} catch (_) {
/* already disposed */
}
}
this.disposables = [];
this.editor = null;
}
/** Open (or focus) the transcript tab. */
async show() {
await this._ensureEditor(true);
await this._render();
return this.editor;
}
/** Re-render soon; safe to call on every incoming token. */
update() {
if (!this.editor) return;
this.scheduleRender();
}
/** Render immediately — used at the end of a turn. */
async flush() {
this.scheduleRender.cancel();
await this._render();
await this._save();
}
async _ensureEditor(focus) {
if (this.editor && this.editor.document && !this.editor.document.isClosed) {
if (focus && this.path) {
await nova.workspace.openFile(this.path);
}
return this.editor;
}
const dir = storageDirectory();
try {
nova.fs.mkdir(dir);
} catch (_) {
/* already exists */
}
this.path = nova.path.join(dir, FILE_NAME);
if (!nova.fs.access(this.path, nova.fs.F_OK)) {
const file = nova.fs.open(this.path, "w");
file.write("");
file.close();
this.lastText = "";
}
const editor = await nova.workspace.openFile(this.path);
if (!editor) {
warn("could not open the transcript tab");
return null;
}
this.editor = editor;
this.lastText = editor.document.getTextInRange(new Range(0, editor.document.length));
this.disposables.push(
editor.onDidDestroy(() => {
this.editor = null;
})
);
return editor;
}
async _save() {
if (!this.editor || !this.editor.document || this.editor.document.isClosed) return;
if (!this.editor.document.isDirty) return;
try {
await this.editor.save();
} catch (err) {
log("could not save the transcript:", err);
}
}
async _render() {
if (!this.editor || !this.editor.document || this.editor.document.isClosed) return;
const text = this.build();
if (text === this.lastText) return;
const document = this.editor.document;
const current = document.getTextInRange(new Range(0, document.length));
// Replace only the tail that differs, so the viewport stays put.
let prefix = 0;
const limit = Math.min(current.length, text.length);
while (prefix < limit && current.charCodeAt(prefix) === text.charCodeAt(prefix)) {
prefix++;
}
const wasAtEnd = this.editor.selectedRange.start >= current.length - 1;
try {
await this.editor.edit((edit) => {
edit.replace(new Range(prefix, current.length), text.slice(prefix));
});
} catch (err) {
warn("could not update the transcript:", err);
return;
}
this.lastText = text;
if (wasAtEnd) {
const end = text.length;
this.editor.selectedRange = new Range(end, end);
this.editor.scrollToCursorPosition();
}
}
// -- markdown ----------------------------------------------------------
build() {
const session = this.session;
const lines = [];
lines.push(`# Claude`);
lines.push("");
lines.push(this._header());
lines.push("");
for (const entry of session.entries) {
const block = this._renderEntry(entry);
if (block) {
lines.push(block);
lines.push("");
}
}
if (session.approvals.length > 0) {
lines.push("---");
lines.push("");
for (const approval of session.approvals) {
const request = approval.request;
lines.push(
`> **Waiting for you:** \`${request.tool_name}\`${approval.summary || "needs approval"}`
);
}
lines.push("");
}
if (session.status === "thinking") {
lines.push("_Thinking…_");
lines.push("");
} else if (session.status === "working" && session.statusDetail) {
lines.push(`_Working — ${session.statusDetail}_`);
lines.push("");
}
return lines.join("\n");
}
_header() {
const session = this.session;
const bits = [];
if (session.model) bits.push(session.model);
if (session.permissionMode) bits.push(modeLabel(session.permissionMode));
const cost = formatCost(session.usage.costUSD);
if (cost) bits.push(cost);
const input = formatTokens(session.usage.inputTokens);
const output = formatTokens(session.usage.outputTokens);
if (input || output) bits.push(`${input || "0"} in / ${output || "0"} out`);
if (session.sessionId) bits.push(`session ${session.sessionId.slice(0, 8)}`);
return bits.length ? `<sub>${bits.join(" · ")}</sub>` : "";
}
_renderEntry(entry) {
switch (entry.kind) {
case "user":
return this._renderUser(entry);
case "assistant":
return this._renderAssistant(entry);
case "tool":
return this._renderTool(entry);
case "notice":
return this._renderNotice(entry);
default:
return null;
}
}
_renderUser(entry) {
const parts = ["## You", ""];
parts.push(entry.text);
if (entry.context) {
parts.push("");
parts.push(`<sub>with ${entry.context}</sub>`);
}
return parts.join("\n");
}
_renderAssistant(entry) {
const parts = [];
for (const block of entry.blocks) {
if (!block || !block.text || !block.text.trim()) continue;
if (block.type === "thinking") {
if (!this.showThinking) continue;
parts.push("_Thinking_");
parts.push("");
parts.push(
block.text
.trim()
.split("\n")
.map((line) => "> " + line)
.join("\n")
);
parts.push("");
continue;
}
if (block.type === "text") {
parts.push(block.text.replace(/\s+$/, ""));
parts.push("");
}
}
if (parts.length === 0) return null;
const body = parts.join("\n").replace(/\n+$/, "");
return ["## Claude", "", body].join("\n");
}
_renderTool(entry) {
const mark = STATE_MARK[entry.state] || "·";
const summary = entry.summary ? ` \`${entry.summary}\`` : "";
const head = `**${mark} ${entry.name}**${summary}`;
const parts = [head];
if (entry.name === "Bash" && entry.input && entry.input.command) {
parts.push("");
parts.push(fence(entry.input.command, "sh"));
}
if (entry.state === "denied") {
parts.push("");
parts.push(`_Denied${entry.denyReason ? ": " + entry.denyReason : ""}_`);
return parts.join("\n");
}
// File contents Claude read are already on disk; echoing them here
// buries the conversation.
if (entry.name === "Read" && entry.state === "done") {
const lines = String(entry.result || "").split("\n").length;
parts.push("");
parts.push(`<sub>read ${lines} line${lines === 1 ? "" : "s"}</sub>`);
return parts.join("\n");
}
if (entry.result && entry.name !== "TodoWrite") {
const body = truncateResult(entry.result).trim();
if (body) {
parts.push("");
parts.push(fence(body, entry.state === "error" ? "" : "text"));
}
}
return parts.join("\n");
}
_renderNotice(entry) {
const prefix = entry.tone === "error" ? "⚠︎ " : "";
return `> ${prefix}${entry.text.split("\n").join("\n> ")}`;
}
}
module.exports = { Transcript };
+31 -20
View File
@@ -1,29 +1,40 @@
# Feature documentation
One file per capability. Each records what the feature does, how it is wired,
the protocol facts it depends on, and the traps found while building it — so a
future session does not have to re-derive them.
the protocol facts it depends on, and the traps found while building it.
| Feature | Status | Code |
| --- | --- | --- |
| [Session transport](session-transport.md) | Implemented | `Scripts/client.js` |
| [Conversation model](conversation-model.md) | Implemented | `Scripts/session.js` |
| [Transcript](transcript.md) | Implemented | `Scripts/transcript.js` |
| [Sidebar](sidebar.md) | Implemented | `Scripts/sidebar.js` |
| [Tool approvals](tool-approvals.md) | Implemented | `Scripts/permissions.js`, `Scripts/diff.js` |
| [Plan mode](plan-mode.md) | Implemented | `Scripts/permissions.js` |
| [Questions](questions.md) | Implemented | `Scripts/permissions.js` |
| [Launching sessions](launching.md) | Implemented | `Scripts/launch.js` |
| [Sessions sidebar](sessions.md) | Implemented | `Scripts/sidebar.js`, `Scripts/sessions-store.js` |
| [Authentication](authentication.md) | Implemented | `Scripts/auth.js`, `Scripts/cli.js` |
| [Context and prompting](context.md) | Implemented | `Scripts/main.js` |
| [Session history](session-history.md) | Implemented | `Scripts/sessions-store.js` |
| [IDE bridge](ide-bridge.md) | **Proposed — not built** | — |
| [IDE bridge](ide-bridge.md) | **Next — not built** | — |
## The one-paragraph architecture
## Architecture
The extension spawns one `claude` process per window and speaks its
newline-delimited JSON protocol over stdio. Session messages (assistant text,
tool calls, results) and control traffic (the initialize handshake, permission
prompts, interrupts) share that pipe. `client.js` owns the framing; `session.js`
turns messages into a conversation model; `transcript.js`, `sidebar.js` and
`permissions.js` are views over that model. Nothing else runs — no Node sidecar,
no bundled copy of Claude Code, no network access of the extension's own.
Chat happens in a terminal running `claude`. The extension does not own a
session and does not proxy the conversation; it prepares launch commands, tracks
the project's sessions, and manages the account behind them.
```
Nova sidebar ──copy command──> clipboard ──you paste──> terminal running claude
│ │
└── reads ~/.claude/projects/<slug>/*.jsonl ───────────────────┘
(the same store the CLI uses)
```
Because a launched session is an ordinary CLI session, it is resumable from any
terminal and eligible for `--remote-control` — nothing about it is specific to
this extension.
## History
The extension previously drove `claude --print` over its stream-json protocol
and rendered the conversation into a Markdown tab, with approvals, plan review
and question answering in Nova panels. That was removed: modal panels truncated
long questions and lost their input whenever you clicked away to check
something. The terminal TUI handles those interactions properly.
That implementation — including the stream-json transport, the editable-proposal
flow, and the change reconstruction in `Scripts/diff.js` — is preserved in commit
`89d1a06` and is the starting point for the [IDE bridge](ide-bridge.md).
-40
View File
@@ -1,40 +0,0 @@
# Context and prompting
**Status:** Implemented · **Code:** `Scripts/main.js`
## Asking
| Command | Shortcut | Context attached |
| --- | --- | --- |
| `claudenova.ask` | ⌘⇧L | The active editor's selection, if any |
| `claudenova.askAboutSelection` | ⌃⌘L | Required — warns when nothing is selected |
| `claudenova.addFileContext` | — | The active file's path |
| `claudenova.addFiles` | — | Files/folders chosen from `showFileChooser` |
| `claudenova.plan` | ⌘⇧P | As `ask`, in plan mode |
A context object is `{label, content(question)}`. The label appears in the
transcript under the user turn and in the input palette's title; `content()`
wraps the question with the attachment.
## Selection payload
`selectionContext()` computes 1-based line numbers by counting newlines before
the selection, and fences the text with the document's syntax name:
```
<question>
From `src/parser.js` lines 1024:
```javascript
<selected text>
```
```
Controlled by `claudenova.includeSelection`.
## Prompting
`showInputPalette` (not `showInputPanel`) — it is the palette-style single-line
field, consistent with Nova's other quick entry points. Nova has no multi-line
input available to extensions; for long-form input the pattern used elsewhere in
this extension is to open a scratch document instead (see [plan mode](plan-mode.md)).
-60
View File
@@ -1,60 +0,0 @@
# Conversation model
**Status:** Implemented · **Code:** `Scripts/session.js`
`Session` consumes CLI messages and holds everything the views render: the
ordered transcript, tool activity, changed files, pending approvals, and running
cost. Views subscribe via `session.onChange`.
## Entries
`session.entries` is an ordered array of:
| Kind | Fields |
| --- | --- |
| `user` | `text`, `context` (the label of any attached selection) |
| `assistant` | `messageId`, `blocks[]` of `{type: "text" \| "thinking", text}` |
| `tool` | `id`, `name`, `input`, `summary`, `filePath`, `state`, `result` |
| `notice` | `text`, `tone` (`info` \| `error`) |
Tool `state` moves `running → done | error`.
## The assistant-message merge
**One assistant message id arrives several times.** The CLI emits a separate
`assistant` message per *completed content block* — one carrying `thinking`,
then another carrying `text` — and with `--include-partial-messages` the same id
also arrives as `stream_event` deltas. They are all one bubble.
`_assistantEntry(messageId)` keeps a map from message id to entry so every
delivery merges into the same entry. Two rules make it correct:
1. If the entry was streamed (`entry.streamed`), the authoritative `assistant`
message's text/thinking blocks are **skipped** — they would duplicate content
already assembled from deltas. Tool-use blocks are still registered.
2. `_handleAssistant` sets `liveAssistant` to the entry rather than clearing it.
Clearing it was a real bug: the authoritative thinking message arrived
mid-message, cleared the pointer, and every subsequent text delta was
dropped — the reply rendered empty.
The map is cleared on `result`, which bounds it to one turn.
## Changed files
Only recorded when an editing tool's `tool_result` comes back **without**
`is_error`. Recording optimistically on tool use was wrong: a denied write never
touched the file, but still appeared in the Changed Files list.
`tool_use_result.additions` / `.deletions` supply the counts when present.
## Ignored message types
`rate_limit_event`, `system:status`, `system:thinking_tokens`,
`system:commands_changed`, `system:mcp_status` carry nothing renderable.
`system:permission_denied` becomes an error notice.
## Usage
`result` carries `total_cost_usd` (cumulative for the session, not per turn — read
the latest, never sum) and `usage`. Input tokens are summed across
`input_tokens + cache_read_input_tokens + cache_creation_input_tokens`.
+21 -21
View File
@@ -1,31 +1,31 @@
# IDE bridge
**Status: Proposed — not built.** This is a design note, not a description of
**Status: Next — not built.** This is a design note, not a description of
shipped behaviour.
This is now the main roadmap item. With chat in a terminal, the bridge is what
gives that session access to Nova: the selection, the open files, and diffs
rendered for review in the editor instead of as terminal text.
> Provenance: the protocol details below come from
> [coder/claudecode.nvim](https://github.com/coder/claudecode.nvim)'s
> `PROTOCOL.md`, which documents a working reimplementation of the same
> integration the VS Code extension uses. **None of it has been verified against
> the CLI by this project** — unlike the stdio protocol in
> [session transport](session-transport.md), which has. Treat every claim here
> as needing a first-run check.
> the CLI by this project.** Treat every claim here as needing a first-run
> check. (The stdio protocol the extension used previously *was* verified; that
> work is in commit `89d1a06`.)
## What it would add
Today the extension **drives** Claude: it spawns `claude --print` and owns the
session. The IDE bridge inverts that — Nova becomes a **server** that a `claude`
process running in a terminal connects to, so the terminal session can see the
editor:
Nova becomes a **server** that the `claude` process running in your terminal
connects to, so that session can see the editor:
- the current selection and open files as context
- diffs pushed into Nova for review rather than rendered as terminal text
- "open this file at this line" from Claude
The two modes are complementary, not competing. Driving is better for an
in-editor chat surface; the bridge is better if you live in the terminal — and
it gets the rich Claude Code TUI for free, which is the one thing Nova's UI
cannot render well.
The extension already launches the terminal session (see
[launching](launching.md)); the bridge is the return path.
## How discovery works
@@ -61,7 +61,7 @@ Messages are JSON-RPC 2.0 over RFC 6455 WebSocket.
| `getWorkspaceFolders` | ✅ `nova.workspace.path` |
| `checkDocumentDirty` | ✅ `document.isDirty` |
| `saveDocument` | ✅ `editor.save()` |
| `openDiff` | ⚠️ No diff viewer API. Reuse the editable-proposal pattern from [tool approvals](tool-approvals.md): stage the proposed file, open it, and map save → `FILE_SAVED`, close → `DIFF_REJECTED`. The tool blocks until one of those. |
| `openDiff` | ⚠️ No diff viewer API. Reuse the editable-proposal pattern: stage the proposed file, open it, and map save → `FILE_SAVED`, close → `DIFF_REJECTED`. The tool blocks until one of those. Working code for this — including change reconstruction and a unified diff renderer — is in commit `89d1a06` (`Scripts/diff.js`, `Scripts/permissions.js`). |
| `close_tab` | ❌ Nova cannot close tabs programmatically |
| `closeAllDiffTabs` | ❌ same |
| `getDiagnostics` | ❌ An extension can only read its own `IssueCollection`. Claude running the linter itself via Bash is the workaround. |
@@ -78,8 +78,8 @@ sidecar** spawned via `Process`, with the extension and sidecar talking over
stdio — `Process` supports `stdio: "jsonrpc"`, which is a natural fit here since
the payloads are already JSON-RPC.
That means Node 18+ becomes a runtime requirement, which v1 deliberately avoided.
Gate it behind a setting that is off by default.
That means Node 18+ becomes a runtime requirement, which the extension has so
far avoided. Gate it behind a setting that is off by default.
```
Nova extension ──jsonrpc/stdio──> node sidecar ──ws──> claude (terminal)
@@ -94,10 +94,10 @@ to it** — commands can only be placed in the `editor`, `extensions`, `text` an
`command-palette` menus, and there is no terminal object in the API surface.
So the realistic shape is: the extension runs the sidecar and publishes the lock
file; the user opens Nova's terminal themselves and runs `claude`. Discovery is
automatic from there — the CLI finds the lock file without being told. A
`claudenova.copyTerminalCommand` helper could put the right `env` prefix on the
clipboard for shells that do not inherit it.
file; you paste the launch command into Nova's terminal as you already do.
Discovery is automatic from there — the CLI finds the lock file without being
told. If the environment variables do not reach the terminal, `launch.js` can
prefix them onto the copied command.
If Panic ever exposes a terminal API, the only change is automating that last
step.
@@ -108,8 +108,8 @@ step.
accepted?
2. What does it do with tools that answer "unsupported" — degrade, or error the
turn?
3. Can the bridge and the driven session coexist in one window without the CLI
getting confused about which IDE it is attached to?
3. With several terminal sessions open in one project, do they all attach to the
same lock file — and does the editor need to distinguish them?
4. Lock-file lifecycle: who cleans up after a crash? (`pid` is in the file
precisely so stale entries can be detected.)
+50
View File
@@ -0,0 +1,50 @@
# Launching sessions
**Status:** Implemented · **Code:** `Scripts/launch.js`
Prepares the command that starts or resumes a Claude Code session and puts it on
the clipboard.
## Why the clipboard
**Nova exposes no terminal API to extensions.** Verified three ways: no terminal
type in `@types/nova-editor-node`, nothing in the API reference, and in the Nova
14 binary `newTerminal:` is an Objective-C menu selector with no JS bridge. An
extension cannot open a terminal tab, cannot target an existing one, and cannot
type into either.
The options were an external terminal app (a separate window, outside Nova) or
the clipboard. The clipboard keeps everything in Nova at the cost of one paste.
If Panic ever ships a terminal API, `copyCommand()` is the only function that
changes.
## The command
```
cd <workspace> && claude [--resume <id>] [--remote-control] [configured flags]
```
- `cd` is included so a terminal opened anywhere lands in the project.
- The executable is spelled `claude` when it resolves to a standard install
location (which interactive shells have on `PATH`) and as an absolute path
otherwise — a version-manager shim or a configured path is not guaranteed to
be on the shell's `PATH`.
- Configured flags come from settings: `--model`, `--effort`,
`--permission-mode`, plus `claudenova.extraArgs`. A launched session therefore
matches what the sidebar says it will be.
- Everything is escaped with `shellQuote()` — paths with spaces are common
enough to matter.
## Remote Control
`--remote-control` is offered as a separate action per session. It is the CLI's
own flag for driving a session from elsewhere; because the extension launches an
ordinary CLI session, nothing extra is needed to support it.
## Gotchas
- The notification is a courtesy — the clipboard write is the actual outcome, so
a failed notification is logged and ignored.
- Do not name a specific Nova menu path in user-facing copy; the terminal's
location in the menus has not been verified from here.
-69
View File
@@ -1,69 +0,0 @@
# Plan mode
**Status:** Implemented · **Code:** `Scripts/permissions.js` (`_reviewPlan`,
`_approvePlan`, `writeFeedback`, `sendFeedback`), `Scripts/main.js` (`commandPlan`)
Claude researches and proposes without changing anything, then presents a plan
for approval.
## Entering
`Plan with Claude…` (⌘⇧P) switches a **running** session to plan mode with the
`set_permission_mode` control request, so existing context carries into the
planning, then prompts. The permission mode can also be set to `plan` for a
whole session.
## The handoff
`ExitPlanMode` arrives as a `can_use_tool` request with
`requires_user_interaction: true`, carrying:
- `plan` — the plan as Markdown
- `planFilePath` — where Claude Code saved it (`~/.claude/plans/<slug>.md`)
The plan file is opened when it exists (it is the real artifact), otherwise the
`plan` text is shown in a scratch tab.
Announcement is a **`NotificationRequest`**, not an action panel: notifications
do not block the window, so the plan stays scrollable and editable while the
decision is open. Actions: `Approve`, `Auto-accept edits`, `Write Feedback…`.
`_reviewPlanFallback` uses a panel if notifications are unavailable.
## Approving
```jsonc
{"behavior":"allow",
"updatedPermissions":[{"type":"setMode","mode":"acceptEdits"|"default","destination":"session"}]}
```
The two approve options map onto Claude Code's own "auto-accept edits" and
"manually approve edits". The CLI answers with *"User has approved your plan.
You can now start coding."*
If the plan document was edited, `updatedInput.plan` carries the user's version
**and the CLI writes it to `planFilePath`**, making it the plan of record.
Verified: an edited plan was what Claude then followed.
## Three feedback channels
All combine into one `deny` message:
1. **Edit the plan document.** Unsaved editor content wins over disk
(`_currentPlanText` prefers an open editor over `readFile`).
2. **Highlight a passage** in the plan, then send feedback — the selection is
quoted back so "this part is wrong" has a referent (`_planSelection`).
3. **Write Feedback…** opens a scratch Markdown tab with a comment header.
Write freely, then `Send Feedback` in the sidebar. The header is stripped up
to the first `-->`.
Nova gives extensions no multi-line input field, so the editor *is* the text
box. The one-line `showInputPanel` remains only as a fallback when a scratch tab
cannot be opened.
Denying keeps the session in plan mode; Claude revises and re-presents.
## Gotchas
- Dismissing the notification leaves the plan pending in the sidebar with its
choices. Plans are expensive; never auto-deny on dismissal.
- `_showPlan` is also reachable from the `Open Plan` sidebar row.
-47
View File
@@ -1,47 +0,0 @@
# Questions
**Status:** Implemented · **Code:** `Scripts/permissions.js` (`_askQuestions`,
`_askOne`, `_askMultiple`)
When Claude needs a decision it calls `AskUserQuestion`, which arrives as a
`can_use_tool` request with `requires_user_interaction: true`.
## Input
```jsonc
{"questions":[{
"question":"Do you prefer tabs or spaces?",
"header":"Indentation",
"multiSelect":false,
"options":[{"label":"Tabs","description":"…"},{"label":"Spaces","description":"…"}]
}]}
```
## Answering
The host **rewrites the tool input**; there is no separate answer channel:
```jsonc
{"behavior":"allow",
"updatedInput":{…input, "answers":{"<question text>":"<chosen label>"}}}
```
Keyed by the question's exact text. The CLI feeds it to the tool, which reports
back *"Your questions have been answered: …"*. Verified end to end: answering
`"Tabs"` produced a model reply of "You chose **Tabs**."
Multi-select answers are joined with `, `.
## Presentation
Each question becomes a `showChoicePalette` with `label — description` rows,
asked in sequence.
- **Single select** appends `Other…`, which opens an input panel for free text.
- **Multi select** toggles rows with a `✓` marker and appends
`Done — N selected`; with nothing selected that row is `Other…` instead.
- Dismissing a palette resolves `deny` with "The user dismissed the question
without answering" — matching Claude Code's own Escape behaviour.
Unit-covered: multi-select, free text, dismissal, and several questions in
sequence.
-39
View File
@@ -1,39 +0,0 @@
# Session history
**Status:** Implemented · **Code:** `Scripts/sessions-store.js`
`Resume Session…` lists this project's previous Claude Code sessions — including
ones started in a terminal — without shelling out to the interactive picker.
## Where Claude Code stores sessions
```
~/.claude/projects/<slug>/<session-id>.jsonl
```
The slug is the project path with `/` and `.` flattened to `-`:
```
/Users/me/Developer/git.example.ca/thing.novaextension
→ -Users-me-Developer-git-example-ca-thing-novaextension
```
`projectSlug()` implements exactly that substitution. Files are sorted by mtime,
newest first.
## Labels
`firstPrompt()` reads up to 40 lines of a transcript looking for the first
non-meta, non-synthetic `user` message and uses it as the picker label, falling
back to the short session id.
## Resuming
`--resume <id>` replaces `--session-id`. The session model is reset and rebuilt
from the resumed session's messages as they stream in.
## Gotchas
- Transcripts can be megabytes; `firstPrompt()` uses `readline()` and stops
early rather than reading whole files.
- Zero-byte and non-`.jsonl` entries are skipped.
-94
View File
@@ -1,94 +0,0 @@
# Session transport
**Status:** Implemented · **Code:** `Scripts/client.js`, `Scripts/cli.js`
Drives the `claude` executable in streaming JSON mode and owns the wire
protocol. Everything above this layer deals in parsed messages.
## Command line
```
claude --print
--input-format stream-json --output-format stream-json --verbose
--permission-prompt-tool stdio
[--include-partial-messages]
(--session-id <uuid> | --resume <id>)
[--model <alias>] [--effort <level>] [--permission-mode <mode>]
[--add-dir <path>]… [extra args]
```
`--permission-prompt-tool stdio` is **load-bearing and undocumented**. It does
not appear in `claude --help`; it was found in the Agent SDK bundle, which
passes it whenever a `canUseTool` callback is supplied. Without it the CLI has
nowhere to send permission prompts and **auto-denies every tool that needs one**
— the session appears to work and silently accomplishes nothing.
`--verbose` is required for `stream-json` output under `--print`.
We deliberately do **not** pass `--replay-user-messages`: it echoes our own user
turns back, which would double them in the transcript since we render them
locally on send.
## Protocol
Two kinds of traffic share stdout, distinguished by `type`.
**Session messages** — `system` (`subtype: init` carries `session_id`, `model`,
`permissionMode`, `cwd`, `tools`), `assistant`, `user`, `stream_event`,
`result`. Also seen and ignored: `rate_limit_event`, `system:status`,
`system:thinking_tokens`, `keep_alive`, `transcript_mirror`.
**Control traffic** — request/response in both directions:
```jsonc
// out: we ask the CLI to do something
{"type":"control_request","request_id":"…","request":{"subtype":"initialize"}}
// in: the reply, matched by request_id
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{…}}}
// in: the CLI asks us something (permission prompts)
{"type":"control_request","request_id":"…","request":{"subtype":"can_use_tool",…}}
// out: our answer
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{…}}}
{"type":"control_response","response":{"subtype":"error","request_id":"…","error":"…"}}
// in: a prompt was withdrawn
{"type":"control_cancel_request","request_id":"…"}
```
Outbound control requests used: `initialize`, `interrupt`, `set_permission_mode`,
`set_model`. Inbound: `can_use_tool`. Any other inbound subtype is answered with
an error response rather than ignored.
**A control request must always be answered.** The CLI blocks on `can_use_tool`,
so a handler that throws — or silently returns — hangs the session forever.
`_handleControlRequest` wraps the handler and converts a throw into an error
response for exactly this reason.
## User input
```jsonc
{"type":"user","message":{"role":"user","content":"…"},"parent_tool_use_id":null}
```
`content` may be a string or an array of content blocks. No `session_id` is
required on input.
## Framing
`Process.onStdout` delivers chunks, not guaranteed whole lines, so `_onStdout`
buffers and splits on `\n`. Non-JSON lines are logged and skipped — the CLI
occasionally emits human-readable chatter.
## Binary resolution
`cli.js` looks in order: the configured path, `~/.local/bin/claude`,
`~/.claude/local/claude`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`,
then asks the user's login shell (`$SHELL -l -c 'command -v claude'`) — which is
what catches nvm, mise and asdf installs. The result is cached until the
`claudenova.binaryPath` setting changes.
## Gotchas
- `Process` stdin is a `WritableStream` that also has a convenience `write()`.
`processWriter()` in `util.js` handles both shapes.
- Never close stdin: the session stays open for streaming input.
- Exit status 143 is normal — it is SIGTERM from our own `stop()`.
+56
View File
@@ -0,0 +1,56 @@
# Sessions sidebar
**Status:** Implemented · **Code:** `Scripts/sidebar.js`, `Scripts/sessions-store.js`
Two sections: **Claude** (account, model, permission mode) and **Sessions** (the
project's conversations, newest first, each expanding into its actions).
```
Claude
Account you@example.com · Pro
Model Opus
Permissions Plan only
Sessions
▾ fix the parser bug 2h ago
Copy Resume Command
Copy Resume + Remote Control
Reveal Session File
```
## Where sessions come from
Claude Code stores one JSONL transcript per session:
```
~/.claude/projects/<slug>/<session-id>.jsonl
```
The slug is the project path with `/` and `.` flattened to `-`
(`/Users/me/git.example.ca/thing``-Users-me-git-example-ca-thing`). Files are
sorted by mtime; `firstPrompt()` reads up to 40 lines looking for the first
non-meta, non-synthetic user message to use as the row's label, falling back to
the short session id.
**Sessions started in a terminal appear here too** — it is the CLI's own store,
not something this extension maintains.
## Refreshing
Sessions live in `~/.claude`, **outside the workspace**, so `nova.fs.watch`
would never fire for them (and would cost a callback on every project file
change). Instead the list is rescanned when the Sessions section becomes
visible, plus on demand from the header's refresh button.
## Row plumbing
- `TreeItem.command` fires on **double-click**. A session row copies its resume
command; an action row runs its own action via `claudenova.sessionAction`,
which reads `sessionsView.selection[0]`.
- Session rows are `Collapsed` by default — with 25 rows, expanding all of them
would bury the list.
- `getParent()` returns the session for action rows so `reveal()` works.
## Gotchas
- `firstPrompt()` uses `readline()` and stops early: transcripts reach megabytes.
- Zero-byte and non-`.jsonl` entries are skipped.
-42
View File
@@ -1,42 +0,0 @@
# Sidebar
**Status:** Implemented · **Code:** `Scripts/sidebar.js`
Four TreeView sections under one sidebar (`claude`). Sections are declared in
`extension.json`; each is backed by a `TreeDataProvider` whose identifier matches
the section id.
| Section | Rows |
| --- | --- |
| `claude.session` | Status, Account, Model, Permissions, Usage |
| `claude.approvals` | Pending requests, each **expanded** with its choices as child rows |
| `claude.activity` | Tool calls, newest first, capped at 120 |
| `claude.changes` | Files Claude actually modified |
## Approvals are hierarchical
The parent row is the request; children are the actions from
`actionsFor(approval)` in `permissions.js`. Selecting a child and activating it
runs `claudenova.approvalAction`, which reads `approvalsView.selection[0]` and
calls `broker.decide(row.approval, row.id)`.
This is what makes a decision possible without any panel being open — dismissing
a notification never strands the session.
Action rows are generated, so they follow state: a plan shows
`Write Feedback…` until a feedback tab exists, then `Send Feedback`.
## Row plumbing
- `TreeItem.command` fires on **double-click**, not single click. Context menus
in `extension.json` mirror the same commands, gated on `contextValue`.
- `TreeItem.path` gives a file row its filetype icon and reveal behaviour.
- `getParent()` must return the parent for `reveal()` to work; action rows
return their approval.
- Reloads are throttled to 200ms — every streamed token changes the session.
## Labels
`friendlyToolName()` renames the interaction-surface tools: `ExitPlanMode`
`Plan`, `AskUserQuestion``Question`. `modeLabel()` maps permission modes;
note the CLI reports `default` for the mode its own flag calls `manual`.
-89
View File
@@ -1,89 +0,0 @@
# Tool approvals
**Status:** Implemented · **Code:** `Scripts/permissions.js`, `Scripts/diff.js`
Every `can_use_tool` control request becomes a decision surface. The request is
registered in the sidebar **first**, then surfaced — so dismissing a
notification or panel never strands the session.
## Routing
`_present(approval)` picks the surface:
| Request | Surface |
| --- | --- |
| `AskUserQuestion` | [Questions](questions.md) |
| `ExitPlanMode` | [Plan mode](plan-mode.md) |
| A file change (`preview` reconstructed) | Editable proposal, below |
| Other `requires_user_interaction` | Denied with an explanation |
| Anything else | Action panel (Allow / Allow for Session / Review / Deny) |
## `PermissionResult` shapes
```jsonc
{"behavior":"allow"}
{"behavior":"allow","updatedInput":{…}} // run with different input
{"behavior":"allow","updatedPermissions":[{…}]} // + change rules or mode
{"behavior":"deny","message":"…"} // message is shown to Claude
```
`updatedPermissions` entries used here: `{type:"addRules", rules:[{toolName}],
behavior:"allow", destination:"session"}` for *Allow for Session*, and
`{type:"setMode", mode, destination:"session"}` for plan approval.
## Editable proposals
The claudecode.nvim lesson: a proposal should be a **document you can amend**,
not a yes/no gate. When Claude wants to edit or create a file:
1. `diff.proposedChange()` reconstructs the file **as it would be after** the
change (applying `old_string``new_string`, or the `edits[]` in order).
2. That content is staged to
`<storage>/proposed/<last-8-of-request-id>/<basename>` and opened.
3. Editor gestures settle it:
| Gesture | Result |
| --- | --- |
| Save (`onDidSave`) | Apply — with any edits the user made |
| Close (`onDidDestroy`) | Deny |
A notification and the sidebar rows carry the same choices.
**Why a staged real file, not an untitled document:** an untitled document
cannot be saved without a Save dialog, which would break the gesture. Keeping
the original file extension also preserves syntax highlighting. Each request
stages into its own directory so two files with the same basename can be under
review at once while the tab still reads `index.js`. Stale proposals are cleared
on new session (`Controller.clearStagedProposals`).
**Expressing an amendment.** `_inputForContent()` rewrites the tool input as a
whole-file replacement rather than trying to recover a minimal edit:
| Tool | Amended input |
| --- | --- |
| `Write` | `{…input, content}` |
| `Edit` / `Update` | `{…input, old_string: <entire original file>, new_string: <content>, replace_all: false}` |
| `MultiEdit` | `{…input, edits:[{old_string: <entire original file>, new_string: <content>}]}` |
The whole original file is unique within itself by construction, so `old_string`
always matches exactly once — and the result is precisely what the user saw.
Set `claudenova.reviewEdits` to `panel` for the older quick yes/no.
## Diff rendering
`diff.js` is an LCS line diff with common prefix/suffix trimming and a
1,000,000-cell guard beyond which it degrades to a plain replacement. Rendered
unified with 3 lines of context, capped at 400 lines. `splitLines` drops the
final empty element after a trailing newline — otherwise every diff carried a
phantom blank line and a new file showed `-1`.
## Gotchas
- `requires_user_interaction: true` does **not** mean "cannot be handled" — it
means one-tap approve/deny is wrong because the tool's own card is the
interaction surface. Both tools that set it are handled natively; blanket
denying it (the first implementation) made plan mode a dead end.
- Sidebar `allow`/`deny` must not bypass the plan and question flows — a bare
allow on a plan would skip the `setMode` update and leave the session stuck in
plan mode. `decide()` routes those two back through `_present()`.
-45
View File
@@ -1,45 +0,0 @@
# Transcript
**Status:** Implemented · **Code:** `Scripts/transcript.js`
Nova sidebars hold only tree rows, so the readable conversation lives in an
editor tab as Markdown.
## Where it lives
`<workspaceStoragePath>/Claude Transcript.md` (global storage when the window
has no workspace). A real file rather than an untitled document so the tab has a
name, survives restarts, and can be saved without a dialog.
## Incremental rendering
`build()` produces the whole document; `_render()` diffs it against the previous
render, finds the common prefix, and replaces only the differing tail:
```js
edit.replace(new Range(prefix, current.length), text.slice(prefix));
```
Because output is append-mostly, the common prefix is usually the entire previous
text — one small edit at the end. This is what keeps scroll position and
selection stable while tokens stream in. Renders are throttled to 140ms;
`flush()` forces one and saves.
The viewport is only auto-scrolled when the caret was already at the end, so a
user reading earlier history is not yanked around.
## Format notes
- Tool results are fenced and truncated to 12 lines / 1200 chars.
- `Read` results are replaced by `<sub>read N lines</sub>` — echoing file
contents Claude just read buries the conversation.
- Fences widen past any backtick run in the body, so fenced content can't break
out.
- Thinking is rendered as a blockquote, controlled by `claudenova.showThinking`.
## Gotchas
- The document is user-editable. `_render()` diffs against the *document's*
current text, not a cached copy, so manual edits are overwritten rather than
corrupting the diff.
- `editor.onDidDestroy` clears the cached editor; the next `show()` reopens it.
+26 -93
View File
@@ -2,7 +2,7 @@
"identifier": "unsupervised.claudenova",
"name": "Claude",
"organization": "Unsupervised",
"description": "Claude Code in Nova — a chat sidebar, a live transcript, and tool approvals you review in the editor.",
"description": "Launch and resume Claude Code sessions for your project, and keep track of them from Nova's sidebar.",
"version": "1.0",
"min_runtime": "10",
"categories": ["sidebars", "commands"],
@@ -21,15 +21,9 @@
"commands": {
"extensions": [
{"title": "Ask Claude…", "command": "claudenova.ask", "shortcut": "cmd-shift-l"},
{"title": "Plan with Claude…", "command": "claudenova.plan", "shortcut": "cmd-shift-p"},
{"title": "Add Files to Context…", "command": "claudenova.addFiles"},
{"title": "Show Transcript", "command": "claudenova.showTranscript"},
{"separator": true},
{"title": "New Session", "command": "claudenova.newSession"},
{"title": "New Session…", "command": "claudenova.newSession", "shortcut": "cmd-shift-l"},
{"title": "Resume Session…", "command": "claudenova.resumeSession"},
{"title": "Stop Claude", "command": "claudenova.interrupt"},
{"title": "End Session", "command": "claudenova.endSession"},
{"title": "Refresh Sessions", "command": "claudenova.refreshSessions"},
{"separator": true},
{"title": "Set Model…", "command": "claudenova.setModel"},
{"title": "Set Permission Mode…", "command": "claudenova.setPermissionMode"},
@@ -41,8 +35,7 @@
{"title": "Clear Stored API Key", "command": "claudenova.clearApiKey"}
],
"editor": [
{"title": "Ask Claude About Selection…", "command": "claudenova.askAboutSelection", "shortcut": "cmd-ctrl-l"},
{"title": "Add File to Claude's Context", "command": "claudenova.addFileContext"}
{"title": "Copy File Reference for Claude", "command": "claudenova.copyFileReference"}
]
},
@@ -57,10 +50,10 @@
{
"key": "claudenova.model",
"title": "Model",
"description": "Model alias passed to Claude Code. “Default” uses whatever your Claude Code settings specify.",
"description": "Passed as --model to sessions this extension launches.",
"type": "enum",
"values": [
["", "Default"],
["", "Claude Code default"],
["opus", "Opus"],
["sonnet", "Sonnet"],
["haiku", "Haiku"],
@@ -71,10 +64,10 @@
{
"key": "claudenova.effort",
"title": "Effort",
"description": "How much thinking and exploration Claude does per turn.",
"description": "Passed as --effort to sessions this extension launches.",
"type": "enum",
"values": [
["", "Default"],
["", "Claude Code default"],
["low", "Low"],
["medium", "Medium"],
["high", "High"],
@@ -86,59 +79,22 @@
{
"key": "claudenova.permissionMode",
"title": "Permission mode",
"description": "How tool use is approved. “Manual” asks you before every tool that needs permission.",
"description": "Passed as --permission-mode to sessions this extension launches.",
"type": "enum",
"values": [
["", "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)"]
],
"default": "manual"
},
{
"key": "claudenova.reviewEdits",
"title": "Reviewing file changes",
"description": "How Claude's edits are presented when they need approval.",
"type": "enum",
"values": [
["editor", "Open the proposed file — save to apply, close to reject"],
["panel", "Ask in a panel, with the diff on request"]
],
"default": "editor"
},
{
"key": "claudenova.openTranscript",
"title": "Open the transcript automatically",
"description": "Open the transcript tab when a session starts.",
"type": "boolean",
"default": true
},
{
"key": "claudenova.streamPartial",
"title": "Stream text as it is generated",
"description": "Show Claude's reply token by token instead of message by message.",
"type": "boolean",
"default": true
},
{
"key": "claudenova.showThinking",
"title": "Show thinking in the transcript",
"type": "boolean",
"default": true
},
{
"key": "claudenova.includeSelection",
"title": "Include the editor selection with prompts",
"description": "When text is selected, send the file, line range, and selected text along with your question.",
"type": "boolean",
"default": true
"default": ""
},
{
"key": "claudenova.extraArgs",
"title": "Additional CLI arguments",
"description": "Space-separated arguments appended to the `claude` command line. For advanced use.",
"description": "Appended to the launch command. For advanced use.",
"type": "string",
"placeholder": "--add-dir ../shared"
}
@@ -182,61 +138,38 @@
"largeImage": "claude-large",
"sections": [
{
"id": "claude.session",
"name": "Session",
"id": "claude.status",
"name": "Claude",
"allowMultiple": false,
"placeholderText": "No session yet.\nPress New Session to start one.",
"placeholderText": "Checking Claude Code…",
"placeholderImage": "claude-placeholder",
"headerCommands": [
{"title": "Ask Claude", "image": "__builtin.action", "tooltip": "Ask Claude…", "command": "claudenova.ask"},
{"title": "New Session", "image": "__builtin.add", "tooltip": "Start a new session", "command": "claudenova.newSession"},
{"title": "New Session", "image": "__builtin.add", "tooltip": "Copy a command to start a session", "command": "claudenova.newSession"},
{"title": "Refresh", "image": "__builtin.refresh", "tooltip": "Refresh account status", "command": "claudenova.refreshStatus"}
],
"contextCommands": [
{"title": "Set Model…", "command": "claudenova.setModel"},
{"title": "Set Permission Mode…", "command": "claudenova.setPermissionMode"},
{"separator": true},
{"title": "Stop Claude", "command": "claudenova.interrupt"},
{"title": "End Session", "command": "claudenova.endSession"}
{"title": "Sign In…", "command": "claudenova.signIn"},
{"title": "Show Account Status", "command": "claudenova.authStatus"}
]
},
{
"id": "claude.approvals",
"name": "Needs Approval",
"id": "claude.sessions",
"name": "Sessions",
"allowMultiple": false,
"placeholderText": "Nothing waiting on you.",
"placeholderText": "No sessions yet.\nStart one to see it here.",
"placeholderImage": "claude-placeholder",
"contextCommands": [
{"title": "Choose", "command": "claudenova.approvalAction", "when": "viewItem == 'approval-action'"},
{"title": "Review…", "command": "claudenova.reviewApproval", "when": "viewItem == 'approval'"},
{"title": "Allow", "command": "claudenova.allowApproval", "when": "viewItem == 'approval'"},
{"title": "Allow for This Session", "command": "claudenova.allowApprovalAlways", "when": "viewItem == 'approval'"},
{"title": "Deny", "command": "claudenova.denyApproval", "when": "viewItem == 'approval'"}
]
},
{
"id": "claude.activity",
"name": "Activity",
"allowMultiple": false,
"placeholderText": "Claude's tool use shows up here.",
"headerCommands": [
{"title": "Show Transcript", "image": "__builtin.path", "tooltip": "Show the transcript", "command": "claudenova.showTranscript"}
{"title": "New Session", "image": "__builtin.add", "tooltip": "Copy a command to start a session", "command": "claudenova.newSession"},
{"title": "Refresh", "image": "__builtin.refresh", "tooltip": "Rescan this project's sessions", "command": "claudenova.refreshSessions"}
],
"contextCommands": [
{"title": "Open File", "command": "claudenova.openActivityFile", "when": "viewItem == 'activity-file'"},
{"title": "Copy Details", "command": "claudenova.copyActivity"}
]
},
{
"id": "claude.changes",
"name": "Changed Files",
"allowMultiple": true,
"placeholderText": "Files Claude edits appear here.",
"contextCommands": [
{"title": "Open File", "command": "claudenova.openChangedFile"},
{"title": "Show Change…", "command": "claudenova.showChangeDiff"},
{"title": "Choose", "command": "claudenova.sessionAction", "when": "viewItem == 'session-action'"},
{"title": "Copy Resume Command", "command": "claudenova.copyResume", "when": "viewItem == 'session'"},
{"separator": true},
{"title": "Clear List", "command": "claudenova.clearChanges"}
{"title": "Refresh Sessions", "command": "claudenova.refreshSessions"}
]
}
]